From fa3d21f6225d6a4867c4b0e4cfd70e1dac27fc70 Mon Sep 17 00:00:00 2001 From: James Kellie Date: Tue, 29 Sep 2026 19:57:30 +1000 Subject: [PATCH 01/31] Rebuild recruitment management for four forums - Enforce one listing and accepted-only creation cooldowns - Add Guidelines setup, owner controls and moderator recovery - Persist recoverable workflows and add regression coverage --- .../Recruitment/RecruitmentRecoveryTests.cs | 144 +++++ .../RecruitmentStorageAndSetupTests.cs | 179 ++++++ .../Recruitment/RecruitmentTestRig.cs | 116 ++++ .../Recruitment/RecruitmentWorkflowTests.cs | 259 ++++++++ .../Settings/BotSettingsValidationTests.cs | 16 +- .../recruitment/guidelines/hobby-for-hire.md | 13 + .../guidelines/hobby-recruiting.md | 13 + .../recruitment/guidelines/paid-for-hire.md | 13 + .../recruitment/guidelines/paid-recruiting.md | 13 + DiscordBot/Assets/recruitment/tags.json | 202 +++++++ DiscordBot/DiscordBot.csproj | 4 + .../Modules/Recruitment/RecruitmentModule.cs | 100 ++++ DiscordBot/Program.cs | 23 +- DiscordBot/Services/Profiles/XpService.cs | 5 +- .../Recruitment/DiscordRecruitmentAdapter.cs | 302 ++++++++++ .../Recruitment/IRecruitmentDiscord.cs | 32 + .../Services/Recruitment/RecruitmentAssets.cs | 158 +++++ .../Recruitment/RecruitmentContent.cs | 25 + .../Services/Recruitment/RecruitmentEngine.cs | 566 ++++++++++++++++++ .../Recruitment/RecruitmentPresentation.cs | 89 +++ .../Recruitment/RecruitmentService.cs | 195 ++++++ .../Services/Recruitment/RecruitmentState.cs | 201 +++++++ .../Recruitment/RecruitmentStateStore.cs | 125 ++++ DiscordBot/Services/Server/RecruitService.cs | 398 ------------ DiscordBot/Settings/Deserialized/Settings.cs | 59 +- DiscordBot/Settings/Settings.example.json | 18 +- Dockerfile | 1 + docs/recruitment.md | 91 +++ k8s/dev/bot-config.yaml | 19 +- k8s/prod/bot-config.yaml | 18 +- 30 files changed, 2947 insertions(+), 450 deletions(-) create mode 100644 DiscordBot.Tests/Recruitment/RecruitmentRecoveryTests.cs create mode 100644 DiscordBot.Tests/Recruitment/RecruitmentStorageAndSetupTests.cs create mode 100644 DiscordBot.Tests/Recruitment/RecruitmentTestRig.cs create mode 100644 DiscordBot.Tests/Recruitment/RecruitmentWorkflowTests.cs create mode 100644 DiscordBot/Assets/recruitment/guidelines/hobby-for-hire.md create mode 100644 DiscordBot/Assets/recruitment/guidelines/hobby-recruiting.md create mode 100644 DiscordBot/Assets/recruitment/guidelines/paid-for-hire.md create mode 100644 DiscordBot/Assets/recruitment/guidelines/paid-recruiting.md create mode 100644 DiscordBot/Assets/recruitment/tags.json create mode 100644 DiscordBot/Modules/Recruitment/RecruitmentModule.cs create mode 100644 DiscordBot/Services/Recruitment/DiscordRecruitmentAdapter.cs create mode 100644 DiscordBot/Services/Recruitment/IRecruitmentDiscord.cs create mode 100644 DiscordBot/Services/Recruitment/RecruitmentAssets.cs create mode 100644 DiscordBot/Services/Recruitment/RecruitmentContent.cs create mode 100644 DiscordBot/Services/Recruitment/RecruitmentEngine.cs create mode 100644 DiscordBot/Services/Recruitment/RecruitmentPresentation.cs create mode 100644 DiscordBot/Services/Recruitment/RecruitmentService.cs create mode 100644 DiscordBot/Services/Recruitment/RecruitmentState.cs create mode 100644 DiscordBot/Services/Recruitment/RecruitmentStateStore.cs delete mode 100644 DiscordBot/Services/Server/RecruitService.cs create mode 100644 docs/recruitment.md diff --git a/DiscordBot.Tests/Recruitment/RecruitmentRecoveryTests.cs b/DiscordBot.Tests/Recruitment/RecruitmentRecoveryTests.cs new file mode 100644 index 00000000..353076fc --- /dev/null +++ b/DiscordBot.Tests/Recruitment/RecruitmentRecoveryTests.cs @@ -0,0 +1,144 @@ +using Discord.Interactions; +using Discord.WebSocket; +using DiscordBot.Modules.Recruitment; +using DiscordBot.Services.Recruitment; +using DiscordBot.Settings; +using Microsoft.Extensions.DependencyInjection; + +namespace DiscordBot.Tests.Recruitment; + +public class RecruitmentRecoveryTests +{ + [Fact] + public async Task InteractionModuleRegistersWithoutConnectingOrStartingTheFeature() + { + using var client = new DiscordSocketClient(); + using var interactions = new InteractionService(client); + var service = new RecruitmentService(client, new BotSettings(), null!); + using var provider = new ServiceCollection().AddSingleton(service).BuildServiceProvider(); + var module = await interactions.AddModuleAsync(provider); + Assert.Equal(7, module.SlashCommands.Count); + Assert.Equal(4, module.ComponentCommands.Count); + Assert.Single(module.ModalCommands); + } + + [Fact] + public async Task LostCloseResponseIsConfirmedAfterRestartWithoutRepeatingAction() + { + using var rig = new RecruitmentRig(); await rig.Start(); await rig.Post(); await rig.Ack(); + rig.Discord.LoseNextCloseResponse = true; + rig.Clock.Advance(TimeSpan.FromDays(31)); await rig.Sweep(); await rig.Restart(); + Assert.Equal(ListingStatus.Closed, rig.State.Listings["100"].Status); + Assert.Single(rig.Discord.Actions); + Assert.Null(rig.State.Listings["100"].PendingAction); + } + + [Fact] + public async Task MissingStarterReservesCapacityAndGetsFullWindowAfterRecovery() + { + using var rig = new RecruitmentRig(); await rig.Start(); + var starter = new ThreadSnapshot("100", "11", "200", "Delayed starter", rig.Clock.Now, false, false, false, false, + Array.Empty(), null, null, null); + rig.Discord.Threads["100"] = starter; await rig.Sweep(); + Assert.Null(rig.State.Listings["100"].Deadline); + var newer = await rig.Post("101", forum: "14"); await rig.Ack("101"); Assert.Null(newer.AcceptedAt); + rig.Clock.Advance(TimeSpan.FromHours(2)); + rig.Discord.Threads["100"] = starter with { Starter = "Now available" }; await rig.Sweep(); + Assert.Equal(rig.Clock.Now.AddMinutes(30), rig.State.Listings["100"].Deadline); + } + + [Fact] + public async Task FirstEnableDiscoversPostsCreatedBeforeBotStartup() + { + using var rig = new RecruitmentRig(); + rig.Discord.Threads["100"] = new ThreadSnapshot("100", "11", "200", "Before startup", rig.Clock.Now.AddDays(-2), false, false, false, false, + Array.Empty(), "A listing", null, null); + await rig.Start(); + Assert.Equal(rig.Clock.Now.AddMinutes(30), rig.State.Listings["100"].Deadline); + Assert.Empty(rig.Discord.Actions); + } + + [Fact] + public async Task RecreatedPromptGetsFreshUsableWindow() + { + using var rig = new RecruitmentRig(); await rig.Start(); await rig.Post(); + rig.Clock.Advance(TimeSpan.FromMinutes(31)); rig.Discord.Messages.Remove("100:recruitment:100:public"); + await rig.Sweep(); + Assert.Equal(rig.Clock.Now.AddMinutes(30), rig.State.Listings["100"].Deadline); + Assert.Empty(rig.Discord.Actions); + } + + [Fact] + public async Task FourthTimeoutDoesNotSendAnotherThresholdAlert() + { + using var rig = new RecruitmentRig(); await rig.Start(); + for (var i = 0; i < 4; i++) { await rig.Post((100 + i).ToString()); rig.Clock.Advance(TimeSpan.FromMinutes(31)); await rig.Sweep(); } + Assert.Contains("Moderator attention", rig.Discord.Messages["99:recruitment:102:staff"].Message.Content); + Assert.DoesNotContain("Moderator attention", rig.Discord.Messages["99:recruitment:103:staff"].Message.Content); + } + + [Fact] + public async Task StoreWriteFailureFencesLaterActionsAndPreservesBackup() + { + using var rig = new RecruitmentRig(); await rig.Start(); await rig.Post(); + var path = Path.Combine(rig.Root, "recruitment/recruitment-state.json"); + var backup = File.ReadAllText(path + ".bak"); File.WriteAllText(path, "broken"); + rig.Clock.Advance(TimeSpan.FromHours(1)); await rig.Sweep(); + Assert.Empty(rig.Discord.Actions); Assert.Contains("state write failure", rig.Engine.Status); + Assert.Equal(backup, File.ReadAllText(path + ".bak")); + Assert.Contains("unavailable", await rig.Engine.CloseOwnerAsync("1", "100", "200", default)); + } + + [Fact] + public async Task TimelyQueuedAcknowledgementWinsAgainstTimeoutEvenIfWorkerRunsLate() + { + using var rig = new RecruitmentRig(); await rig.Start(); var post = await rig.Post(); + var received = post.Deadline!.Value.AddSeconds(-1); + rig.Clock.Now = post.Deadline.Value.AddMinutes(1); + rig.PendingThreadWork.Add(post.Id); + var result = await rig.Engine.AcknowledgeAsync("1", post.Id, post.OwnerId, post.ValidCodes.Last(), default, received); + Assert.Contains("accepted", result); Assert.Empty(rig.Discord.Actions); + Assert.NotNull(post.AcceptedAt); + } + + [Fact] + public async Task LateSubmissionDoesNotBypassDeadlineEvenWhenTimeoutIsQueued() + { + using var rig = new RecruitmentRig(); await rig.Start(); var post = await rig.Post(); + rig.Clock.Now = post.Deadline!.Value.AddMinutes(1); rig.PendingThreadWork.Add(post.Id); + Assert.Contains("expired", await rig.Ack()); Assert.Null(post.AcceptedAt); + rig.PendingThreadWork.Clear(); await rig.Sweep(); + Assert.Equal(ListingStatus.Deleted, post.Status); + } + + [Fact] + public async Task MissingThreadClearsUnfinishedPublicDeliveryAndDoesNotResetTerminalRetention() + { + using var rig = new RecruitmentRig(); await rig.Start(); var post = await rig.Post(); + post.PublicSendPending = true; rig.Discord.Threads.Remove(post.Id); await rig.Sweep(); + var deletedAt = post.UpdatedAt; + Assert.False(post.PublicSendPending); Assert.False(post.PublicationPending); + rig.Clock.Advance(TimeSpan.FromDays(2)); await rig.Sweep(); + Assert.Equal(deletedAt, post.UpdatedAt); + } + + [Fact] + public async Task StaffPreviewRedactsRotatingAnswerAndIncludesFullGuidelines() + { + using var rig = new RecruitmentRig(); await rig.Start(); + var preview = await rig.Staff("preview", "paid-recruiting"); + Assert.Contains("one open listing total", preview); Assert.Contains("Managed tags", preview); + Assert.DoesNotContain(rig.State.RotationCode, preview); + } + + [Fact] + public async Task StaffReopenCannotGrantSecondSlot() + { + using var rig = new RecruitmentRig(); await rig.Start(); await rig.Post(); await rig.Ack(); await rig.Staff("close"); + rig.Clock.Advance(TimeSpan.FromDays(31)); await rig.Post("101"); await rig.Ack("101"); + Assert.Contains("Another listing", await rig.Staff("reopen")); + Assert.Null(rig.State.Listings["100"].PendingAction); + Assert.Equal(ListingStatus.Closed, rig.State.Listings["100"].Status); + Assert.Equal(ListingStatus.Open, rig.State.Listings["101"].Status); + } +} diff --git a/DiscordBot.Tests/Recruitment/RecruitmentStorageAndSetupTests.cs b/DiscordBot.Tests/Recruitment/RecruitmentStorageAndSetupTests.cs new file mode 100644 index 00000000..45b4a39d --- /dev/null +++ b/DiscordBot.Tests/Recruitment/RecruitmentStorageAndSetupTests.cs @@ -0,0 +1,179 @@ +using DiscordBot.Services.Recruitment; +using ImageMagick; +using Newtonsoft.Json.Linq; + +namespace DiscordBot.Tests.Recruitment; + +public class RecruitmentStorageAndSetupTests +{ + [Fact] + public async Task StoreRoundTripsCooldownAndRefusesSecondWriter() + { + using var rig = new RecruitmentRig(); await rig.Start(); var post = await rig.Post(); await rig.Ack(); + Assert.Throws(() => new RecruitmentStateStore(rig.Root, "1", rig.Settings.Recruitment.Forums.Mapping().ToDictionary(x => x.Key, x => x.Value.ToString()), rig.Clock.Now)); + await rig.Restart(); Assert.Equal(post.CreatedAt, rig.State.Authors["200"].LastAcceptedCreation); + Assert.NotNull(rig.State.Listings["100"].AcceptedAt); + } + + [Theory] + [InlineData("corrupt")] + [InlineData("missing")] + [InlineData("authors")] + [InlineData("schema")] + public async Task LostOrMalformedHistoryStopsFeatureWithoutSilentlyResetting(string damage) + { + using var rig = new RecruitmentRig(); await rig.Start(); await rig.Post(); await rig.Ack(); rig.Engine.Dispose(); + var path = Path.Combine(rig.Root, "recruitment/recruitment-state.json"); + if (damage == "missing") File.Delete(path); + else if (damage == "corrupt") File.WriteAllText(path, "{broken"); + else + { + var json = JObject.Parse(File.ReadAllText(path)); + if (damage == "authors") json.Remove("Authors"); + else json["Schema"] = "legacy-v1"; + File.WriteAllText(path, json.ToString()); + } + var damaged = File.Exists(path) ? File.ReadAllText(path) : null; + await rig.Restart(); Assert.Null(rig.Engine.State); Assert.Contains("stopped", rig.Engine.Status); + Assert.Equal(damaged, File.Exists(path) ? File.ReadAllText(path) : null); + } + + [Fact] + public async Task ChangedForumIdsCannotAdoptExistingHistory() + { + using var rig = new RecruitmentRig(); await rig.Start(); + rig.Settings.Recruitment.Forums.PaidRecruitingId = 15; + await rig.Restart(); Assert.Null(rig.Engine.State); Assert.Contains("stopped", rig.Engine.Status); + } + + [Fact] + public async Task SetupIsIdempotentAndKeepsTagIdentities() + { + using var rig = new RecruitmentRig(); await rig.Start(); + var ids = rig.State.Forums["paid-recruiting"].Tags.ToDictionary(t => t.Key, t => t.Value.Id); + Assert.Equal(4, rig.Discord.ForumWrites); await rig.Sweep(); await rig.Restart(); + Assert.Equal(4, rig.Discord.ForumWrites); + Assert.Equal(ids, rig.State.Forums["paid-recruiting"].Tags.ToDictionary(t => t.Key, t => t.Value.Id)); + Assert.All(rig.Discord.Forums.Values, f => Assert.True(f.RequireTag)); + } + + [Fact] + public async Task UnexpectedGuidelinesEditsHoldActionsUntilExplicitRepair() + { + using var rig = new RecruitmentRig(); await rig.Start(); var post = await rig.Post(); + rig.Discord.Forums["11"] = rig.Discord.Forums["11"] with { Topic = "Moderator change" }; + rig.Clock.Advance(TimeSpan.FromHours(1)); await rig.Sweep(); + Assert.Contains("Guidelines changed", rig.Engine.Status); Assert.Empty(rig.Discord.Actions); + Assert.Equal("Moderator change", rig.Discord.Forums["11"].Topic); + await rig.Staff("repair"); Assert.DoesNotContain("Moderator change", rig.Discord.Forums["11"].Topic); + } + + [Fact] + public async Task DeletedManagedTagRequiresExplicitRepairAndPreservesOtherIdentities() + { + using var rig = new RecruitmentRig(); await rig.Start(); + var receipt = rig.State.Forums["paid-recruiting"]; + var missing = receipt.Tags["closed"].Id; + var preserved = receipt.Tags["programming"].Id; + rig.Discord.Forums["11"] = rig.Discord.Forums["11"] with { Tags = rig.Discord.Forums["11"].Tags.Where(t => t.Id != missing).ToArray() }; + await rig.Sweep(); Assert.Contains("managed tag is missing", rig.Engine.Status); + await rig.Staff("repair"); + Assert.NotEqual(missing, receipt.Tags["closed"].Id); + Assert.Equal(preserved, receipt.Tags["programming"].Id); + } + + [Fact] + public async Task FirstUseDoesNotOverwriteUnexpectedForumContent() + { + using var rig = new RecruitmentRig(); rig.Discord.Forums["11"] = rig.Discord.Forums["11"] with { Topic = "Existing Guidelines" }; + await rig.Start(); Assert.Contains("Guidelines changed", rig.Engine.Status); + Assert.Equal(0, rig.Discord.ForumWrites); + } + + [Fact] + public async Task PartialRotationRetriesSameCode_AndAcceptsPreviouslyIssuedCode() + { + using var rig = new RecruitmentRig(); await rig.Start(); + rig.Clock.Now = rig.State.RotateAt.AddMinutes(-5); + var post = await rig.Post(); var issued = post.ValidCodes.Single(); + rig.Clock.Advance(TimeSpan.FromMinutes(6)); rig.Discord.FailForum = "12"; + await rig.Sweep(); var rotating = rig.State.RotationCode; + Assert.Equal(rotating, rig.State.Forums["paid-recruiting"].VisibleCode); + Assert.NotEqual(rotating, rig.State.Forums["paid-for-hire"].VisibleCode); + rig.Discord.FailForum = null; await rig.Sweep(); + Assert.Equal(rotating, rig.State.RotationCode); + Assert.Contains("accepted", await rig.Ack(code: issued)); + Assert.DoesNotContain(rotating, rig.Discord.Messages["99:recruitment:100:staff"].Message.Content); + Assert.DoesNotContain(rotating, rig.Discord.Messages["100:recruitment:100:public"].Message.Content); + } + + [Fact] + public async Task RestartNeverClosesAcceptedListingBasedOnIncompleteHistoricalReplies() + { + using var rig = new RecruitmentRig(); await rig.Start(); await rig.Post(); await rig.Ack(); + rig.Clock.Advance(TimeSpan.FromDays(31)); await rig.Restart(); + Assert.Equal(ListingStatus.Open, rig.State.Listings["100"].Status); + Assert.False(rig.State.Listings["100"].ResponseCoverageComplete); + await rig.Staff("review-no-reply"); await rig.Sweep(); + Assert.Equal(ListingStatus.Closed, rig.State.Listings["100"].Status); + } + + [Fact] + public async Task RetentionExpiresTerminalDetailsButKeepsOpenAndReviewRecords() + { + using var rig = new RecruitmentRig(); await rig.Start(); + var terminal = await rig.Post(); await rig.Staff("close"); + var open = await rig.Post("101", "201"); await rig.Ack("101", "201"); + open.Exempt = true; + rig.Clock.Advance(TimeSpan.FromDays(400)); await rig.Sweep(); + Assert.DoesNotContain("100", rig.State.Listings.Keys); + Assert.Contains("101", rig.State.Listings.Keys); + await rig.Sweep(); Assert.DoesNotContain("100", rig.State.Listings.Keys); + Assert.True(rig.State.DiscoveryFloor > terminal.CreatedAt); + } + + [Fact] + public async Task ClosingOldListingRefreshesAuthorRetentionWithoutChangingCooldownAnchor() + { + using var rig = new RecruitmentRig(); await rig.Start(); var post = await rig.Post(); await rig.Ack(); + rig.Clock.Advance(TimeSpan.FromDays(800)); + await rig.Staff("close"); + Assert.Equal(rig.Clock.Now, rig.State.Authors[post.OwnerId].UpdatedAt); + Assert.Equal(post.CreatedAt, rig.State.Authors[post.OwnerId].LastAcceptedCreation); + } + + [Fact] + public async Task DisabledServiceDoesNotCreateStateOrPublish_ButForumsStillExcludeXp() + { + using var rig = new RecruitmentRig(); rig.Settings.Recruitment.Enabled = false; await rig.Start(); + Assert.Null(rig.Engine.State); Assert.False(Directory.Exists(rig.Root)); + Assert.Empty(rig.Discord.Messages); Assert.Equal(0, rig.Discord.ForumWrites); + Assert.True(rig.Settings.Recruitment.Forums.Contains(11)); + Assert.True(rig.Settings.Recruitment.Forums.Contains(12345, 14)); + Assert.False(rig.Settings.Recruitment.Forums.Contains(12345, 15)); + Assert.False(rig.Settings.Recruitment.Forums.Contains(0)); + } + + [Theory] + [InlineData("USD 2000", "concrete rate or budget stated")] + [InlineData("$35/hour", "concrete rate or budget stated")] + [InlineData("150 EUR per day", "concrete rate or budget stated")] + [InlineData("Revenue-share", "ambiguous / discuss payment terms")] + [InlineData("Competitive rates", "ambiguous / discuss payment terms")] + [InlineData("A Unity hobby project", "no concrete rate or budget found")] + [InlineData(null, "unknown")] + public void PaymentIsNeutralAndAdvisory(string? text, string expected) => Assert.Equal(expected, RecruitmentContent.Payment(text)); + + [Fact] + public async Task BannerUsesPackagedFontAndBoundedImage_WithEssentialTextFallback() + { + using var rig = new RecruitmentRig(); await rig.Start(); var post = await rig.Post(); + var png = RecruitmentPresentation.RenderBanner(post, rig.Settings.AssetsRootPath); + Assert.NotNull(png); Assert.InRange(png.Length, 100, 512 * 1024); + using var image = new MagickImage(png); Assert.Equal(900u, image.Width); Assert.Equal(270u, image.Height); + Assert.Null(RecruitmentPresentation.RenderBanner(post, "/nonexistent/udc-assets")); + var fallback = RecruitmentPresentation.Public(rig.State, post, rig.Settings.Recruitment, rig.Clock.Now); + Assert.Contains("Community activity: unknown", fallback.Content); Assert.Contains("Initial snapshot", fallback.Content); + File.WriteAllBytes(Path.Combine(Path.GetTempPath(), "udc-recruitment-preview.png"), png); + } +} diff --git a/DiscordBot.Tests/Recruitment/RecruitmentTestRig.cs b/DiscordBot.Tests/Recruitment/RecruitmentTestRig.cs new file mode 100644 index 00000000..5ae40dfa --- /dev/null +++ b/DiscordBot.Tests/Recruitment/RecruitmentTestRig.cs @@ -0,0 +1,116 @@ +using DiscordBot.Services.Recruitment; +using DiscordBot.Settings; + +namespace DiscordBot.Tests.Recruitment; + +internal sealed class ManualClock : TimeProvider +{ + public DateTimeOffset Now { get; set; } = new(2026, 9, 29, 10, 0, 0, TimeSpan.Zero); + public override DateTimeOffset GetUtcNow() => Now; + public void Advance(TimeSpan duration) => Now += duration; +} + +internal sealed class RecruitmentRig : IDisposable +{ + public string Root { get; } = Path.Combine(Path.GetTempPath(), "udc-recruitment-tests-" + Guid.NewGuid().ToString("N")); + public HashSet PendingThreadWork { get; } = new(); + public ManualClock Clock { get; } = new(); + public BotSettings Settings { get; } + public FakeRecruitmentDiscord Discord { get; } = new(); + public RecruitmentEngine Engine { get; private set; } + public RecruitmentState State => Engine.State!; + public RecruitmentRig() + { + Settings = new BotSettings { GuildId = 1, ServerRootPath = Root, AssetsRootPath = Path.Combine(AppContext.BaseDirectory, "Assets"), + Recruitment = new RecruitmentSettings { Enabled = true, FeedChannelId = 99, + Forums = new RecruitmentForums { PaidRecruitingId = 11, PaidForHireId = 12, HobbyRecruitingId = 13, HobbyForHireId = 14 } } }; + foreach (var id in Settings.Recruitment.Forums.Mapping().Values) + Discord.Forums[id.ToString()] = new ForumSnapshot(id.ToString(), "", false, Array.Empty()); + Engine = new RecruitmentEngine(Settings, Discord, Clock, PendingThreadWork.Contains); + } + public Task Start() => Engine.StartAsync(default); + public Task Sweep() => Engine.SweepAsync(default); + public async Task Restart() + { Engine.Dispose(); Engine = new RecruitmentEngine(Settings, Discord, Clock, PendingThreadWork.Contains); await Start(); } + public async Task Post(string id = "100", string owner = "200", string forum = "11", bool live = true, DateTimeOffset? created = null) + { + var thread = new ThreadSnapshot(id, forum, owner, "Example listing", created ?? Clock.Now, false, false, false, false, + Array.Empty(), "Budget AUD 500 for a short Unity project", Clock.Now.AddYears(-2), Clock.Now.AddMonths(-3)); + Discord.Threads[id] = thread; + await Engine.ObserveAsync(thread, live, default); + await Sweep(); + return State.Listings[id]; + } + public Task Ack(string id = "100", string owner = "200", string? code = null) => + Engine.AcknowledgeAsync("1", id, owner, code ?? State.Listings[id].ValidCodes.Last(), default); + public Task Staff(string operation, string target = "100", string reason = "Test recovery", string actor = "900") => + Engine.StaffAsync("1", actor, operation, target, reason, default); + public void Dispose() { Engine.Dispose(); if (Directory.Exists(Root)) Directory.Delete(Root, true); } +} + +internal sealed class FakeRecruitmentDiscord : IRecruitmentDiscord +{ + public Dictionary Forums { get; } = new(); + public Dictionary Threads { get; } = new(); + public Dictionary Messages { get; } = new(); + public List<(string Thread, string Kind)> Actions { get; } = new(); + public HashSet UnknownThreads { get; } = new(); + public HashSet Replies { get; } = new(); + public bool InventoryComplete { get; set; } = true; + public bool FailStaff { get; set; } + public bool LoseNextSendResponse { get; set; } + public bool LoseNextCloseResponse { get; set; } + public bool LoseNextDeleteResponse { get; set; } + public bool FailPermissions { get; set; } + public string? FailForum { get; set; } + public Action? BeforeDelete { get; set; } + public int Sends { get; private set; } + public int ForumWrites { get; private set; } + private int _nextId = 1000; + public Task ValidateAccessAsync(CancellationToken ct) => FailPermissions ? Task.FromException(new InvalidOperationException("Missing permissions")) : Task.CompletedTask; + public Task IsModeratorAsync(string guildId, string userId, CancellationToken ct) => Task.FromResult(guildId == "1" && userId == "900"); + public Task GetForumAsync(string id, CancellationToken ct) => Task.FromResult(Forums[id]); + public Task PublishForumAsync(string id, string topic, IReadOnlyList tags, CancellationToken ct) + { + if (FailForum == id) throw new InvalidOperationException("Forum write failed"); + ForumWrites++; + Forums[id] = new ForumSnapshot(id, topic, true, tags.Select(t => t with { Id = t.Id == "" ? (++_nextId).ToString() : t.Id }).ToArray()); + return Task.CompletedTask; + } + public Task InventoryAsync(DateTimeOffset floor, IReadOnlySet knownIds, IReadOnlyDictionary cursors, CancellationToken ct) => Task.FromResult(new ThreadInventory(Threads.Values.Where(t => t.CreatedAt >= floor).ToArray(), InventoryComplete)); + public Task GetThreadAsync(string id, CancellationToken ct) => UnknownThreads.Contains(id) + ? Task.FromException(new InvalidOperationException("Forbidden")) : Task.FromResult(Threads.GetValueOrDefault(id)); + public Task HasReplyAsync(ThreadSnapshot thread, CancellationToken ct) => Task.FromResult(Replies.Contains(thread.Id)); + public Task GetActivityAsync(string userId, CancellationToken ct) => Task.FromResult("unknown"); + public Task UpsertAsync(string channelId, string? messageId, bool recovering, DateTimeOffset since, RecruitmentMessage message, Action beforeWrite, CancellationToken ct) + { + if (FailStaff && channelId == "99") throw new InvalidOperationException("Feed unavailable"); + var key = channelId + ":" + message.Marker; + if (Messages.TryGetValue(key, out var existing)) + { + if (existing.Message.Content != message.Content) beforeWrite(); + Messages[key] = (existing.Id, message); return Task.FromResult(new Delivery(existing.Id, false)); + } + beforeWrite(); + var id = (++_nextId).ToString(); Messages[key] = (id, message); Sends++; + if (LoseNextSendResponse) { LoseNextSendResponse = false; throw new InvalidOperationException("Ambiguous send"); } + return Task.FromResult(new Delivery(id, true)); + } + public Task CloseAsync(ThreadSnapshot thread, string? closedTagId, bool protectPinned, CancellationToken ct) + { + Threads[thread.Id] = thread with { Archived = true, Locked = true }; Actions.Add((thread.Id, "close")); + if (LoseNextCloseResponse) { LoseNextCloseResponse = false; throw new InvalidOperationException("Ambiguous close"); } + return Task.CompletedTask; + } + public Task ReopenAsync(ThreadSnapshot thread, string? closedTagId, CancellationToken ct) + { Threads[thread.Id] = thread with { Archived = false, Locked = false }; Actions.Add((thread.Id, "reopen")); return Task.CompletedTask; } + public Task DeleteAsync(ThreadSnapshot thread, string reason, bool protectPinned, CancellationToken ct) + { + var id = thread.Id; + BeforeDelete?.Invoke(); Threads.Remove(id); Actions.Add((id, "delete")); + if (LoseNextDeleteResponse) { LoseNextDeleteResponse = false; throw new InvalidOperationException("Ambiguous delete"); } + return Task.CompletedTask; + } + public Task DeleteAdvisoryAsync(string id, string messageId, CancellationToken ct) + { foreach (var key in Messages.Where(x => x.Value.Id == messageId).Select(x => x.Key).ToArray()) Messages.Remove(key); return Task.CompletedTask; } +} diff --git a/DiscordBot.Tests/Recruitment/RecruitmentWorkflowTests.cs b/DiscordBot.Tests/Recruitment/RecruitmentWorkflowTests.cs new file mode 100644 index 00000000..0b7aadc5 --- /dev/null +++ b/DiscordBot.Tests/Recruitment/RecruitmentWorkflowTests.cs @@ -0,0 +1,259 @@ +using DiscordBot.Services.Recruitment; + +namespace DiscordBot.Tests.Recruitment; + +public class RecruitmentWorkflowTests +{ + [Fact] + public async Task CorrectCodeAcceptsImmediately_UsingOriginalCreationTime() + { + using var rig = new RecruitmentRig(); await rig.Start(); + var post = await rig.Post(); rig.Clock.Advance(TimeSpan.FromMinutes(2)); + Assert.Contains("accepted", await rig.Ack()); + Assert.Equal(rig.Clock.Now, post.AcceptedAt); + Assert.Equal(post.CreatedAt, rig.State.Authors[post.OwnerId].LastAcceptedCreation); + } + + [Theory] + [InlineData(2, 28)] + [InlineData(40, 0)] + public async Task AcceptedDeletionKeepsCreationCooldown(int elapsedDays, int remainingDays) + { + using var rig = new RecruitmentRig(); await rig.Start(); + var post = await rig.Post(); await rig.Ack(); + rig.Clock.Advance(TimeSpan.FromDays(elapsedDays)); + rig.Discord.Threads.Remove(post.Id); await rig.Sweep(); + var replacement = await rig.Post("101", forum: "14"); + var policy = RecruitmentPolicy.Evaluate(rig.State, replacement, rig.Clock.Now, 30); + Assert.Equal(remainingDays == 0, policy.Allowed); + Assert.Equal(post.CreatedAt, rig.State.Authors[post.OwnerId].LastAcceptedCreation); + if (remainingDays > 0) Assert.Equal(rig.Clock.Now.AddDays(remainingDays), policy.NextAllowedAt); + } + + [Fact] + public async Task UnacceptedRemovalNeverCreatesCooldown() + { + using var rig = new RecruitmentRig(); await rig.Start(); await rig.Post(); + var confirmation = rig.Engine.PrepareRemoval("1", "100", "200"); + await rig.Engine.ConfirmRemovalAsync("1", "100", "200", confirmation.Token!, default); + Assert.Null(rig.State.Authors["200"].LastAcceptedCreation); + await rig.Post("101"); Assert.Contains("accepted", await rig.Ack("101")); + } + + [Fact] + public async Task AllForumsShareOneSlot_EvenAfterThirtyDaysOrNaturalArchive() + { + using var rig = new RecruitmentRig(); await rig.Start(); await rig.Post(); await rig.Ack(); + rig.Discord.Replies.Add("100"); rig.Clock.Advance(TimeSpan.FromDays(40)); + rig.Discord.Threads["100"] = rig.Discord.Threads["100"] with { Archived = true }; + var second = await rig.Post("101", forum: "14"); await rig.Ack("101"); + Assert.Null(second.AcceptedAt); Assert.NotNull(second.AcknowledgedAt); + Assert.Equal("100", RecruitmentPolicy.Evaluate(rig.State, second, rig.Clock.Now, 30).BlockingListingId); + } + + [Fact] + public async Task EarlierPendingAttemptWinsAcrossForums_AndAcknowledgedLoserCloses() + { + using var rig = new RecruitmentRig(); await rig.Start(); + await rig.Post(); var second = await rig.Post("101", forum: "12"); + await rig.Ack("101"); Assert.Null(second.AcceptedAt); + await rig.Ack(); Assert.NotNull(rig.State.Listings["100"].AcceptedAt); + rig.Clock.Advance(TimeSpan.FromMinutes(31)); await rig.Sweep(); + Assert.Equal(ListingStatus.Closed, second.Status); + Assert.DoesNotContain(("101", "delete"), rig.Discord.Actions); + } + + [Fact] + public async Task AcknowledgedAttemptAcceptsWhenPendingBlockerDisappears() + { + using var rig = new RecruitmentRig(); await rig.Start(); await rig.Post(); + var second = await rig.Post("101", forum: "13"); await rig.Ack("101"); + rig.Discord.Threads.Remove("100"); await rig.Sweep(); + Assert.NotNull(second.AcceptedAt); + } + + [Fact] + public async Task TimeoutPersistsIntentAndStaffEvidenceBeforeDelete_WithoutCooldown() + { + using var rig = new RecruitmentRig(); await rig.Start(); var post = await rig.Post(); + rig.Discord.BeforeDelete = () => + { + Assert.NotNull(post.PendingAction); Assert.NotNull(post.StaffMessageId); + var json = File.ReadAllText(Path.Combine(rig.Root, "recruitment/recruitment-state.json")); + Assert.Contains("Guidelines acknowledgement timed out", json); + Assert.Contains(post.PendingAction!.Id, json); + }; + rig.Clock.Advance(TimeSpan.FromMinutes(31)); await rig.Sweep(); + Assert.Equal(ListingStatus.Deleted, post.Status); + Assert.Equal(1, rig.State.Authors["200"].ConsecutiveTimeouts); + Assert.Null(rig.State.Authors["200"].LastAcceptedCreation); + await rig.Sweep(); Assert.Single(rig.Discord.Actions); + } + + [Fact] + public async Task FailedStaffDeliveryPreventsDeletion() + { + using var rig = new RecruitmentRig(); await rig.Start(); var post = await rig.Post(); + rig.Clock.Advance(TimeSpan.FromMinutes(31)); rig.Discord.FailStaff = true; await rig.Sweep(); + Assert.Empty(rig.Discord.Actions); Assert.Equal(ListingStatus.Open, post.Status); + } + + [Fact] + public async Task LostDeleteResponseDoesNotInventDeletionActorOrTimeoutStreak() + { + using var rig = new RecruitmentRig(); await rig.Start(); await rig.Post(); + rig.Discord.LoseNextDeleteResponse = true; rig.Clock.Advance(TimeSpan.FromMinutes(31)); + await rig.Sweep(); await rig.Restart(); + Assert.Equal(ListingStatus.Deleted, rig.State.Listings["100"].Status); + Assert.Equal(0, rig.State.Authors["200"].ConsecutiveTimeouts); + Assert.Single(rig.Discord.Actions); + } + + [Fact] + public async Task AmbiguousSendRecoversOneMessagePerDestination() + { + using var rig = new RecruitmentRig(); await rig.Start(); rig.Discord.LoseNextSendResponse = true; + await rig.Post(); await rig.Restart(); await rig.Sweep(); + Assert.Equal(2, rig.Discord.Sends); + Assert.Equal(2, rig.Discord.Messages.Count); + Assert.NotNull(rig.State.Listings["100"].Deadline); + } + + [Fact] + public async Task RestartGivesPendingAttemptFullWindow_AndHoldsIncompleteReplyHistory() + { + using var rig = new RecruitmentRig(); await rig.Start(); await rig.Post(); + rig.Clock.Advance(TimeSpan.FromHours(3)); await rig.Restart(); + var post = rig.State.Listings["100"]; + Assert.Equal(rig.Clock.Now.AddMinutes(30), post.Deadline); + Assert.False(post.ResponseCoverageComplete); Assert.Empty(rig.Discord.Actions); + } + + [Fact] + public async Task UnansweredListingClosesOnlyWithContinuousEvidence() + { + using var rig = new RecruitmentRig(); await rig.Start(); var post = await rig.Post(); await rig.Ack(); + rig.Clock.Advance(TimeSpan.FromDays(31)); await rig.Sweep(); + Assert.Equal(ListingStatus.Closed, post.Status); Assert.Contains(("100", "close"), rig.Discord.Actions); + } + + [Fact] + public async Task KnownReplySurvivesDeletionAndRestart() + { + using var rig = new RecruitmentRig(); await rig.Start(); await rig.Post(); await rig.Ack(); + rig.Engine.ObserveReply(new ReplyObservation("100", "333", false, false, false)); + await rig.Restart(); rig.Clock.Advance(TimeSpan.FromDays(31)); await rig.Sweep(); + Assert.True(rig.State.Listings["100"].HasQualifyingReply); Assert.Empty(rig.Discord.Actions); + } + + [Theory] + [InlineData("200", false, false, false)] + [InlineData("333", true, false, false)] + [InlineData("333", false, true, false)] + [InlineData("333", false, false, true)] + public async Task OwnerBotWebhookAndModeratorDoNotCountAsCommunityReply(string author, bool bot, bool webhook, bool staff) + { + using var rig = new RecruitmentRig(); await rig.Start(); var post = await rig.Post(); + rig.Engine.ObserveReply(new ReplyObservation(post.Id, author, bot, webhook, staff)); + Assert.False(post.HasQualifyingReply); + } + + [Fact] + public async Task UnknownRolesHoldUnansweredClosure() + { + using var rig = new RecruitmentRig(); await rig.Start(); var post = await rig.Post(); await rig.Ack(); + rig.Engine.ObserveReply(new ReplyObservation(post.Id, "333", false, false, null)); + rig.Clock.Advance(TimeSpan.FromDays(31)); await rig.Sweep(); + Assert.False(post.ResponseCoverageComplete); Assert.Equal(ListingStatus.Open, post.Status); + } + + [Fact] + public async Task AccessFailureDoesNotFreeAcceptedSlot() + { + using var rig = new RecruitmentRig(); await rig.Start(); await rig.Post(); await rig.Ack(); + rig.Discord.UnknownThreads.Add("100"); rig.Clock.Advance(TimeSpan.FromDays(40)); + var second = await rig.Post("101", forum: "14"); await rig.Ack("101"); + Assert.Null(second.AcceptedAt); Assert.True(rig.State.Listings["100"].AvailabilityUnknown); + } + + [Fact] + public async Task IncompleteInventoryPreventsAcceptanceAndAutomaticActions() + { + using var rig = new RecruitmentRig(); await rig.Start(); var post = await rig.Post(); + rig.Discord.InventoryComplete = false; await rig.Ack(); rig.Clock.Advance(TimeSpan.FromHours(1)); await rig.Sweep(); + Assert.Null(post.AcceptedAt); Assert.Empty(rig.Discord.Actions); + } + + [Fact] + public async Task ReferenceDesignationRemovesProvisionalAdvisoryAndReservation() + { + using var rig = new RecruitmentRig(); await rig.Start(); var reference = await rig.Post(); + await rig.Staff("reference"); + Assert.Equal(ListingStatus.Reference, reference.Status); Assert.Null(reference.PublicMessageId); + Assert.Null(rig.State.Authors["200"].LastAcceptedCreation); + await rig.Post("101"); Assert.Contains("accepted", await rig.Ack("101")); + } + + [Fact] + public async Task PinProtectsFromTimeoutButDoesNotGrantSecondSlot() + { + using var rig = new RecruitmentRig(); await rig.Start(); var first = await rig.Post(); + rig.Discord.Threads["100"] = rig.Discord.Threads["100"] with { Pinned = true }; + rig.Clock.Advance(TimeSpan.FromHours(1)); await rig.Sweep(); + Assert.Equal(ListingStatus.Open, first.Status); + var second = await rig.Post("101"); await rig.Ack("101"); Assert.Null(second.AcceptedAt); + } + + [Fact] + public async Task OwnerControlsAndSingleUseRemovalAreBoundToGuildThreadOwnerAndVersion() + { + using var rig = new RecruitmentRig(); await rig.Start(); var post = await rig.Post(); + Assert.Null(rig.Engine.PrepareRemoval("2", post.Id, "200").Token); + Assert.Null(rig.Engine.PrepareRemoval("1", post.Id, "201").Token); + var token = rig.Engine.PrepareRemoval("1", post.Id, "200").Token!; + Assert.Contains("does not belong", await rig.Engine.ConfirmRemovalAsync("1", post.Id, "201", token, default)); + await rig.Ack(); + Assert.Contains("listing changed", await rig.Engine.ConfirmRemovalAsync("1", post.Id, "200", token, default)); + Assert.Contains("does not belong", await rig.Engine.ConfirmRemovalAsync("1", post.Id, "200", token, default)); + Assert.Empty(rig.Discord.Actions); + } + + [Fact] + public async Task WrongCodeIsRateLimited_AndDoesNotIncreaseTimeoutStreak() + { + using var rig = new RecruitmentRig(); await rig.Start(); await rig.Post(); + Assert.Contains("does not match", await rig.Ack(code: "00000")); + Assert.Contains("wait", await rig.Ack()); Assert.Equal(0, rig.State.Authors["200"].ConsecutiveTimeouts); + rig.Clock.Advance(TimeSpan.FromSeconds(5)); Assert.Contains("accepted", await rig.Ack()); + } + + [Fact] + public async Task ThirdConfirmedTimeoutAlertsOnce_RealAcknowledgementResetsStreak() + { + using var rig = new RecruitmentRig(); await rig.Start(); + for (var i = 0; i < 3; i++) { await rig.Post((100 + i).ToString()); rig.Clock.Advance(TimeSpan.FromMinutes(31)); await rig.Sweep(); } + Assert.Equal(3, rig.State.Authors["200"].ConsecutiveTimeouts); Assert.True(rig.State.Authors["200"].TimeoutAlerted); + Assert.Contains("Moderator attention", rig.Discord.Messages["99:recruitment:102:staff"].Message.Content); + await rig.Post("103"); await rig.Ack("103"); + Assert.Equal(0, rig.State.Authors["200"].ConsecutiveTimeouts); + } + + [Fact] + public async Task StaffAuthorizationIsEnforcedAtExecution_AndWaiverDoesNotFreeSlot() + { + using var rig = new RecruitmentRig(); await rig.Start(); await rig.Post(); await rig.Ack(); + Assert.Contains("Moderator access", await rig.Staff("close", actor: "201")); + await rig.Staff("waive-cooldown", "200"); + Assert.Null(rig.State.Authors["200"].LastAcceptedCreation); + var second = await rig.Post("101"); await rig.Ack("101"); Assert.Null(second.AcceptedAt); + } + + [Fact] + public async Task ReopenChecksCapacityAndSuppressesImmediateRepeatClosure() + { + using var rig = new RecruitmentRig(); await rig.Start(); await rig.Post(); await rig.Ack(); + rig.Clock.Advance(TimeSpan.FromDays(31)); await rig.Sweep(); await rig.Staff("reopen"); await rig.Sweep(); + Assert.Equal(ListingStatus.Open, rig.State.Listings["100"].Status); + Assert.True(rig.State.Listings["100"].SuppressUnansweredClose); + } +} diff --git a/DiscordBot.Tests/Settings/BotSettingsValidationTests.cs b/DiscordBot.Tests/Settings/BotSettingsValidationTests.cs index daa92f26..3a06a434 100644 --- a/DiscordBot.Tests/Settings/BotSettingsValidationTests.cs +++ b/DiscordBot.Tests/Settings/BotSettingsValidationTests.cs @@ -20,7 +20,6 @@ public class BotSettingsValidationTests 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 }, @@ -98,24 +97,23 @@ public void NegativeCasinoStartingTokens_Error() } [Fact] - public void RecruitmentEnabled_MissingTags_Warnings() + public void RecruitmentEnabled_MissingForums_WarningsWithoutFatalErrors() { 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")); + var (errors, warnings) = settings.Validate(); + Assert.Empty(errors); + Assert.Contains(warnings, w => w.Contains("four named forum IDs")); + Assert.Contains(warnings, w => w.Contains("FeedChannelId")); } [Fact] - public void RecruitmentDisabled_NoTagWarnings() + public void RecruitmentDisabled_NoRecruitmentWarnings() { var settings = CreateValid(); settings.Recruitment = new RecruitmentSettings { Enabled = false }; var (_, warnings) = settings.Validate(); - Assert.DoesNotContain(warnings, w => w.Contains("TagLooking")); + Assert.DoesNotContain(warnings, w => w.Contains("Recruitment")); } [Fact] diff --git a/DiscordBot/Assets/recruitment/guidelines/hobby-for-hire.md b/DiscordBot/Assets/recruitment/guidelines/hobby-for-hire.md new file mode 100644 index 00000000..2e174d27 --- /dev/null +++ b/DiscordBot/Assets/recruitment/guidelines/hobby-for-hire.md @@ -0,0 +1,13 @@ +# Hobby for hire + +Describe the skills you offer, examples of your work and your available time. State that you are offering unpaid collaboration and explain your expectations. + +Use an accurate title and relevant discipline and engagement tags. Missing pricing or recommended tags will receive advice; they are not proof of misconduct. + +You may have **one open listing total across all four recruitment forums**. If you both recruit and offer your services, combine them in a for-hire listing. An accepted listing starts a **{{cooldownDays}}-day** creation cooldown. Closing or deleting it does not reset or shorten that deadline. Unaccepted attempts do not start a cooldown. + +Read these Guidelines, then use the bot's **Acknowledge** button and enter **{{code}}** within **{{acknowledgementMinutes}} minutes of the bot's prompt**. Unacknowledged posts are removed. Acknowledged duplicates or posts still on cooldown are closed when that window ends. + +Use **Closed** when your listing is filled or no longer needed. The bot locks and archives closed listings. Accepted listings without a qualifying community reply are closed after **{{unansweredDays}} days**; ordinary auto-archive does not free your listing slot. + +Be respectful. Agree on scope and terms in writing, verify claims independently, and avoid sharing sensitive information. The bot's facts are context, not an endorsement. Contact moderators about concerns. diff --git a/DiscordBot/Assets/recruitment/guidelines/hobby-recruiting.md b/DiscordBot/Assets/recruitment/guidelines/hobby-recruiting.md new file mode 100644 index 00000000..f8b82cf5 --- /dev/null +++ b/DiscordBot/Assets/recruitment/guidelines/hobby-recruiting.md @@ -0,0 +1,13 @@ +# Hobby recruiting + +Describe the project, current progress, required skills and realistic time commitment. State clearly that this is unpaid; explain any revenue-sharing arrangement without promising income. + +Use an accurate title and relevant discipline and engagement tags. Missing pricing or recommended tags will receive advice; they are not proof of misconduct. + +You may have **one open listing total across all four recruitment forums**. If you both recruit and offer your services, combine them in a for-hire listing. An accepted listing starts a **{{cooldownDays}}-day** creation cooldown. Closing or deleting it does not reset or shorten that deadline. Unaccepted attempts do not start a cooldown. + +Read these Guidelines, then use the bot's **Acknowledge** button and enter **{{code}}** within **{{acknowledgementMinutes}} minutes of the bot's prompt**. Unacknowledged posts are removed. Acknowledged duplicates or posts still on cooldown are closed when that window ends. + +Use **Closed** when your listing is filled or no longer needed. The bot locks and archives closed listings. Accepted listings without a qualifying community reply are closed after **{{unansweredDays}} days**; ordinary auto-archive does not free your listing slot. + +Be respectful. Agree on scope and terms in writing, verify claims independently, and avoid sharing sensitive information. The bot's facts are context, not an endorsement. Contact moderators about concerns. diff --git a/DiscordBot/Assets/recruitment/guidelines/paid-for-hire.md b/DiscordBot/Assets/recruitment/guidelines/paid-for-hire.md new file mode 100644 index 00000000..093c870a --- /dev/null +++ b/DiscordBot/Assets/recruitment/guidelines/paid-for-hire.md @@ -0,0 +1,13 @@ +# Paid for hire + +Describe your services, portfolio, availability, rates and currency. Explain how clients should contact you. + +Use an accurate title and relevant discipline and engagement tags. Missing pricing or recommended tags will receive advice; they are not proof of misconduct. + +You may have **one open listing total across all four recruitment forums**. If you both recruit and offer your services, combine them in a for-hire listing. An accepted listing starts a **{{cooldownDays}}-day** creation cooldown. Closing or deleting it does not reset or shorten that deadline. Unaccepted attempts do not start a cooldown. + +Read these Guidelines, then use the bot's **Acknowledge** button and enter **{{code}}** within **{{acknowledgementMinutes}} minutes of the bot's prompt**. Unacknowledged posts are removed. Acknowledged duplicates or posts still on cooldown are closed when that window ends. + +Use **Closed** when your listing is filled or no longer needed. The bot locks and archives closed listings. Accepted listings without a qualifying community reply are closed after **{{unansweredDays}} days**; ordinary auto-archive does not free your listing slot. + +Be respectful. Agree on scope and terms in writing, verify claims independently, and avoid sharing sensitive information. The bot's facts are context, not an endorsement. Contact moderators about concerns. diff --git a/DiscordBot/Assets/recruitment/guidelines/paid-recruiting.md b/DiscordBot/Assets/recruitment/guidelines/paid-recruiting.md new file mode 100644 index 00000000..3479d411 --- /dev/null +++ b/DiscordBot/Assets/recruitment/guidelines/paid-recruiting.md @@ -0,0 +1,13 @@ +# Paid recruiting + +Describe the work, scope, budget, currency and payment schedule. Specify the skills you need and how applicants should contact you. + +Use an accurate title and relevant discipline and engagement tags. Missing pricing or recommended tags will receive advice; they are not proof of misconduct. + +You may have **one open listing total across all four recruitment forums**. If you both recruit and offer your services, combine them in a for-hire listing. An accepted listing starts a **{{cooldownDays}}-day** creation cooldown. Closing or deleting it does not reset or shorten that deadline. Unaccepted attempts do not start a cooldown. + +Read these Guidelines, then use the bot's **Acknowledge** button and enter **{{code}}** within **{{acknowledgementMinutes}} minutes of the bot's prompt**. Unacknowledged posts are removed. Acknowledged duplicates or posts still on cooldown are closed when that window ends. + +Use **Closed** when your listing is filled or no longer needed. The bot locks and archives closed listings. Accepted listings without a qualifying community reply are closed after **{{unansweredDays}} days**; ordinary auto-archive does not free your listing slot. + +Be respectful. Agree on scope and terms in writing, verify claims independently, and avoid sharing sensitive information. The bot's facts are context, not an endorsement. Contact moderators about concerns. diff --git a/DiscordBot/Assets/recruitment/tags.json b/DiscordBot/Assets/recruitment/tags.json new file mode 100644 index 00000000..06a8fa8c --- /dev/null +++ b/DiscordBot/Assets/recruitment/tags.json @@ -0,0 +1,202 @@ +{ + "paid-recruiting": [ + { + "Key": "programming", + "Name": "Programming", + "Group": "discipline", + "Closing": false + }, + { + "Key": "art", + "Name": "Art / animation", + "Group": "discipline", + "Closing": false + }, + { + "Key": "audio", + "Name": "Audio", + "Group": "discipline", + "Closing": false + }, + { + "Key": "design", + "Name": "Design / writing", + "Group": "discipline", + "Closing": false + }, + { + "Key": "other", + "Name": "Other discipline", + "Group": "discipline", + "Closing": false + }, + { + "Key": "short", + "Name": "Short project", + "Group": "engagement", + "Closing": false + }, + { + "Key": "ongoing", + "Name": "Ongoing", + "Group": "engagement", + "Closing": false + }, + { + "Key": "closed", + "Name": "Closed", + "Group": "", + "Closing": true + } + ], + "paid-for-hire": [ + { + "Key": "programming", + "Name": "Programming", + "Group": "discipline", + "Closing": false + }, + { + "Key": "art", + "Name": "Art / animation", + "Group": "discipline", + "Closing": false + }, + { + "Key": "audio", + "Name": "Audio", + "Group": "discipline", + "Closing": false + }, + { + "Key": "design", + "Name": "Design / writing", + "Group": "discipline", + "Closing": false + }, + { + "Key": "other", + "Name": "Other discipline", + "Group": "discipline", + "Closing": false + }, + { + "Key": "short", + "Name": "Short project", + "Group": "engagement", + "Closing": false + }, + { + "Key": "ongoing", + "Name": "Ongoing", + "Group": "engagement", + "Closing": false + }, + { + "Key": "closed", + "Name": "Closed", + "Group": "", + "Closing": true + } + ], + "hobby-recruiting": [ + { + "Key": "programming", + "Name": "Programming", + "Group": "discipline", + "Closing": false + }, + { + "Key": "art", + "Name": "Art / animation", + "Group": "discipline", + "Closing": false + }, + { + "Key": "audio", + "Name": "Audio", + "Group": "discipline", + "Closing": false + }, + { + "Key": "design", + "Name": "Design / writing", + "Group": "discipline", + "Closing": false + }, + { + "Key": "other", + "Name": "Other discipline", + "Group": "discipline", + "Closing": false + }, + { + "Key": "short", + "Name": "Short project", + "Group": "engagement", + "Closing": false + }, + { + "Key": "ongoing", + "Name": "Ongoing", + "Group": "engagement", + "Closing": false + }, + { + "Key": "closed", + "Name": "Closed", + "Group": "", + "Closing": true + } + ], + "hobby-for-hire": [ + { + "Key": "programming", + "Name": "Programming", + "Group": "discipline", + "Closing": false + }, + { + "Key": "art", + "Name": "Art / animation", + "Group": "discipline", + "Closing": false + }, + { + "Key": "audio", + "Name": "Audio", + "Group": "discipline", + "Closing": false + }, + { + "Key": "design", + "Name": "Design / writing", + "Group": "discipline", + "Closing": false + }, + { + "Key": "other", + "Name": "Other discipline", + "Group": "discipline", + "Closing": false + }, + { + "Key": "short", + "Name": "Short project", + "Group": "engagement", + "Closing": false + }, + { + "Key": "ongoing", + "Name": "Ongoing", + "Group": "engagement", + "Closing": false + }, + { + "Key": "closed", + "Name": "Closed", + "Group": "", + "Closing": true + } + ] +} diff --git a/DiscordBot/DiscordBot.csproj b/DiscordBot/DiscordBot.csproj index 8abb6470..7be4ecad 100644 --- a/DiscordBot/DiscordBot.csproj +++ b/DiscordBot/DiscordBot.csproj @@ -21,4 +21,8 @@ + + + + \ No newline at end of file diff --git a/DiscordBot/Modules/Recruitment/RecruitmentModule.cs b/DiscordBot/Modules/Recruitment/RecruitmentModule.cs new file mode 100644 index 00000000..2d3064c6 --- /dev/null +++ b/DiscordBot/Modules/Recruitment/RecruitmentModule.cs @@ -0,0 +1,100 @@ +using Discord.Interactions; +using DiscordBot.Services.Recruitment; + +namespace DiscordBot.Modules.Recruitment; + +[Group("recruitment", "Recruitment moderation and recovery")] +public sealed class RecruitmentModule : InteractionModuleBase +{ + public RecruitmentService Recruitment { get; set; } = null!; + private string Guild => Context.Guild?.Id.ToString() ?? ""; + private string Actor => Context.User.Id.ToString(); + private string Thread => Context.Channel.Id.ToString(); + + [SlashCommand("status", "Show recruitment availability")] + public Task Status() => Staff("status"); + [SlashCommand("lookup", "Look up a managed thread or author ID")] + public Task Lookup(string id) => Staff("lookup", id); + [SlashCommand("preview", "Preview managed forum setup")] + public Task Preview([Choice("Paid recruiting", "paid-recruiting"), Choice("Paid for hire", "paid-for-hire"), + Choice("Hobby recruiting", "hobby-recruiting"), Choice("Hobby for hire", "hobby-for-hire")] string forum = "") => Staff("preview", forum); + [SlashCommand("repair", "Publish and verify managed Guidelines and tags")] + public Task Repair(string reason) => Staff("repair", reason: reason); + [SlashCommand("reconcile", "Reconcile Discord state and pending actions")] + public Task Reconcile(string reason) => Staff("reconcile", reason: reason); + [SlashCommand("manage", "Change a listing or review uncertain evidence")] + public Task Manage( + [Choice("Exempt", "exempt"), Choice("Remove exemption", "unexempt"), Choice("Reset acknowledgement/timeouts", "reset"), Choice("Reset timeout streak", "reset-timeouts"), + Choice("Close", "close"), Choice("Reopen", "reopen"), Choice("Designate reference post", "reference"), + Choice("Confirm no qualifying reply", "review-no-reply"), Choice("Confirm qualifying reply", "review-reply"), + Choice("Confirm unavailable thread is absent", "review-absent")] string operation, + string threadId, string reason) => Staff(operation, threadId, reason); + [SlashCommand("waive-cooldown", "Waive an author's creation cooldown without granting a second slot")] + public Task Waive(IUser author, string reason) => Staff("waive-cooldown", author.Id.ToString(), reason); + + private async Task Staff(string operation, string target = "", string reason = "") + { + await DeferAsync(ephemeral: true); + await Reply(() => Recruitment.RunAsync((engine, ct) => engine.StaffAsync(Guild, Actor, operation, target, reason, ct))); + } + + [ComponentInteraction("recruit:ack:*", true)] + public async Task Acknowledge(string thread) + { + if (thread != Thread || Context.Guild is null) { await RespondAsync("This control belongs to another thread.", ephemeral: true); return; } + // The modal itself grants no authority; submission checks persisted ownership and eligibility. + await RespondWithModalAsync($"recruit:code:{thread}"); + } + public sealed class GuidelinesModal : IModal + { + public string Title => "Acknowledge the Guidelines"; + [InputLabel("Code from this forum's native Guidelines")] + [ModalTextInput("code", TextInputStyle.Short, minLength: 5, maxLength: 5)] + public string Code { get; set; } = ""; + } + [ModalInteraction("recruit:code:*", true)] + public async Task SubmitCode(string thread, GuidelinesModal modal) + { + await DeferAsync(ephemeral: true); + if (thread != Thread) { await FollowupAsync("This control belongs to another thread.", ephemeral: true); return; } + await Reply(() => Recruitment.RunAsync((engine, ct) => engine.AcknowledgeAsync(Guild, thread, Actor, modal.Code, ct, Context.Interaction.CreatedAt), thread)); + } + [ComponentInteraction("recruit:close:*", true)] + public async Task Close(string thread) + { + await DeferAsync(ephemeral: true); + if (thread != Thread) { await FollowupAsync("This control belongs to another thread.", ephemeral: true); return; } + await Reply(() => Recruitment.RunAsync((engine, ct) => engine.CloseOwnerAsync(Guild, thread, Actor, ct), thread)); + } + [ComponentInteraction("recruit:remove:*", true)] + public async Task Remove(string thread) + { + await DeferAsync(ephemeral: true); + if (thread != Thread) { await FollowupAsync("This control belongs to another thread.", ephemeral: true); return; } + try + { + var result = await Recruitment.RunAsync((engine, _) => Task.FromResult(engine.PrepareRemoval(Guild, thread, Actor)), thread); + var controls = result.Token is null ? null : new ComponentBuilder().WithButton("Confirm removal", + $"recruit:confirm:{thread}:{result.Token}", ButtonStyle.Danger).Build(); + await FollowupAsync(result.Message, ephemeral: true, components: controls, allowedMentions: AllowedMentions.None); + } + catch { await FollowupAsync("Recruitment could not complete this request. Try again or ask a moderator to check status.", ephemeral: true); } + } + [ComponentInteraction("recruit:confirm:*:*", true)] + public async Task Confirm(string thread, string token) + { + await DeferAsync(ephemeral: true); + if (thread != Thread) { await FollowupAsync("This control belongs to another thread.", ephemeral: true); return; } + await Reply(() => Recruitment.RunAsync((engine, ct) => engine.ConfirmRemovalAsync(Guild, thread, Actor, token, ct), thread)); + } + private async Task Reply(Func> action) + { + try + { + var text = await action(); + for (var offset = 0; offset < text.Length; offset += 1900) + await FollowupAsync(text.Substring(offset, Math.Min(1900, text.Length - offset)), ephemeral: true, allowedMentions: AllowedMentions.None); + } + catch { await FollowupAsync("Recruitment could not complete this request. Try again or ask a moderator to check status.", ephemeral: true); } + } +} diff --git a/DiscordBot/Program.cs b/DiscordBot/Program.cs index b3bf6eb0..4e690da9 100644 --- a/DiscordBot/Program.cs +++ b/DiscordBot/Program.cs @@ -3,6 +3,7 @@ using Discord.Interactions; using Discord.WebSocket; using DiscordBot.Services; +using DiscordBot.Services.Recruitment; using DiscordBot.Settings; using DiscordBot.Utils; using Microsoft.Extensions.DependencyInjection; @@ -22,6 +23,7 @@ public class Program private CommandService _commandService = null!; private InteractionService _interactionService = null!; private IServiceProvider _services = null!; + private Task _recruitmentStart = Task.CompletedTask; private readonly CancellationTokenSource _cts = new(); @@ -49,9 +51,6 @@ private async Task MainAsync() }); _client.Log += LoggingService.DiscordNetLogger; - await _client.LoginAsync(TokenType.Bot, _settings.Token); - await _client.StartAsync(); - _client.Ready += () => { // Ready can be called additional times if the bot disconnects for long enough, @@ -75,12 +74,11 @@ private async Task MainAsync() // Announce, and Log bot started to track issues a bit easier var logger = _services.GetRequiredService(); - logger.LogChannelAndFile("Bot Started.", ExtendedLogSeverity.Positive); + _ = logger.LogChannelAndFile("Bot Started.", ExtendedLogSeverity.Positive); LoggingService.LogToConsole("Bot is connected.", ExtendedLogSeverity.Positive); _services.GetRequiredService(); - _services.GetRequiredService(); _services.GetRequiredService(); _services.GetRequiredService(); _services.GetRequiredService(); @@ -91,9 +89,14 @@ private async Task MainAsync() _services.GetRequiredService(); _services.GetRequiredService(); + // The worker owns startup I/O; do not block the gateway event dispatcher during inventory. + _recruitmentStart = _services.GetRequiredService().StartAsync(); return Task.CompletedTask; }; + await _client.LoginAsync(TokenType.Bot, _settings.Token); + await _client.StartAsync(); + try { await Task.Delay(Timeout.Infinite, _cts.Token); @@ -101,6 +104,14 @@ private async Task MainAsync() catch (TaskCanceledException) { } LoggingService.LogToConsole("Shutdown signal received, stopping...", ExtendedLogSeverity.Warning); + if (_services is not null) + { + using var recruitmentTimeout = new CancellationTokenSource(TimeSpan.FromSeconds(30)); + await _services.GetRequiredService().StopAsync(recruitmentTimeout.Token); + try { await _recruitmentStart; } + catch (OperationCanceledException) { } + catch (Exception ex) { LoggingService.LogToConsole($"Recruitment startup failed ({ex.GetType().Name}).", 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); } @@ -135,7 +146,7 @@ private IServiceProvider ConfigureServices() => .AddSingleton() .AddSingleton() .AddSingleton() - .AddSingleton() + .AddSingleton() .AddSingleton() .AddSingleton() .AddSingleton() diff --git a/DiscordBot/Services/Profiles/XpService.cs b/DiscordBot/Services/Profiles/XpService.cs index c3ab38ee..a7f9f802 100644 --- a/DiscordBot/Services/Profiles/XpService.cs +++ b/DiscordBot/Services/Profiles/XpService.cs @@ -11,6 +11,7 @@ public class XpService private readonly Dictionary _xpCooldown; private readonly List _noXpChannels; private readonly Random _rand; + private readonly RecruitmentForums _recruitmentForums; private readonly int _xpMinPerMessage; private readonly int _xpMaxPerMessage; @@ -21,6 +22,7 @@ public XpService(DiscordSocketClient client, DatabaseService databaseService, IL BotSettings settings, UserSettings userSettings) { _databaseService = databaseService; + _recruitmentForums = settings.Recruitment.Forums; _loggingService = loggingService; _rand = new Random(); _xpCooldown = new Dictionary(); @@ -40,7 +42,8 @@ private Task UpdateXp(SocketMessage messageParam) if (messageParam.Author.IsBot) return Task.CompletedTask; - if (_noXpChannels.Contains(messageParam.Channel.Id)) + if (_recruitmentForums.Contains(messageParam.Channel.Id, (messageParam.Channel as SocketThreadChannel)?.ParentChannel.Id) || + _noXpChannels.Contains(messageParam.Channel.Id)) return Task.CompletedTask; var userId = messageParam.Author.Id; diff --git a/DiscordBot/Services/Recruitment/DiscordRecruitmentAdapter.cs b/DiscordBot/Services/Recruitment/DiscordRecruitmentAdapter.cs new file mode 100644 index 00000000..356bbada --- /dev/null +++ b/DiscordBot/Services/Recruitment/DiscordRecruitmentAdapter.cs @@ -0,0 +1,302 @@ +using System.IO; +using System.Net; +using Discord.Net; +using Discord.Rest; +using Discord.WebSocket; +using DiscordBot.Settings; + +namespace DiscordBot.Services.Recruitment; + +public sealed class DiscordRecruitmentAdapter : IRecruitmentDiscord +{ + private readonly DiscordSocketClient _client; + private readonly BotSettings _settings; + private readonly DatabaseService _database; + public DiscordRecruitmentAdapter(DiscordSocketClient client, BotSettings settings, DatabaseService database) + => (_client, _settings, _database) = (client, settings, database); + private static RequestOptions Options(CancellationToken ct) => new() { CancelToken = ct, Timeout = 15000 }; + private SocketGuild Guild => _client.GetGuild(_settings.GuildId) ?? throw new InvalidOperationException("Configured guild is unavailable."); + + public async Task ValidateAccessAsync(CancellationToken ct) + { + if (_settings.Roles.Moderator == 0 || Guild.GetRole(_settings.Roles.Moderator) is null) + throw new InvalidOperationException("The configured moderator role is unavailable."); + foreach (var id in _settings.Recruitment.Forums.Mapping().Values) + { + var forum = await ForumAsync(id.ToString(), ct); + var cached = Guild.GetChannel(id) ?? throw new InvalidOperationException("Forum permission cache unavailable."); + var perms = Guild.CurrentUser.GetPermissions(cached); + if (!perms.ViewChannel || !perms.ReadMessageHistory || !perms.SendMessagesInThreads || !perms.ManageThreads || !perms.ManageChannel) + throw new InvalidOperationException("Recruitment needs View Channel, Read Message History, Send Messages in Threads, Manage Threads and Manage Channels on each forum."); + } + var feed = await _client.Rest.GetChannelAsync(_settings.Recruitment.FeedChannelId, Options(ct)) as RestTextChannel; + if (feed is null || feed.GuildId != _settings.GuildId) throw new InvalidOperationException("Recruitment feed must be a text channel in the configured guild."); + var cachedFeed = Guild.GetTextChannel(feed.Id) ?? throw new InvalidOperationException("Feed permission cache unavailable."); + var access = Guild.CurrentUser.GetPermissions(cachedFeed); + if (!access.ViewChannel || !access.SendMessages || !access.ReadMessageHistory) + throw new InvalidOperationException("Recruitment needs View Channel, Send Messages and Read Message History in the staff feed."); + var everyone = feed.PermissionOverwrites.FirstOrDefault(p => p.TargetId == Guild.EveryoneRole.Id && p.TargetType == PermissionTarget.Role); + if (everyone.Permissions.ViewChannel != PermValue.Deny) + throw new InvalidOperationException("The staff feed must explicitly deny View Channel to @everyone."); + foreach (var overwrite in feed.PermissionOverwrites.Where(p => p.Permissions.ViewChannel == PermValue.Allow)) + { + if (overwrite.TargetType == PermissionTarget.Role) + { + var role = Guild.GetRole(overwrite.TargetId); + if (role?.Permissions.Administrator == true || overwrite.TargetId == _settings.Roles.Moderator || + (role?.IsManaged == true && Guild.CurrentUser.Roles.Any(r => r.Id == role.Id))) continue; + } + else if (overwrite.TargetId == _client.CurrentUser.Id || await IsModeratorAsync(_settings.GuildId.ToString(), overwrite.TargetId.ToString(), ct)) continue; + throw new InvalidOperationException("The recruitment feed grants access outside the configured moderator/admin roles."); + } + } + + public async Task IsModeratorAsync(string guildId, string userId, CancellationToken ct) + { + if (guildId != _settings.GuildId.ToString() || !ulong.TryParse(userId, out var id)) return false; + var user = await _client.Rest.GetGuildUserAsync(_settings.GuildId, id, Options(ct)); + return user is not null && (user.GuildPermissions.Administrator || user.RoleIds.Contains(_settings.Roles.Moderator)); + } + + private async Task ForumAsync(string id, CancellationToken ct) + { + var channel = await _client.Rest.GetChannelAsync(ulong.Parse(id), Options(ct)); + return channel is RestForumChannel forum && forum.GuildId == _settings.GuildId && _settings.Recruitment.Forums.Contains(forum.Id) + ? forum : throw new InvalidOperationException("Configured recruitment forum is unavailable or has the wrong type/guild."); + } + + public async Task GetForumAsync(string forumId, CancellationToken ct) + { + var forum = await ForumAsync(forumId, ct); + return new ForumSnapshot(forumId, forum.Topic ?? "", (forum.Flags & ChannelFlags.RequireTag) != 0, + forum.Tags.Select(t => new LiveTag(t.Id.ToString(), t.Name, t.IsModerated, t.Emoji)).ToArray()); + } + + public async Task PublishForumAsync(string forumId, string topic, IReadOnlyList tags, CancellationToken ct) + { + var forum = await ForumAsync(forumId, ct); + await forum.ModifyAsync(p => + { + p.Topic = topic; + p.Flags = forum.Flags | ChannelFlags.RequireTag; + p.Tags = tags.Select(t => + { + var builder = new ForumTagBuilder(t.Name, string.IsNullOrEmpty(t.Id) ? null : ulong.Parse(t.Id), t.Moderated); + if (t.Emoji is not null) builder.WithEmoji(t.Emoji); + return builder.Build(); + }).ToArray(); + }, Options(ct)); + } + + public async Task InventoryAsync(DateTimeOffset floor, IReadOnlySet knownIds, + IReadOnlyDictionary cursors, CancellationToken ct) + { + var found = new Dictionary(); + var progress = new Dictionary(); + var complete = true; + foreach (var id in _settings.Recruitment.Forums.Mapping().Values) + { + var forum = await ForumAsync(id.ToString(), ct); + foreach (var thread in await forum.GetActiveThreadsAsync(Options(ct))) found[thread.Id] = thread; + var cursor = cursors[id.ToString()]; + var started = cursor.StartedAt ?? DateTimeOffset.UtcNow; + var since = cursor.Since > floor ? cursor.Since : floor; + var before = cursor.Before; + var finished = false; + // Resume bounded archive pages across sweeps. A completed pass advances its lower boundary. + for (var page = 0; page < 2; page++) + { + var batch = await forum.GetPublicArchivedThreadsAsync(100, before, Options(ct)); + foreach (var thread in batch.Where(t => t.CreatedAt >= floor)) found[thread.Id] = thread; + if (batch.Count < 100 || batch.Min(t => t.ArchiveTimestamp) < since) { finished = true; break; } + before = batch.Min(t => t.ArchiveTimestamp); + } + complete &= finished; + // An overlap protects against timestamp precision and threads archiving during the scan. + progress[id.ToString()] = finished ? new ArchiveCursor(started.AddMinutes(-5)) : new ArchiveCursor(since, before, started); + } + var snapshots = new List(); + var unseen = found.Values.Where(t => t.CreatedAt >= floor && !knownIds.Contains(t.Id.ToString())).ToArray(); + foreach (var thread in unseen.Take(50)) snapshots.Add(await SnapshotAsync(thread, ct)); + // Do not advance past an archive page until all its unknown identities have been retained. + return unseen.Length > 50 ? new ThreadInventory(snapshots, false, cursors) : new ThreadInventory(snapshots, complete, progress); + } + + public async Task GetThreadAsync(string threadId, CancellationToken ct) + { + try + { + var channel = await _client.Rest.GetChannelAsync(ulong.Parse(threadId), Options(ct)); + if (channel is not RestThreadChannel thread || thread.GuildId != _settings.GuildId || !_settings.Recruitment.Forums.Contains(thread.ParentChannelId)) + throw new InvalidOperationException("Thread is outside configured recruitment forums."); + return await SnapshotAsync(thread, ct); + } + catch (HttpException ex) when (ex.DiscordCode == DiscordErrorCode.UnknownChannel) { return null; } + } + + private async Task SnapshotAsync(RestThreadChannel thread, CancellationToken ct) + { + var ownerId = ((IThreadChannel)thread).OwnerId; + var user = await _client.Rest.GetGuildUserAsync(_settings.GuildId, ownerId, Options(ct)); + var starter = await thread.GetMessageAsync(thread.Id, Options(ct)); + return new ThreadSnapshot(thread.Id.ToString(), thread.ParentChannelId.ToString(), ownerId.ToString(), thread.Name, + thread.CreatedAt.ToUniversalTime(), user?.IsBot ?? starter?.Author.IsBot ?? false, + (thread.Flags & ChannelFlags.Pinned) != 0, thread.IsArchived, thread.IsLocked, + thread.AppliedTags.Select(t => t.ToString()).ToArray(), starter?.Content, + SnowflakeUtils.FromSnowflake(ownerId), user?.JoinedAt); + } + + public async Task HasReplyAsync(ThreadSnapshot thread, CancellationToken ct) + { + var channel = await MessageChannelAsync(thread.Id, ct); + var unknown = false; + // Positive evidence can be recovered; this scan never certifies complete historical coverage. + foreach (var message in await channel.GetMessagesAsync(100, CacheMode.AllowDownload, Options(ct)).FlattenAsync()) + { + if (message.Id.ToString() == thread.Id || message.Author.Id.ToString() == thread.OwnerId || message.Author.IsBot || message.Source == MessageSource.Webhook) continue; + var user = await _client.Rest.GetGuildUserAsync(_settings.GuildId, message.Author.Id, Options(ct)); + if (user is null) { unknown = true; continue; } + if (!user.GuildPermissions.Administrator && !user.RoleIds.Contains(_settings.Roles.Moderator)) return true; + } + return unknown ? null : false; + } + + public async Task GetActivityAsync(string userId, CancellationToken ct) + { + try + { + var query = _database.Query; + if (query is null) return "unknown"; + var user = await query.GetUser(userId).WaitAsync(TimeSpan.FromSeconds(5), ct); + if (user is null) return "unknown"; + return user.Exp > 0 || user.Karma > 0 || user.KarmaGiven > 0 ? "recorded" : "none recorded"; + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; } + catch { return "unknown"; } + } + + private async Task MessageChannelAsync(string id, CancellationToken ct) + { + var channel = await _client.Rest.GetChannelAsync(ulong.Parse(id), Options(ct)); + if (channel is not IGuildChannel guildChannel || guildChannel.GuildId != _settings.GuildId || channel is not IMessageChannel messages) + throw new InvalidOperationException("Recruitment message channel unavailable."); + return messages; + } + + public async Task UpsertAsync(string channelId, string? messageId, bool recovering, DateTimeOffset since, RecruitmentMessage message, Action beforeWrite, CancellationToken ct) + { + var channel = await MessageChannelAsync(channelId, ct); + IUserMessage? existing = null; + if (messageId is not null) existing = await channel.GetMessageAsync(ulong.Parse(messageId), CacheMode.AllowDownload, Options(ct)) as IUserMessage; + if (existing is not null && (existing.Author.Id != _client.CurrentUser.Id || !existing.Content.Contains(message.Marker))) + throw new InvalidOperationException("Stored recruitment message identity does not match."); + if (existing is null && recovering) + { + ulong? before = null; + var covered = false; + for (var page = 0; page < 20; page++) + { + var messages = (before.HasValue ? await channel.GetMessagesAsync(before.Value, Direction.Before, 100, CacheMode.AllowDownload, Options(ct)).FlattenAsync() + : await channel.GetMessagesAsync(100, CacheMode.AllowDownload, Options(ct)).FlattenAsync()).ToArray(); + existing = messages.OfType().FirstOrDefault(m => m.Author.Id == _client.CurrentUser.Id && m.Content.Contains(message.Marker)); + if (existing is not null) break; + if (messages.Length < 100 || messages.Min(m => m.Timestamp) <= since.AddMinutes(-5)) { covered = true; break; } + before = messages.Min(m => m.Id); + } + if (existing is null && !covered) throw new InvalidOperationException("Message recovery history exceeds the scan bound; staff review required before resending."); + } + var components = message.OwnerControls ? new ComponentBuilder() + .WithButton("Acknowledge", $"recruit:ack:{channelId}", ButtonStyle.Primary) + .WithButton("Close listing", $"recruit:close:{channelId}", ButtonStyle.Secondary) + .WithButton("Remove post", $"recruit:remove:{channelId}", ButtonStyle.Danger).Build() : new ComponentBuilder().Build(); + if (existing is not null) + { + var unarchivedForEdit = false; + var edit = existing.Content != message.Content || existing.Components.Count == 0 && message.OwnerControls || existing.Components.Count > 0 && !message.OwnerControls; + if (edit) + { + beforeWrite(); + if (channel is IThreadChannel { IsArchived: true } archived) + { + if (archived.IsLocked && !message.RestoreArchive) throw new InvalidOperationException("A locked listing requires moderator review."); + await archived.ModifyAsync(p => p.Archived = false, Options(ct)); + unarchivedForEdit = true; + } + await existing.ModifyAsync(p => { p.Content = message.Content; p.Components = components; p.AllowedMentions = AllowedMentions.None; }, Options(ct)); + } + // The caller persists this restoration intent before publication, so a restart can finish it. + if (message.RestoreArchive && channel is IThreadChannel thread && (unarchivedForEdit || !thread.IsArchived)) + { + if (!edit) beforeWrite(); + await thread.ModifyAsync(p => p.Archived = true, Options(ct)); + } + return new Delivery(existing.Id.ToString(), false); + } + beforeWrite(); + if (channel is IThreadChannel { IsArchived: true } archivedThread) + { + if (archivedThread.IsLocked && !message.RestoreArchive) throw new InvalidOperationException("A locked thread needs moderator review before receiving a challenge."); + await archivedThread.ModifyAsync(p => p.Archived = false, Options(ct)); + } + IUserMessage sent; + if (message.Image is not null) + { + using var stream = new MemoryStream(message.Image, false); + try + { + sent = await channel.SendFileAsync(new FileAttachment(stream, "recruitment.png", message.ImageDescription), message.Content, + options: Options(ct), allowedMentions: AllowedMentions.None, components: components); + } + catch (HttpException ex) when (ex.HttpCode == HttpStatusCode.BadRequest || ex.DiscordCode == DiscordErrorCode.MissingPermissions) + { + // These are definitive rejections, so a text fallback cannot duplicate an ambiguous successful send. + sent = await channel.SendMessageAsync(message.Content, options: Options(ct), allowedMentions: AllowedMentions.None, components: components); + } + } + else sent = await channel.SendMessageAsync(message.Content, options: Options(ct), allowedMentions: AllowedMentions.None, components: components); + if (message.RestoreArchive && channel is IThreadChannel restoreThread) + await restoreThread.ModifyAsync(p => p.Archived = true, Options(ct)); + return new Delivery(sent.Id.ToString(), true); + } + + public async Task CloseAsync(ThreadSnapshot thread, string? closedTagId, bool protectPinned, CancellationToken ct) + { + var live = await ThreadAsync(thread.Id, ct); + GuardWrite(live, thread, protectPinned); + var tags = live.AppliedTags.ToList(); + if (closedTagId is not null && tags.Count < 5 && !tags.Contains(ulong.Parse(closedTagId))) tags.Add(ulong.Parse(closedTagId)); + await live.ModifyAsync(p => { p.AppliedTags = tags; p.Locked = true; p.Archived = true; }, Options(ct)); + var verified = await ThreadAsync(thread.Id, ct); + if (!verified.IsLocked || !verified.IsArchived) throw new InvalidOperationException("Thread closure was not confirmed."); + } + public async Task ReopenAsync(ThreadSnapshot thread, string? closedTagId, CancellationToken ct) + { + var live = await ThreadAsync(thread.Id, ct); + GuardWrite(live, thread, false); + await live.ModifyAsync(p => { p.AppliedTags = live.AppliedTags.Where(t => t.ToString() != closedTagId).ToArray(); p.Locked = false; p.Archived = false; }, Options(ct)); + var verified = await ThreadAsync(thread.Id, ct); + if (verified.IsLocked || verified.IsArchived) throw new InvalidOperationException("Thread reopening was not confirmed."); + } + private async Task ThreadAsync(string id, CancellationToken ct) => + await _client.Rest.GetChannelAsync(ulong.Parse(id), Options(ct)) as RestThreadChannel ?? throw new InvalidOperationException("Thread unavailable."); + public async Task DeleteAsync(ThreadSnapshot thread, string reason, bool protectPinned, CancellationToken ct) + { + var live = await ThreadAsync(thread.Id, ct); + GuardWrite(live, thread, protectPinned); + var options = Options(ct); options.AuditLogReason = "Recruitment: " + RecruitmentPresentation.Safe(reason); + await live.DeleteAsync(options); + } + private void GuardWrite(RestThreadChannel live, ThreadSnapshot expected, bool protectPinned) + { + if (live.GuildId != _settings.GuildId || live.ParentChannelId.ToString() != expected.ForumId || + ((IThreadChannel)live).OwnerId.ToString() != expected.OwnerId || !_settings.Recruitment.Forums.Contains(live.ParentChannelId) || + protectPinned && (live.Flags & ChannelFlags.Pinned) != 0) + throw new InvalidOperationException("Thread identity or protection changed before the action."); + } + public async Task DeleteAdvisoryAsync(string threadId, string messageId, CancellationToken ct) + { + var channel = await MessageChannelAsync(threadId, ct); + if (await channel.GetMessageAsync(ulong.Parse(messageId), CacheMode.AllowDownload, Options(ct)) is IUserMessage message && message.Author.Id == _client.CurrentUser.Id) + await message.DeleteAsync(Options(ct)); + } +} diff --git a/DiscordBot/Services/Recruitment/IRecruitmentDiscord.cs b/DiscordBot/Services/Recruitment/IRecruitmentDiscord.cs new file mode 100644 index 00000000..340931ba --- /dev/null +++ b/DiscordBot/Services/Recruitment/IRecruitmentDiscord.cs @@ -0,0 +1,32 @@ +namespace DiscordBot.Services.Recruitment; + +public sealed record LiveTag(string Id, string Name, bool Moderated, IEmote? Emoji = null); +public sealed record ForumSnapshot(string Id, string Topic, bool RequireTag, IReadOnlyList Tags); +public sealed record ThreadSnapshot(string Id, string ForumId, string OwnerId, string Title, DateTimeOffset CreatedAt, + bool OwnerIsBot, bool Pinned, bool Archived, bool Locked, IReadOnlyList Tags, string? Starter, + DateTimeOffset? AccountCreatedAt, DateTimeOffset? JoinedAt); +public sealed record ArchiveCursor(DateTimeOffset Since, DateTimeOffset? Before = null, DateTimeOffset? StartedAt = null); +public sealed record ThreadInventory(IReadOnlyList Threads, bool Complete, IReadOnlyDictionary? Cursors = null); +public sealed record ReplyObservation(string ThreadId, string AuthorId, bool IsBot, bool IsWebhook, bool? IsStaff); +public sealed record RecruitmentMessage(string Content, string Marker, bool OwnerControls, byte[]? Image = null, string? ImageDescription = null, bool RestoreArchive = false); +public sealed record Delivery(string Id, bool NewlySent); + +/// Null thread means confirmed Discord Unknown Channel, never a cache miss or permission failure. +public interface IRecruitmentDiscord +{ + Task ValidateAccessAsync(CancellationToken ct); + Task IsModeratorAsync(string guildId, string userId, CancellationToken ct); + Task GetForumAsync(string forumId, CancellationToken ct); + Task PublishForumAsync(string forumId, string topic, IReadOnlyList tags, CancellationToken ct); + Task InventoryAsync(DateTimeOffset floor, IReadOnlySet knownIds, IReadOnlyDictionary cursors, CancellationToken ct); + Task GetThreadAsync(string threadId, CancellationToken ct); + Task HasReplyAsync(ThreadSnapshot thread, CancellationToken ct); + Task GetActivityAsync(string userId, CancellationToken ct); + // Ambiguous sends must search the entire bounded recovery interval or hold; never blindly resend. + Task UpsertAsync(string channelId, string? messageId, bool recovering, DateTimeOffset since, + RecruitmentMessage message, Action beforeWrite, CancellationToken ct); + Task CloseAsync(ThreadSnapshot thread, string? closedTagId, bool protectPinned, CancellationToken ct); + Task ReopenAsync(ThreadSnapshot thread, string? closedTagId, CancellationToken ct); + Task DeleteAsync(ThreadSnapshot thread, string reason, bool protectPinned, CancellationToken ct); + Task DeleteAdvisoryAsync(string threadId, string messageId, CancellationToken ct); +} diff --git a/DiscordBot/Services/Recruitment/RecruitmentAssets.cs b/DiscordBot/Services/Recruitment/RecruitmentAssets.cs new file mode 100644 index 00000000..ae7905e2 --- /dev/null +++ b/DiscordBot/Services/Recruitment/RecruitmentAssets.cs @@ -0,0 +1,158 @@ +using System.IO; +using System.Security.Cryptography; +using DiscordBot.Settings; +using Newtonsoft.Json; + +namespace DiscordBot.Services.Recruitment; + +public sealed class TagDefinition +{ + public string Key { get; set; } = ""; + public string Name { get; set; } = ""; + public string Group { get; set; } = ""; + public bool Closing { get; set; } +} + +public sealed class RecruitmentAssets +{ + public Dictionary Templates { get; } + public Dictionary> Tags { get; } + public RecruitmentAssets(string root, RecruitmentSettings settings) + { + var directory = Path.Combine(root, "recruitment"); + Tags = JsonConvert.DeserializeObject>>(File.ReadAllText(Path.Combine(directory, "tags.json"))) + ?? throw new InvalidDataException("Recruitment tag manifest is empty."); + Templates = new(); + foreach (var purpose in settings.Forums.Mapping().Keys) + { + var template = File.ReadAllText(Path.Combine(directory, "guidelines", purpose + ".md")); + if (template.Split("{{code}}").Length != 2 || + new[] { "acknowledgementMinutes", "cooldownDays", "unansweredDays" }.Any(p => !template.Contains("{{" + p + "}}"))) + throw new InvalidDataException($"Invalid Guidelines placeholders for {purpose}."); + Templates[purpose] = template; + if (!Tags.TryGetValue(purpose, out var tags) || tags.Count is < 1 or > 20 || + tags.Count(t => t.Closing) != 1 || tags.Any(t => string.IsNullOrWhiteSpace(t.Key) || + t.Key.Length > 32 || t.Group.Length > 32 || string.IsNullOrWhiteSpace(t.Name) || + t.Name.Length is < 1 or > 20 || (t.Closing && t.Group.Length != 0)) || + tags.Select(t => t.Key).Distinct().Count() != tags.Count || + tags.Select(t => t.Name).Distinct(StringComparer.OrdinalIgnoreCase).Count() != tags.Count) + throw new InvalidDataException($"Invalid managed tags for {purpose}."); + _ = Render(purpose, "ABCDE", settings); + } + } + + public string Render(string purpose, string code, RecruitmentSettings settings) + { + var text = Templates[purpose].Replace("{{code}}", code) + .Replace("{{acknowledgementMinutes}}", settings.AcknowledgementMinutes.ToString()) + .Replace("{{cooldownDays}}", settings.CooldownDays.ToString()) + .Replace("{{unansweredDays}}", settings.UnansweredDays.ToString()).Trim(); + if (text.Length > 4096 || text.Contains("{{")) throw new InvalidDataException($"Invalid Guidelines length/placeholders for {purpose}."); + return text; + } + + public static string NewCode() + { + const string alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; + return new string(Enumerable.Range(0, 5).Select(_ => alphabet[RandomNumberGenerator.GetInt32(alphabet.Length)]).ToArray()); + } + + public string TagAdvice(string purpose, IEnumerable appliedIds, ForumReceipt receipt) + { + var groups = Tags[purpose].Where(t => !t.Closing && t.Group.Length > 0).GroupBy(t => t.Group); + var missing = groups.Where(g => !g.Any(t => receipt.Tags.TryGetValue(t.Key, out var managed) && appliedIds.Contains(managed.Id))) + .Select(g => g.Key).ToArray(); + return missing.Length == 0 ? "Recommended tag groups present" : "Consider adding tags for: " + string.Join(", ", missing); + } +} + +public sealed class RecruitmentSetup +{ + private readonly RecruitmentStateStore _store; + private readonly RecruitmentAssets _assets; + private readonly RecruitmentSettings _settings; + private readonly IRecruitmentDiscord _discord; + public RecruitmentSetup(RecruitmentStateStore store, RecruitmentAssets assets, RecruitmentSettings settings, IRecruitmentDiscord discord) + => (_store, _assets, _settings, _discord) = (store, assets, settings, discord); + + public async Task ReconcileAsync(DateTimeOffset now, bool repair, CancellationToken ct) + { + var state = _store.State; + if (state.RotationCode.Length == 0 || (now >= state.RotateAt && state.Forums.Values.All(f => f.PendingTopic is null && f.VisibleCode == state.RotationCode))) + { + state.RotationCode = RecruitmentAssets.NewCode(); + // Monday 00:00 UTC, persisted once for all four publications. + var date = now.UtcDateTime.Date; + state.RotateAt = new DateTimeOffset(date.AddDays(7 - ((int)date.DayOfWeek + 6) % 7), TimeSpan.Zero); + _store.Save(); + } + foreach (var (purpose, receipt) in state.Forums) + { + var live = await _discord.GetForumAsync(receipt.ForumId, ct); + var topic = receipt.PendingTopic ?? _assets.Render(purpose, state.RotationCode, _settings); + var code = receipt.PendingCode ?? state.RotationCode; + var definitions = receipt.PendingTags ?? _assets.Tags[purpose]; + if (!repair && live.Topic != receipt.ConfirmedTopic && live.Topic != receipt.PendingTopic) + throw new InvalidOperationException($"Guidelines changed outside the bot in {purpose}; preview and repair setup."); + if (!repair && receipt.ConfirmedTopic.Length > 0 && receipt.PendingTopic is null && !live.RequireTag) + throw new InvalidOperationException($"Require Tag was disabled outside the bot in {purpose}; preview and repair setup."); + if (!repair && receipt.ConfirmedTopic.Length == 0 && receipt.PendingTopic is null && live.Tags.Count > 0) + throw new InvalidOperationException($"Unexpected existing tags in {purpose}; preview and repair setup."); + if (receipt.Tags.Keys.Except(definitions.Select(d => d.Key)).Any()) + throw new InvalidOperationException($"Removing managed tags in {purpose} needs manual review; existing relationships must be preserved."); + var desired = live.Tags.ToList(); + foreach (var definition in definitions) + { + receipt.Tags.TryGetValue(definition.Key, out var saved); + var existing = saved is null ? null : live.Tags.SingleOrDefault(t => t.Id == saved.Id); + if (saved is not null && existing is null && !repair && !receipt.PendingRepair) + throw new InvalidOperationException($"A managed tag is missing in {purpose}; preview and repair setup to recreate it."); + if (!repair && existing is not null && existing.Moderated != saved!.Moderated) + throw new InvalidOperationException($"Managed tag permissions changed outside the bot in {purpose}."); + if (!repair && existing is not null && existing.Name != saved!.Name && existing.Name != definition.Name) + throw new InvalidOperationException($"A managed tag was renamed outside the bot in {purpose}."); + var matches = live.Tags.Where(t => t.Name.Equals(definition.Name, StringComparison.OrdinalIgnoreCase) && t.Id != existing?.Id).ToArray(); + if (existing is null && matches.Length == 1 && (repair || receipt.PendingTags is not null)) existing = matches[0]; + else if (matches.Length > 0) throw new InvalidOperationException($"Conflicting tag names in {purpose}."); + if (existing is not null) desired.Remove(existing); + desired.Add(new LiveTag(existing?.Id ?? "", definition.Name, false)); + } + if (desired.Count > 20) throw new InvalidOperationException($"Tag capacity exceeded in {purpose}; unrelated tags were preserved."); + if (live.Topic != topic || !live.RequireTag || !SameTags(live.Tags, desired)) + { + receipt.PendingRepair |= repair; + receipt.PendingTopic = topic; + receipt.PendingCode = code; + receipt.PendingTags = definitions; + _store.Save(); + // Re-read immediately before writing to detect edits made during setup preparation. + var beforeWrite = await _discord.GetForumAsync(receipt.ForumId, ct); + if (beforeWrite.Topic != live.Topic || !SameTags(beforeWrite.Tags, live.Tags) || beforeWrite.RequireTag != live.RequireTag) + throw new InvalidOperationException($"Concurrent forum edit in {purpose}; retry setup preview."); + await _discord.PublishForumAsync(receipt.ForumId, topic, desired, ct); + live = await _discord.GetForumAsync(receipt.ForumId, ct); + } + if (live.Topic != topic || !live.RequireTag || desired.Count != live.Tags.Count || + desired.Any(t => !live.Tags.Any(v => v.Name == t.Name && v.Moderated == t.Moderated && (t.Id == "" || t.Id == v.Id)))) + throw new InvalidOperationException($"Setup verification failed in {purpose}."); + receipt.Tags = definitions.ToDictionary(d => d.Key, d => + { + var tag = live.Tags.Single(t => t.Name == d.Name); + return new ManagedTag { Id = tag.Id, Name = tag.Name, Moderated = tag.Moderated }; + }); + receipt.ConfirmedTopic = topic; + receipt.VisibleCode = code; + receipt.PendingTopic = receipt.PendingCode = null; + receipt.PendingTags = null; + receipt.PendingRepair = false; + // Any code actually visible during a delivered challenge remains valid for that challenge. + foreach (var post in state.Listings.Values.Where(p => p.ForumId == receipt.ForumId && p.Status == ListingStatus.Open && !p.AcceptedAt.HasValue)) + if (!post.ValidCodes.Contains(code)) post.ValidCodes.Add(code); + _store.Save(); + } + } + + private static bool SameTags(IReadOnlyList a, IReadOnlyList b) => + a.OrderBy(t => t.Id).Select(t => (t.Id, t.Name, t.Moderated, Emoji: t.Emoji?.ToString())) + .SequenceEqual(b.OrderBy(t => t.Id).Select(t => (t.Id, t.Name, t.Moderated, Emoji: t.Emoji?.ToString()))); +} diff --git a/DiscordBot/Services/Recruitment/RecruitmentContent.cs b/DiscordBot/Services/Recruitment/RecruitmentContent.cs new file mode 100644 index 00000000..047ad495 --- /dev/null +++ b/DiscordBot/Services/Recruitment/RecruitmentContent.cs @@ -0,0 +1,25 @@ +using System.Text.RegularExpressions; + +namespace DiscordBot.Services.Recruitment; + +public static partial class RecruitmentContent +{ + // Bounded input and regex timeout keep member-provided content off the unbounded work path. + public static string Payment(string? content) + { + if (content is null) return "unknown"; + var text = content[..Math.Min(content.Length, 8000)]; + try + { + if (Concrete().IsMatch(text)) return "concrete rate or budget stated"; + if (Ambiguous().IsMatch(text)) return "ambiguous / discuss payment terms"; + return "no concrete rate or budget found"; + } + catch (RegexMatchTimeoutException) { return "unknown"; } + } + + [GeneratedRegex(@"(?:[$€£¥]\s*\d+(?:[.,]\d+)*(?:\s*[kK])?|\b\d+(?:[.,]\d+)*\s*(?:USD|AUD|CAD|EUR|GBP|JPY)\b|\b(?:USD|AUD|CAD|EUR|GBP|JPY)\s*\d+)", RegexOptions.IgnoreCase, 100)] + private static partial Regex Concrete(); + [GeneratedRegex(@"\b(?:rev(?:enue)?[ -]?share|royalt(?:y|ies)|negotiable|competitive|paid|budget|payment|rates?|profit[ -]?share)\b", RegexOptions.IgnoreCase, 100)] + private static partial Regex Ambiguous(); +} diff --git a/DiscordBot/Services/Recruitment/RecruitmentEngine.cs b/DiscordBot/Services/Recruitment/RecruitmentEngine.cs new file mode 100644 index 00000000..85c147d9 --- /dev/null +++ b/DiscordBot/Services/Recruitment/RecruitmentEngine.cs @@ -0,0 +1,566 @@ +using System.IO; +using DiscordBot.Settings; + +namespace DiscordBot.Services.Recruitment; + +/// All entry points are serialized by RecruitmentService, including staff and owner commands. +public sealed class RecruitmentEngine : IDisposable +{ + private readonly BotSettings _bot; + private readonly RecruitmentSettings _settings; + private readonly IRecruitmentDiscord _discord; + private readonly TimeProvider _time; + private readonly Func _pendingThreadWork; + private RecruitmentStateStore? _store; + private RecruitmentAssets? _assets; + private RecruitmentSetup? _setup; + private bool _ready; + private bool _inventoryComplete; + private readonly HashSet _recoverWindows = new(); + private readonly Dictionary _confirmations = new(); + public string Status { get; private set; } = "Not started"; + public RecruitmentState? State => _store?.State; + private DateTimeOffset Now => _time.GetUtcNow(); + private sealed record RemovalConfirmation(string Guild, string Thread, string Owner, long Version, DateTimeOffset Expires); + + public RecruitmentEngine(BotSettings bot, IRecruitmentDiscord discord, TimeProvider? time = null, Func? pendingThreadWork = null) + { + (_bot, _settings, _discord, _time) = (bot, bot.Recruitment, discord, time ?? TimeProvider.System); + _pendingThreadWork = pendingThreadWork ?? (_ => false); + } + + public async Task StartAsync(CancellationToken ct) + { + if (!_settings.Enabled) { Status = "Disabled"; return; } + var errors = _settings.Validate(); + if (errors.Count > 0) { Status = string.Join("; ", errors); return; } + try + { + _assets = new RecruitmentAssets(_bot.AssetsRootPath, _settings); + _store = new RecruitmentStateStore(_bot.ServerRootPath, _bot.GuildId.ToString(), + _settings.Forums.Mapping().ToDictionary(x => x.Key, x => x.Value.ToString()), Now); + _setup = new RecruitmentSetup(_store, _assets, _settings, _discord); + MarkEvidenceGap(); + await SweepAsync(ct); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { Status = $"Recruitment stopped: {ex.GetType().Name}: {ex.Message}"; _ready = false; } + } + + public void MarkEvidenceGap() + { + if (State is null) return; + _inventoryComplete = false; + foreach (var post in State.Listings.Values.Where(p => p.Status == ListingStatus.Open)) + { + post.ResponseCoverageComplete = false; + if (!post.AcceptedAt.HasValue) _recoverWindows.Add(post.Id); + } + Save(); + } + + public async Task SweepAsync(CancellationToken ct) + { + if (_store is null || _setup is null) return; + _ready = false; + try + { + await _discord.ValidateAccessAsync(ct); + await _setup.ReconcileAsync(Now, false, ct); + _ready = true; + await DiscoverAsync(ct); + var cursor = ulong.TryParse(State!.SweepAfterId, out var savedCursor) ? savedCursor : 0; + var batch = State.Listings.Values + .Where(p => p.Status == ListingStatus.Open || p.PendingAction is not null || p.StaffSendPending || p.PublicSendPending || p.PublicationPending) + .OrderBy(p => ulong.Parse(p.Id) <= cursor).ThenBy(p => ulong.Parse(p.Id)).Take(50).ToArray(); + foreach (var post in batch) + { + ct.ThrowIfCancellationRequested(); + try { await ProcessAsync(post, ct); } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not IOException) + { + post.ResponseCoverageComplete = false; + post.Hold = $"Discord operation incomplete ({ex.GetType().Name}); retrying reconciliation"; + if (!post.AcceptedAt.HasValue && post.Status == ListingStatus.Open) _recoverWindows.Add(post.Id); + Save(); + } + } + if (batch.Length > 0) State.SweepAfterId = batch[^1].Id; + RecruitmentPolicy.Prune(State, Now, _settings.PostRetentionMonths, _settings.AuthorRetentionMonths, _settings.CooldownDays); + State.LastSweepAt = Now; + Save(); + Status = _inventoryComplete ? $"Operating; {State.Listings.Count} retained listings; {State.Listings.Values.Count(p => p.Hold is not null)} held for recovery/review" : "Inventory incomplete; acceptance and automatic actions held"; + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + _ready = false; _inventoryComplete = false; + if (State is not null) + { + foreach (var p in State.Listings.Values.Where(p => p.Status == ListingStatus.Open)) + { + p.ResponseCoverageComplete = false; + if (!p.AcceptedAt.HasValue) _recoverWindows.Add(p.Id); + } + try { Save(); } catch { return; } + } + Status = $"Recruitment held: {ex.GetType().Name}: {ex.Message}"; + } + } + + private async Task DiscoverAsync(CancellationToken ct) + { + var state = State!; + var cursors = state.Forums.Values.ToDictionary(f => f.ForumId, f => f.ArchiveCursor ?? new ArchiveCursor(state.DiscoveryFloor)); + var inventory = await _discord.InventoryAsync(state.DiscoveryFloor, state.Listings.Keys.ToHashSet(), cursors, ct); + _inventoryComplete = inventory.Complete; + foreach (var live in inventory.Threads.OrderBy(t => t.CreatedAt).ThenBy(t => ulong.Parse(t.Id))) + await ObserveAsync(live, false, ct); + if (inventory.Cursors is not null) + foreach (var forum in state.Forums.Values) forum.ArchiveCursor = inventory.Cursors[forum.ForumId]; + if (!_inventoryComplete) + foreach (var p in state.Listings.Values.Where(p => p.Status == ListingStatus.Open && !p.AcceptedAt.HasValue)) _recoverWindows.Add(p.Id); + Save(); + } + + public async Task ReconcileThreadAsync(string thread, CancellationToken ct) + { + if (_ready && State?.Listings.GetValueOrDefault(thread) is { } post) await ProcessAsync(post, ct); + } + + public async Task ObserveAsync(ThreadSnapshot live, bool observedFromCreation, CancellationToken ct) + { + if (State is null || !_ready || live.OwnerIsBot || !State.Forums.Values.Any(f => f.ForumId == live.ForumId)) return; + if (State.Listings.ContainsKey(live.Id) || live.CreatedAt < State.DiscoveryFloor) return; + var purpose = Purpose(live.ForumId); + var post = new Listing + { + Id = live.Id, ForumId = live.ForumId, OwnerId = live.OwnerId, Title = RecruitmentPresentation.Safe(live.Title), + CreatedAt = live.CreatedAt.ToUniversalTime(), UpdatedAt = Now, Pinned = live.Pinned, + ResponseCoverageComplete = observedFromCreation, + Facts = new InitialFacts + { + AccountCreatedAt = live.AccountCreatedAt, JoinedAt = live.JoinedAt, + Activity = await _discord.GetActivityAsync(live.OwnerId, ct), Payment = RecruitmentContent.Payment(live.Starter), + TagAdvice = _assets!.TagAdvice(purpose, live.Tags, State.Forums[purpose]), + PriorAcceptedListing = State.Authors.GetValueOrDefault(live.OwnerId)?.LastAcceptedCreation is not null + } + }; + State.Listings[post.Id] = post; + RecruitmentPolicy.Author(State, post.OwnerId, Now); + Save(); + } + + public void ObserveReply(ReplyObservation reply) + { + if (State?.Listings.GetValueOrDefault(reply.ThreadId) is not { } post || post.Status != ListingStatus.Open || + reply.AuthorId == post.OwnerId || reply.IsBot || reply.IsWebhook) return; + if (reply.IsStaff == false) post.HasQualifyingReply = true; + if (reply.IsStaff is null) post.ResponseCoverageComplete = false; + Save(); + } + + private async Task ProcessAsync(Listing post, CancellationToken ct) + { + if (post.Status == ListingStatus.Reference) + { + if (post.PendingAction is not null) await ExecuteActionAsync(post, ct); + return; + } + var live = await ReadAsync(post, ct); + if (live is null) + { + // Unknown Channel proves absence, not who deleted it or when. Never invent a timeout or cooldown. + RecordAbsence(post); + await PublishStaffAsync(post, ct); post.PublicationPending = false; Save(); + return; + } + if (!post.PublicSendPending) post.PublicRestoreArchive = live.Archived && post.AcceptedAt.HasValue; + post.Hold = null; + if (live.Starter is null && !post.PromptDeliveredAt.HasValue && !post.AcceptedAt.HasValue) + { post.Hold = "Starter message is not available yet"; Save(); return; } + if (post.PendingAction is not null) { await ExecuteActionAsync(post, ct); return; } + if (post.Status != ListingStatus.Open) + { + if (post.StaffSendPending || post.PublicSendPending || post.PublicationPending) await PublishAsync(post, ct); + return; + } + var reply = await _discord.HasReplyAsync(live, ct); + if (reply == true) post.HasQualifyingReply = true; + if (reply is null) post.ResponseCoverageComplete = false; + if (_recoverWindows.Remove(post.Id)) + { + post.PromptDeliveredAt = post.Deadline = null; + // A timeout that was never sent must not run immediately after an outage. + post.Version++; + } + var receipt = State!.Forums[Purpose(post.ForumId)]; + if (!post.AcceptedAt.HasValue && !post.ValidCodes.Contains(receipt.VisibleCode)) post.ValidCodes.Add(receipt.VisibleCode); + Save(); + await PublishAsync(post, ct); + if (!post.PromptDeliveredAt.HasValue && !post.AcceptedAt.HasValue) + { + post.PromptDeliveredAt = Now; + post.Deadline = Now.AddMinutes(_settings.AcknowledgementMinutes); + post.Version++; Save(); + await PublishAsync(post, ct); + } + if (_inventoryComplete && post.AcknowledgedAt.HasValue && !post.AcceptedAt.HasValue) + await RefreshBlockersAsync(post, ct); + if (_inventoryComplete && RecruitmentPolicy.TryAccept(State, post, Now, _settings.CooldownDays)) + { Save(); await PublishAsync(post, ct); } + if (!_inventoryComplete || post.Exempt || post.Pinned) return; + if (live.Tags.Contains(ClosingTag(post))) + await BeginActionAsync(post, RecruitmentActionKind.Close, "system", "Closed tag applied", "closed-tag", true, ct); + else if (!post.AcceptedAt.HasValue && post.Deadline <= Now) + { + if (!post.AcknowledgedAt.HasValue) + await BeginActionAsync(post, RecruitmentActionKind.Delete, "system", "Guidelines acknowledgement timed out", "timeout", true, ct); + else + await BeginActionAsync(post, RecruitmentActionKind.Close, "system", "Acknowledged but still ineligible at deadline", "ineligible", true, ct); + } + else if (RecruitmentPolicy.CanCloseUnanswered(post, Now, _settings.UnansweredDays)) + await BeginActionAsync(post, RecruitmentActionKind.Close, "system", "No qualifying reply within the listing lifetime", "unanswered", true, ct); + } + + private void RecordAbsence(Listing post) + { + post.Status = ListingStatus.Deleted; post.PendingAction = null; post.UpdatedAt = Now; + post.PublicationPending = true; post.PublicSendPending = false; post.Hold = null; + post.LastOutcome = "Thread absent; deletion actor unknown"; post.Version++; + RecruitmentPolicy.Author(State!, post.OwnerId, Now); + Save(); + } + + private async Task RefreshBlockersAsync(Listing candidate, CancellationToken ct) + { + // Eligibility cannot depend on which half of a bounded sweep happened to run first. + for (var count = 0; count < 10; count++) + { + var id = RecruitmentPolicy.Evaluate(State!, candidate, Now, _settings.CooldownDays).BlockingListingId; + if (id is null) return; + var blocker = State!.Listings[id]; + if (await ReadAsync(blocker, ct) is not null) return; + RecordAbsence(blocker); + } + } + + private async Task ReadAsync(Listing post, CancellationToken ct) + { + try + { + var live = await _discord.GetThreadAsync(post.Id, ct); + if (live is not null && (live.OwnerId != post.OwnerId || live.ForumId != post.ForumId)) + throw new InvalidOperationException("Thread identity changed; staff review required."); + post.AvailabilityUnknown = false; + if (live is not null) post.Pinned = live.Pinned; + return live; + } + catch { post.AvailabilityUnknown = true; Save(); throw; } + } + + private async Task PublishStaffAsync(Listing post, CancellationToken ct) + { + var recovering = post.StaffSendPending; + var delivery = await _discord.UpsertAsync(_settings.FeedChannelId.ToString(), post.StaffMessageId, recovering, + post.StaffSendStartedAt ?? post.CreatedAt, RecruitmentPresentation.Staff(State!, post, _settings), () => + { + if (!recovering) post.StaffSendStartedAt = Now; + post.StaffSendPending = true; Save(); + }, ct); + var changed = post.StaffMessageId != delivery.Id || post.StaffSendPending; + post.StaffMessageId = delivery.Id; post.StaffSendPending = false; + if (changed) Save(); + } + + private async Task PublishAsync(Listing post, CancellationToken ct) + { + await PublishStaffAsync(post, ct); + if (post.Status is ListingStatus.Deleted or ListingStatus.Reference) + { post.PublicationPending = false; post.PublicSendPending = false; Save(); return; } + var recovering = post.PublicSendPending; + var image = post.PublicMessageId is null ? RecruitmentPresentation.RenderBanner(post, _bot.AssetsRootPath) : null; + var delivery = await _discord.UpsertAsync(post.Id, post.PublicMessageId, recovering, post.PublicSendStartedAt ?? post.CreatedAt, + RecruitmentPresentation.Public(State!, post, _settings, Now, image) with + { RestoreArchive = post.PublicRestoreArchive || post.Status == ListingStatus.Closed }, () => + { + if (!recovering) post.PublicSendStartedAt = Now; + post.PublicSendPending = true; Save(); + }, ct); + var changed = post.PublicMessageId != delivery.Id || post.PublicSendPending || post.PublicationPending; + post.PublicMessageId = delivery.Id; post.PublicSendPending = false; post.PublicationPending = false; + if (delivery.NewlySent && post.Deadline.HasValue && !post.AcceptedAt.HasValue) + { post.PromptDeliveredAt = null; post.Deadline = null; post.Version++; changed = true; } + if (changed) Save(); + } + + public async Task AcknowledgeAsync(string guild, string thread, string owner, string code, CancellationToken ct, DateTimeOffset? receivedAt = null) + { + var post = Owner(guild, thread, owner); + if (post is null) return "This control is unavailable or does not belong to you."; + await SweepAsync(ct); // Catch up cross-forum capacity before committing acceptance. + if (!_ready) return "Recruitment is waiting for recovery. Please try again shortly."; + await ProcessAsync(post, ct); + if (post.Status != ListingStatus.Open || post.AcceptedAt.HasValue) return "This listing no longer needs acknowledgement."; + var submittedAt = receivedAt ?? Now; + if (!post.Deadline.HasValue || post.Deadline < submittedAt || post.PromptDeliveredAt > submittedAt) + return "The acknowledgement window is unavailable or has expired."; + if (post.LastCodeAttemptAt?.AddSeconds(5) > Now) return "Please wait a few seconds before trying again."; + post.LastCodeAttemptAt = Now; Save(); + if (!post.ValidCodes.Contains(code.Trim().ToUpperInvariant())) return "That code does not match. Read the native Guidelines for this forum and try again."; + post.AcknowledgedAt = Now; post.Version++; post.UpdatedAt = Now; + var author = RecruitmentPolicy.Author(State!, owner, Now); + author.ConsecutiveTimeouts = 0; author.TimeoutAlerted = false; + var accepted = _inventoryComplete && RecruitmentPolicy.TryAccept(State!, post, Now, _settings.CooldownDays); + Save(); await PublishAsync(post, ct); + return accepted ? "Guidelines acknowledged. Your listing is accepted." : !_inventoryComplete + ? "Guidelines acknowledged. Acceptance is waiting for inventory recovery." + : "Guidelines acknowledged. The existing listing or creation cooldown still blocks acceptance; see the advisory."; + } + + public async Task CloseOwnerAsync(string guild, string thread, string owner, CancellationToken ct) + { + var post = Owner(guild, thread, owner); + if (post is null || post.Status != ListingStatus.Open) return "This open listing does not belong to you or recruitment is unavailable."; + await BeginActionAsync(post, RecruitmentActionKind.Close, owner, "Owner closed listing", "", false, ct); + return "Listing closed. Its accepted creation cooldown is unchanged."; + } + + public (string Message, string? Token) PrepareRemoval(string guild, string thread, string owner) + { + var post = Owner(guild, thread, owner); + if (post is null || post.Status is ListingStatus.Deleted or ListingStatus.Reference) return ("This listing is unavailable or does not belong to you.", null); + foreach (var key in _confirmations.Where(x => x.Value.Expires <= Now || x.Value.Owner == owner).Select(x => x.Key).ToArray()) _confirmations.Remove(key); + var token = Guid.NewGuid().ToString("N"); + _confirmations[token] = new RemovalConfirmation(guild, thread, owner, post.Version, Now.AddMinutes(2)); + var next = State!.Authors.GetValueOrDefault(owner)?.LastAcceptedCreation?.AddDays(_settings.CooldownDays); + var timing = !post.AcceptedAt.HasValue ? "Removing this unaccepted attempt adds no cooldown." : next.HasValue + ? "Its creation cooldown remains " + RecruitmentPresentation.Timestamp(next.Value) + "." : "The accepted creation cooldown has been waived."; + return ($"Permanently remove this forum post? {timing} This confirmation expires in two minutes.", token); + } + + public async Task ConfirmRemovalAsync(string guild, string thread, string owner, string token, CancellationToken ct) + { + if (!_confirmations.TryGetValue(token, out var confirm) || confirm.Guild != guild || confirm.Thread != thread || confirm.Owner != owner) + return "This confirmation does not belong to this listing and owner."; + _confirmations.Remove(token); + var post = Owner(guild, thread, owner); + if (post is null || confirm.Expires <= Now || confirm.Version != post.Version || post.Status is ListingStatus.Deleted or ListingStatus.Reference) + return "This confirmation expired or the listing changed. Request removal again."; + await BeginActionAsync(post, RecruitmentActionKind.Delete, owner, "Owner confirmed removal", "", false, ct); + return "Post removed. Any accepted creation cooldown is unchanged."; + } + + private Listing? Owner(string guild, string thread, string owner) => + _ready && State?.GuildId == guild && State.Listings.TryGetValue(thread, out var post) && post.OwnerId == owner ? post : null; + + private async Task BeginActionAsync(Listing post, RecruitmentActionKind kind, string actor, string reason, string condition, bool automatic, CancellationToken ct, bool requiresModerator = false) + { + if (post.PendingAction is not null) throw new InvalidOperationException("An earlier action still needs reconciliation."); + post.PendingAction = new RecruitmentAction { Kind = kind, ActorId = actor, Reason = reason, Condition = condition, + Automatic = automatic, RequiresModerator = requiresModerator, GuidelineTimeout = condition == "timeout", CreatedAt = Now }; + Audit(post, actor, reason); Save(); + await ExecuteActionAsync(post, ct); + } + + private async Task ExecuteActionAsync(Listing post, CancellationToken ct) + { + var action = post.PendingAction!; + if (action.RequiresModerator && !await _discord.IsModeratorAsync(State!.GuildId, action.ActorId, ct)) + { + post.PendingAction = null; post.LastOutcome = "Staff action cancelled: moderator access no longer present"; + post.PublicationPending = true; Save(); await PublishStaffAsync(post, ct); return; + } + // Intent and staff evidence must exist before removing or changing member content. + await PublishStaffAsync(post, ct); + var live = await ReadAsync(post, ct); + if (live is null) + { + RecordAbsence(post); + await PublishStaffAsync(post, ct); post.PublicationPending = false; Save(); return; + } + if (action.Kind == RecruitmentActionKind.RemoveAdvisory) + { + if (post.PublicMessageId is null && post.PublicSendPending) + { + // Resolve a possibly delivered provisional advisory before removing it. + var recovered = await _discord.UpsertAsync(post.Id, null, true, post.PublicSendStartedAt ?? post.CreatedAt, + RecruitmentPresentation.Public(State!, post, _settings, Now), Save, ct); + post.PublicMessageId = recovered.Id; post.PublicSendPending = false; Save(); + } + if (post.PublicMessageId is not null) await _discord.DeleteAdvisoryAsync(post.Id, post.PublicMessageId, ct); + post.PublicMessageId = null; post.PendingAction = null; Save(); return; + } + if (action.CallStarted && action.Kind == RecruitmentActionKind.Close && live.Archived && live.Locked) + { + post.Status = ListingStatus.Closed; post.PendingAction = null; + post.LastOutcome = "Closure confirmed after interrupted action"; post.UpdatedAt = Now; post.Version++; + post.PublicationPending = true; Save(); await PublishAsync(post, ct); return; + } + if (action.Automatic && (!_inventoryComplete || post.Exempt || post.Pinned || post.Status != ListingStatus.Open || + (action.Condition == "timeout" && (post.AcknowledgedAt.HasValue || post.AcceptedAt.HasValue || post.Deadline > Now || _recoverWindows.Contains(post.Id))) || + (action.Condition == "ineligible" && (post.AcceptedAt.HasValue || RecruitmentPolicy.Evaluate(State!, post, Now, _settings.CooldownDays).Allowed)) || + (action.Condition == "closed-tag" && !live.Tags.Contains(ClosingTag(post))) || + (action.Condition == "unanswered" && !RecruitmentPolicy.CanCloseUnanswered(post, Now, _settings.UnansweredDays)))) + { post.PendingAction = null; Save(); return; } + if (action.Condition == "unanswered") + { + var reply = await _discord.HasReplyAsync(live, ct); + if (reply != false) + { + post.HasQualifyingReply |= reply == true; post.ResponseCoverageComplete &= reply.HasValue; + post.PendingAction = null; Save(); return; + } + } + if (action.Kind == RecruitmentActionKind.Reopen && State!.Listings.Values.Any(p => p.OwnerId == post.OwnerId && p.Id != post.Id && p.Status == ListingStatus.Open)) + throw new InvalidOperationException("Another listing occupies this author's slot."); + // Gateway facts and timely owner interactions already queued must be applied before an automatic action. + if (action.Automatic && _pendingThreadWork(post.Id)) + { post.PendingAction = null; Save(); return; } + action.CallStarted = true; Save(); + switch (action.Kind) + { + case RecruitmentActionKind.Delete: + await _discord.DeleteAsync(live, action.Reason, action.Automatic, ct); + post.Status = ListingStatus.Deleted; + if (action.GuidelineTimeout) + { + var author = RecruitmentPolicy.Author(State!, post.OwnerId, Now); + author.ConsecutiveTimeouts++; + if (author.ConsecutiveTimeouts >= 3 && !author.TimeoutAlerted) + { post.TimeoutAlertCount = author.ConsecutiveTimeouts; author.TimeoutAlerted = true; } + } + break; + case RecruitmentActionKind.Close: + await _discord.CloseAsync(live, ClosingTag(post), action.Automatic, ct); + post.Status = ListingStatus.Closed; + break; + case RecruitmentActionKind.Reopen: + await _discord.ReopenAsync(live, ClosingTag(post), ct); + post.Status = ListingStatus.Open; post.SuppressUnansweredClose = true; + if (!post.AcceptedAt.HasValue) _recoverWindows.Add(post.Id); + break; + } + RecruitmentPolicy.Author(State!, post.OwnerId, Now); + post.LastOutcome = action.Reason; post.PendingAction = null; post.UpdatedAt = Now; post.Version++; post.PublicationPending = true; Save(); + await PublishAsync(post, ct); + } + + public async Task StaffAsync(string guild, string actor, string operation, string target, string reason, CancellationToken ct) + { + if (guild != _bot.GuildId.ToString() || !await _discord.IsModeratorAsync(guild, actor, ct)) return "Moderator access is required in the configured server."; + if (operation == "status") return Status; + if (operation == "preview") + { + if (_assets is null) return "Assets are unavailable; inspect the startup error."; + if (_assets.Templates.ContainsKey(target)) + return _assets.Render(target, "•••••", _settings) + "\n\nManaged tags: " + string.Join(", ", _assets.Tags[target].Select(t => t.Name)); + return string.Join("\n", _assets.Tags.Select(x => $"{x.Key}: {string.Join(", ", x.Value.Select(t => t.Name))}")) + + "\nSelect a forum on /recruitment preview to read its proposed native Guidelines. Repair publishes these templates and the persisted rotating code."; + } + if (State is null || !_settings.Enabled) return "Recruitment is unavailable. Enable valid settings and restart first."; + if (operation == "lookup") + { + if (State.Listings.TryGetValue(target, out var found)) return RecruitmentPresentation.Staff(State, found, _settings).Content; + if (State.Authors.TryGetValue(target, out var history)) return $"Author `{target}` · next creation: {history.LastAcceptedCreation?.AddDays(_settings.CooldownDays).ToString("u") ?? "none"} · confirmed timeout streak: {history.ConsecutiveTimeouts}"; + return "No retained recruitment record for that thread or author."; + } + if (string.IsNullOrWhiteSpace(reason)) return "A reason is required for this change."; + reason = RecruitmentPresentation.Safe(reason, 300); + if (operation == "repair") + { + State.SetupAudit.Add(new RecruitmentAudit(Now, actor, "Repair forum setup: " + reason)); + if (State.SetupAudit.Count > 30) State.SetupAudit.RemoveAt(0); + Save(); + await _discord.ValidateAccessAsync(ct); + await _setup!.ReconcileAsync(Now, true, ct); + Status = "Setup repaired"; + await SweepAsync(ct); return Status; + } + if (operation == "reconcile") + { + State.SetupAudit.Add(new RecruitmentAudit(Now, actor, "Reconcile: " + reason)); + if (State.SetupAudit.Count > 30) State.SetupAudit.RemoveAt(0); + Save(); await SweepAsync(ct); return Status; + } + if (!_ready) return "Recruitment setup needs recovery before listing changes are available."; + if (operation == "waive-cooldown") + { + if (!State.Authors.TryGetValue(target, out var author)) return "Author history was not found."; + // Audit on every retained listing for this author; waiver never changes occupied capacity. + var posts = State.Listings.Values.Where(p => p.OwnerId == target).ToArray(); + if (posts.Length == 0) return "No listing audit record remains; no active cooldown should need a waiver."; + foreach (var p in posts) { Audit(p, actor, "Waive cooldown: " + reason); Save(); await PublishStaffAsync(p, ct); } + author.LastAcceptedCreation = null; author.UpdatedAt = Now; Save(); + return "Creation cooldown waived. Existing open listings still occupy the slot."; + } + if (!State.Listings.TryGetValue(target, out var post)) return "Managed thread was not found. Reconcile first."; + Audit(post, actor, operation + ": " + reason); Save(); await PublishStaffAsync(post, ct); + switch (operation) + { + case "exempt": post.Exempt = true; break; + case "unexempt": post.Exempt = false; _recoverWindows.Add(post.Id); break; + case "reset-timeouts": + var timeoutHistory = RecruitmentPolicy.Author(State, post.OwnerId, Now); + timeoutHistory.ConsecutiveTimeouts = 0; timeoutHistory.TimeoutAlerted = false; break; + case "reset": + if (post.AcceptedAt.HasValue || post.Status != ListingStatus.Open) return "Only an open unaccepted attempt can have its acknowledgement reset."; + post.AcknowledgedAt = null; post.ValidCodes.Clear(); _recoverWindows.Add(post.Id); + var history = RecruitmentPolicy.Author(State, post.OwnerId, Now); + history.ConsecutiveTimeouts = 0; history.TimeoutAlerted = false; break; + case "reference": + if (post.Status == ListingStatus.Deleted) return "A deleted thread cannot become a reference post."; + post.Status = ListingStatus.Reference; post.PendingAction = null; post.Deadline = null; post.ValidCodes.Clear(); + post.LastOutcome = "Moderator-designated reference post"; post.Version++; Save(); + await BeginActionAsync(post, RecruitmentActionKind.RemoveAdvisory, actor, reason, "", false, ct, requiresModerator: true); + return "Reference post designated; bot advisory removed. Any accepted cooldown is preserved."; + case "close": + if (post.Status != ListingStatus.Open) return "Only an open listing can be closed."; + await BeginActionAsync(post, RecruitmentActionKind.Close, actor, reason, "", false, ct, requiresModerator: true); return post.Status == ListingStatus.Closed ? "Listing closed." : "Closure was cancelled or is awaiting reconciliation."; + case "reopen": + if (post.Status != ListingStatus.Closed) return "Only a closed listing can be reopened."; + await DiscoverAsync(ct); + if (!_inventoryComplete) return "Inventory is incomplete; reopening is held."; + if (State.Listings.Values.Any(p => p.OwnerId == post.OwnerId && p.Id != post.Id && p.Status == ListingStatus.Open)) + return "Another listing occupies this author's slot."; + await BeginActionAsync(post, RecruitmentActionKind.Reopen, actor, reason, "", false, ct, requiresModerator: true); return post.Status == ListingStatus.Open + ? "Listing reopened; automatic unanswered closure is suppressed for this listing." : "Reopening was cancelled or is awaiting reconciliation."; + case "review-no-reply": post.ResponseCoverageComplete = true; break; + case "review-reply": post.HasQualifyingReply = true; break; + case "review-absent": + if (!post.AvailabilityUnknown) return "Availability is known; use close or reconcile instead."; + post.Status = ListingStatus.Deleted; post.AvailabilityUnknown = false; post.PendingAction = null; + post.PublicSendPending = false; post.Hold = null; + RecruitmentPolicy.Author(State, post.OwnerId, Now); + post.LastOutcome = "Staff confirmed absence"; break; + default: return "Unknown recruitment operation."; + } + post.Version++; post.UpdatedAt = Now; Save(); + if (operation == "review-absent") await PublishStaffAsync(post, ct); + else await PublishAsync(post, ct); + return "Recruitment record updated."; + } + + private void Audit(Listing post, string actor, string reason) + { + post.LastActor = actor; post.LastReason = reason; + post.Audit.Add(new RecruitmentAudit(Now, actor, reason)); + if (post.Audit.Count > 30) post.Audit.RemoveRange(0, post.Audit.Count - 30); + } + private string Purpose(string forum) => State!.Forums.Single(x => x.Value.ForumId == forum).Key; + private string? ClosingTag(Listing post) + { + var purpose = Purpose(post.ForumId); + return State!.Forums[purpose].Tags.GetValueOrDefault(_assets!.Tags[purpose].Single(t => t.Closing).Key)?.Id; + } + private void Save() + { + try { _store!.Save(); } + catch + { + _ready = false; + Status = "Recruitment stopped after a state write failure; restore storage and restart before making changes."; + throw; + } + } + public void Dispose() => _store?.Dispose(); +} diff --git a/DiscordBot/Services/Recruitment/RecruitmentPresentation.cs b/DiscordBot/Services/Recruitment/RecruitmentPresentation.cs new file mode 100644 index 00000000..775ec9c7 --- /dev/null +++ b/DiscordBot/Services/Recruitment/RecruitmentPresentation.cs @@ -0,0 +1,89 @@ +using System.IO; +using DiscordBot.Settings; +using ImageMagick; +using ImageMagick.Drawing; + +namespace DiscordBot.Services.Recruitment; + +public static class RecruitmentPresentation +{ + public static string Facts(Listing post) => + $"Initial snapshot · account created {Date(post.Facts.AccountCreatedAt)} · joined {Date(post.Facts.JoinedAt)}\n" + + $"Community activity: {post.Facts.Activity}. Payment: {post.Facts.Payment}.\n" + + $"Earlier accepted listing: {(post.Facts.PriorAcceptedListing ? "recorded" : "none recorded")}. {post.Facts.TagAdvice}."; + + private static string Date(DateTimeOffset? date) => date?.ToString("yyyy-MM-dd") ?? "unknown"; + public static string Timestamp(DateTimeOffset date) => $""; + public static string Safe(string value, int max = 180) => new string(value.Take(max) + .Select(c => char.IsControl(c) || "`*_~|<>@".Contains(c) ? ' ' : c).ToArray()); + + public static RecruitmentMessage Public(RecruitmentState state, Listing post, RecruitmentSettings settings, DateTimeOffset now, byte[]? image = null) + { + var eligibility = RecruitmentPolicy.Evaluate(state, post, now, settings.CooldownDays); + var status = post.Status != ListingStatus.Open ? post.Status.ToString() : post.AcceptedAt.HasValue ? "Accepted" : + post.AcknowledgedAt.HasValue ? "Guidelines acknowledged; waiting for eligibility" : "Read this forum's Guidelines and acknowledge the code"; + var text = $"**{status}**\n"; + if (post.Status == ListingStatus.Open && !post.AcceptedAt.HasValue) + { + text += post.Deadline.HasValue ? $"Acknowledgement window ends {Timestamp(post.Deadline.Value)}.\n" : "Your full acknowledgement window starts when these controls are confirmed available.\n"; + if (eligibility.BlockingListingId is not null) + text += $"Your other listing <#{eligibility.BlockingListingId}> occupies your one listing slot.\n"; + if (eligibility.NextAllowedAt.HasValue) text += $"Creation cooldown ends {Timestamp(eligibility.NextAllowedAt.Value)}.\n"; + } + if (post.AcceptedAt.HasValue) + { + var next = state.Authors.GetValueOrDefault(post.OwnerId)?.LastAcceptedCreation?.AddDays(settings.CooldownDays); + text += next.HasValue ? $"Replacement cooldown: {Timestamp(next.Value)}. Close this listing before replacing it.\n" + : "Creation cooldown waived. Close this listing before replacing it.\n"; + } + if (post.Exempt || post.Pinned) text += "Protected from automatic closure/removal.\n"; + if (post.Hold is not null || post.AvailabilityUnknown) text += "Processing is waiting for moderator review or Discord recovery.\n"; + text += "\n" + Facts(post) + "\nAgree on scope and payment in writing; verify claims independently. These facts are not an endorsement."; + var marker = $"recruitment:{post.Id}:public"; + return new RecruitmentMessage(text + $"\n||{marker}||", marker, post.Status == ListingStatus.Open, image, Facts(post)); + } + + public static RecruitmentMessage Staff(RecruitmentState state, Listing post, RecruitmentSettings settings) + { + var author = state.Authors.GetValueOrDefault(post.OwnerId); + var next = author?.LastAcceptedCreation?.AddDays(settings.CooldownDays); + var text = $"**Recruitment · {post.Status}** · {Safe(post.Title)}\n" + + $"Author `{post.OwnerId}` · thread `{post.Id}` · forum `{post.ForumId}`\n" + + $"https://discord.com/channels/{state.GuildId}/{post.Id}\n" + + $"Created {Timestamp(post.CreatedAt)} · acknowledged {(post.AcknowledgedAt.HasValue ? "yes" : "no")} · accepted {(post.AcceptedAt.HasValue ? "yes" : "no")}\n" + + $"Next creation: {(next.HasValue ? Timestamp(next.Value) : "no accepted cooldown")}\n" + + $"Response: {(post.HasQualifyingReply ? "qualifying reply recorded" : post.ResponseCoverageComplete ? "none observed" : "history incomplete; unanswered closure held")}\n" + + $"Outcome: {Safe(post.LastOutcome)}\n"; + if (post.PendingAction is { } action) + text += $"Pending {action.Kind}: {Safe(action.Reason)} · actor `{action.ActorId}` · action `{action.Id}`\n"; + if (post.Hold is not null) text += $"Hold: {Safe(post.Hold)}\n"; + if (post.TimeoutAlertCount >= 3) text += $"**Moderator attention: {post.TimeoutAlertCount} consecutive confirmed Guidelines timeouts.**\n"; + foreach (var audit in post.Audit.TakeLast(3)) text += $"{audit.At:yyyy-MM-dd HH:mm} · `{audit.Actor}` · {Safe(audit.Reason, 180)}\n"; + var marker = $"recruitment:{post.Id}:staff"; + if (text.Length > 1900) text = text[..1880] + "\n… more in stored audit\n"; + return new RecruitmentMessage(text + $"||{marker}||", marker, false); + } + + public static byte[]? RenderBanner(Listing post, string assetsRoot) + { + try + { + var font = Path.Combine(assetsRoot, "fonts", "OpenSans-Regular.ttf"); + if (!File.Exists(font)) return null; + // Fixed size, local font and bounded, bot-generated text only. No remote images or member markup. + using var image = new MagickImage(new MagickColor("#17212D"), 900, 270); + var draw = new Drawables().Font(font).FillColor(new MagickColor("#F0F4F8")).FontPointSize(25) + .Text(28, 42, "RECRUITMENT · INITIAL SNAPSHOT").FontPointSize(19) + .Text(28, 84, $"Account: {Date(post.Facts.AccountCreatedAt)} Joined: {Date(post.Facts.JoinedAt)}") + .Text(28, 121, $"Community activity: {post.Facts.Activity}") + .Text(28, 158, $"Payment: {post.Facts.Payment}") + .FontPointSize(17).FillColor(new MagickColor("#B8C8D9")) + .Text(28, 208, "Agree on scope and terms in writing. Verify claims independently.") + .Text(28, 239, "Context only; these facts are not an endorsement."); + draw.Draw(image); + var bytes = image.ToByteArray(MagickFormat.Png); + return bytes.Length <= 512 * 1024 ? bytes : null; + } + catch { return null; } // Rendering is cosmetic; essential information is also text. + } +} diff --git a/DiscordBot/Services/Recruitment/RecruitmentService.cs b/DiscordBot/Services/Recruitment/RecruitmentService.cs new file mode 100644 index 00000000..f41c8849 --- /dev/null +++ b/DiscordBot/Services/Recruitment/RecruitmentService.cs @@ -0,0 +1,195 @@ +using System.Threading.Channels; +using System.Collections.Concurrent; +using Discord.WebSocket; +using DiscordBot.Settings; + +namespace DiscordBot.Services.Recruitment; + +/// One bounded queue owns all recruitment state and external operations. +public sealed class RecruitmentService +{ + private readonly DiscordSocketClient _client; + private readonly BotSettings _settings; + private readonly DiscordRecruitmentAdapter _discord; + private readonly RecruitmentEngine _engine; + private readonly ConcurrentDictionary _pendingThreadWork = new(); + private readonly Channel _queue = Channel.CreateBounded(new BoundedChannelOptions(256) + { SingleReader = true, FullMode = BoundedChannelFullMode.Wait }); + private readonly CancellationTokenSource _stop = new(); + private Task? _worker; + private Timer? _timer; + private DateTimeOffset _connectedSince; + private int _gap; + private long _evidenceGeneration; + private int _sweepQueued; + private sealed record Work(Func Run, Action Cancel); + + public RecruitmentService(DiscordSocketClient client, BotSettings settings, DatabaseService database) + { + _client = client; _settings = settings; + _discord = new DiscordRecruitmentAdapter(client, settings, database); + _engine = new RecruitmentEngine(settings, _discord, pendingThreadWork: id => _pendingThreadWork.GetValueOrDefault(id) > 0); + } + + public async Task StartAsync() + { + if (_worker is not null) return; + _connectedSince = DateTimeOffset.UtcNow; + if (_settings.Recruitment.Enabled) + { + _client.ThreadCreated += ThreadCreated; + _client.ThreadUpdated += ThreadUpdated; + _client.ThreadDeleted += ThreadDeleted; + _client.MessageReceived += MessageReceived; + _client.Disconnected += Disconnected; + _client.Connected += Connected; + } + var started = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + _worker = Task.Run(async () => + { + try + { + await _engine.StartAsync(_stop.Token); + LoggingService.LogToConsole("Recruitment: " + _engine.Status, ExtendedLogSeverity.Info); + started.TrySetResult(); + await foreach (var work in _queue.Reader.ReadAllAsync(_stop.Token)) + { + try + { + if (Interlocked.Exchange(ref _gap, 0) != 0) _engine.MarkEvidenceGap(); + await work.Run(_stop.Token); + } + catch (OperationCanceledException) when (_stop.IsCancellationRequested) { work.Cancel(); break; } + catch (Exception ex) + { + work.Cancel(); + EvidenceGap(); + LoggingService.LogToConsole($"Recruitment operation held ({ex.GetType().Name}). Use /recruitment status or reconcile.", ExtendedLogSeverity.Warning); + } + } + } + catch (OperationCanceledException) when (_stop.IsCancellationRequested) { started.TrySetCanceled(); } + catch (Exception ex) { started.TrySetException(ex); } + finally + { + while (_queue.Reader.TryRead(out var work)) work.Cancel(); + _engine.Dispose(); + } + }); + await started.Task; + if (_settings.Recruitment.Enabled && !_queue.Reader.Completion.IsCompleted && !_stop.IsCancellationRequested) + _timer = new Timer(_ => ScheduleSweep(), null, TimeSpan.FromSeconds(30), TimeSpan.FromSeconds(30)); + } + + private void EvidenceGap() + { + Interlocked.Increment(ref _evidenceGeneration); + Interlocked.Exchange(ref _gap, 1); + } + private Action TrackThread(string? threadId) + { + if (threadId is null) return () => { }; + _pendingThreadWork.AddOrUpdate(threadId, 1, (_, count) => count + 1); + var finished = 0; + return () => + { + if (Interlocked.Exchange(ref finished, 1) == 0) + { + var remaining = _pendingThreadWork.AddOrUpdate(threadId, 0, (_, count) => Math.Max(0, count - 1)); + if (remaining == 0) ((ICollection>)_pendingThreadWork).Remove(new(threadId, 0)); + } + }; + } + private void Event(Func run, string? threadId = null) + { + var finished = TrackThread(threadId); + if (!_queue.Writer.TryWrite(new Work(async ct => { try { await run(ct); } finally { finished(); } }, finished))) + { finished(); EvidenceGap(); } + } + private void ScheduleSweep() + { + if (Interlocked.Exchange(ref _sweepQueued, 1) != 0) return; + if (!_queue.Writer.TryWrite(new Work(async ct => + { + try { await _engine.SweepAsync(ct); } + finally { Interlocked.Exchange(ref _sweepQueued, 0); } + }, () => Interlocked.Exchange(ref _sweepQueued, 0)))) + { Interlocked.Exchange(ref _sweepQueued, 0); EvidenceGap(); } + } + private Task ThreadCreated(SocketThreadChannel thread) + { + if (thread.Guild.Id != _settings.GuildId || !_settings.Recruitment.Forums.Contains(thread.ParentChannel.Id)) return Task.CompletedTask; + var fromCreation = thread.CreatedAt >= _connectedSince; + var generation = Interlocked.Read(ref _evidenceGeneration); + Event(async ct => + { + var live = await _discord.GetThreadAsync(thread.Id.ToString(), ct); + if (live is not null) await _engine.ObserveAsync(live, fromCreation && generation == Interlocked.Read(ref _evidenceGeneration), ct); + }); + ScheduleSweep(); + return Task.CompletedTask; + } + private Task ThreadUpdated(Cacheable before, SocketThreadChannel after) + { + if (after.Guild.Id == _settings.GuildId && _settings.Recruitment.Forums.Contains(after.ParentChannel.Id)) ScheduleSweep(); + return Task.CompletedTask; + } + private Task ThreadDeleted(Cacheable thread) + { + Event(ct => _engine.ReconcileThreadAsync(thread.Id.ToString(), ct)); + ScheduleSweep(); return Task.CompletedTask; + } + private Task MessageReceived(SocketMessage message) + { + if (message.Channel is not SocketThreadChannel thread || thread.Guild.Id != _settings.GuildId || + !_settings.Recruitment.Forums.Contains(thread.ParentChannel.Id)) return Task.CompletedTask; + var user = message.Author as SocketGuildUser; + var reply = new ReplyObservation(thread.Id.ToString(), message.Author.Id.ToString(), message.Author.IsBot, + message.Source == MessageSource.Webhook, user is null ? null : user.GuildPermissions.Administrator || user.Roles.Any(r => r.Id == _settings.Roles.Moderator)); + Event(async ct => + { + if (_engine.State is not null && !_engine.State.Listings.ContainsKey(reply.ThreadId)) + { + var live = await _discord.GetThreadAsync(reply.ThreadId, ct); + if (live is not null) await _engine.ObserveAsync(live, false, ct); + } + _engine.ObserveReply(reply); + }, reply.ThreadId); + return Task.CompletedTask; + } + private Task Disconnected(Exception _) + { EvidenceGap(); return Task.CompletedTask; } + private Task Connected() + { _connectedSince = DateTimeOffset.UtcNow; EvidenceGap(); ScheduleSweep(); return Task.CompletedTask; } + + public async Task RunAsync(Func> command, string? ownerThread = null) + { + if (_worker is null) throw new InvalidOperationException("Recruitment is starting. Try again shortly."); + var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var finished = TrackThread(ownerThread); + var work = new Work(async ct => + { + try { completion.TrySetResult(await command(_engine, ct)); } + catch (Exception ex) { completion.TrySetException(ex); } + finally { finished(); } + }, () => { finished(); completion.TrySetCanceled(); }); + if (!_queue.Writer.TryWrite(work)) + { finished(); throw new InvalidOperationException("Recruitment is busy. Please try again shortly."); } + return await completion.Task; + } + + public async Task StopAsync(CancellationToken ct) + { + _timer?.Dispose(); + _client.ThreadCreated -= ThreadCreated; + _client.ThreadUpdated -= ThreadUpdated; + _client.ThreadDeleted -= ThreadDeleted; + _client.MessageReceived -= MessageReceived; + _client.Disconnected -= Disconnected; + _client.Connected -= Connected; + _queue.Writer.TryComplete(); + if (_worker is null) return; + try { await _worker.WaitAsync(ct); } + catch (OperationCanceledException) { _stop.Cancel(); await _worker; } + } +} diff --git a/DiscordBot/Services/Recruitment/RecruitmentState.cs b/DiscordBot/Services/Recruitment/RecruitmentState.cs new file mode 100644 index 00000000..993a884a --- /dev/null +++ b/DiscordBot/Services/Recruitment/RecruitmentState.cs @@ -0,0 +1,201 @@ +using Newtonsoft.Json; +using Newtonsoft.Json.Converters; + +namespace DiscordBot.Services.Recruitment; + +[JsonConverter(typeof(StringEnumConverter))] +public enum ListingStatus { Open, Closed, Deleted, Reference } +[JsonConverter(typeof(StringEnumConverter))] +public enum RecruitmentActionKind { Delete, Close, Reopen, RemoveAdvisory } + +public sealed class RecruitmentState +{ + public const string Format = "udc-recruitment-v2"; + [JsonProperty(Required = Required.AllowNull)] + public string Schema { get; set; } = Format; + [JsonProperty(Required = Required.AllowNull)] + public string InstanceId { get; set; } = Guid.NewGuid().ToString("N"); + [JsonProperty(Required = Required.AllowNull)] + public string GuildId { get; set; } = ""; + [JsonProperty(Required = Required.AllowNull)] + public DateTimeOffset InitializedAt { get; set; } + [JsonProperty(Required = Required.AllowNull)] + public DateTimeOffset DiscoveryFloor { get; set; } + public List SetupAudit { get; set; } = new(); + public string? SweepAfterId { get; set; } + public DateTimeOffset? LastSweepAt { get; set; } + public string RotationCode { get; set; } = ""; + public DateTimeOffset RotateAt { get; set; } + [JsonProperty(Required = Required.AllowNull)] + public Dictionary Forums { get; set; } = new(); + [JsonProperty(Required = Required.AllowNull)] + public Dictionary Listings { get; set; } = new(); + [JsonProperty(Required = Required.AllowNull)] + public Dictionary Authors { get; set; } = new(); +} + +public sealed class ForumReceipt +{ + [JsonProperty(Required = Required.AllowNull)] + public string ForumId { get; set; } = ""; + public ArchiveCursor? ArchiveCursor { get; set; } + public string ConfirmedTopic { get; set; } = ""; + public string VisibleCode { get; set; } = ""; + public Dictionary Tags { get; set; } = new(); + // Persist the desired publication before Discord is called, including retries after ambiguous writes. + public bool PendingRepair { get; set; } + public string? PendingTopic { get; set; } + public string? PendingCode { get; set; } + public List? PendingTags { get; set; } +} + +public sealed class ManagedTag +{ + [JsonProperty(Required = Required.AllowNull)] + public string Id { get; set; } = ""; + public string Name { get; set; } = ""; + public bool Moderated { get; set; } +} + +public sealed class Listing +{ + [JsonProperty(Required = Required.AllowNull)] + public string Id { get; set; } = ""; + [JsonProperty(Required = Required.AllowNull)] + public string ForumId { get; set; } = ""; + [JsonProperty(Required = Required.AllowNull)] + public string OwnerId { get; set; } = ""; + public string Title { get; set; } = ""; + [JsonProperty(Required = Required.AllowNull)] + public DateTimeOffset CreatedAt { get; set; } + [JsonProperty(Required = Required.AllowNull)] + public DateTimeOffset UpdatedAt { get; set; } + [JsonProperty(Required = Required.AllowNull)] + public ListingStatus Status { get; set; } + public long Version { get; set; } + [JsonProperty(Required = Required.AllowNull)] + public DateTimeOffset? AcceptedAt { get; set; } + public DateTimeOffset? AcknowledgedAt { get; set; } + public DateTimeOffset? PromptDeliveredAt { get; set; } + public DateTimeOffset? Deadline { get; set; } + public List ValidCodes { get; set; } = new(); + public DateTimeOffset? LastCodeAttemptAt { get; set; } + public string? PublicMessageId { get; set; } + public string? StaffMessageId { get; set; } + public DateTimeOffset? PublicSendStartedAt { get; set; } + public DateTimeOffset? StaffSendStartedAt { get; set; } + public bool PublicationPending { get; set; } + public bool PublicRestoreArchive { get; set; } + public int TimeoutAlertCount { get; set; } + public bool PublicSendPending { get; set; } + public bool StaffSendPending { get; set; } + public bool Exempt { get; set; } + public bool Pinned { get; set; } + public bool HasQualifyingReply { get; set; } + public bool ResponseCoverageComplete { get; set; } + public bool AvailabilityUnknown { get; set; } + public bool SuppressUnansweredClose { get; set; } + public string? Hold { get; set; } + public string LastOutcome { get; set; } = "Awaiting Guidelines acknowledgement"; + public string LastActor { get; set; } = "system"; + public string LastReason { get; set; } = "New listing"; + public RecruitmentAction? PendingAction { get; set; } + public List Audit { get; set; } = new(); + public InitialFacts Facts { get; set; } = new(); +} + +public sealed record RecruitmentAudit(DateTimeOffset At, string Actor, string Reason); + +public sealed class InitialFacts +{ + public DateTimeOffset? AccountCreatedAt { get; set; } + public DateTimeOffset? JoinedAt { get; set; } + public string Activity { get; set; } = "unknown"; + public string Payment { get; set; } = "unknown"; + public string TagAdvice { get; set; } = ""; + public bool PriorAcceptedListing { get; set; } +} + +public sealed class AuthorHistory +{ + [JsonProperty(Required = Required.AllowNull)] + public DateTimeOffset? LastAcceptedCreation { get; set; } + [JsonProperty(Required = Required.AllowNull)] + public DateTimeOffset UpdatedAt { get; set; } + public int ConsecutiveTimeouts { get; set; } + public bool TimeoutAlerted { get; set; } +} + +public sealed class RecruitmentAction +{ + [JsonProperty(Required = Required.AllowNull)] + public string Id { get; set; } = Guid.NewGuid().ToString("N"); + public RecruitmentActionKind Kind { get; set; } + public string ActorId { get; set; } = "system"; + public string Reason { get; set; } = ""; + public bool Automatic { get; set; } + public bool RequiresModerator { get; set; } + public string Condition { get; set; } = ""; + public bool GuidelineTimeout { get; set; } + public bool CallStarted { get; set; } + [JsonProperty(Required = Required.AllowNull)] + public DateTimeOffset CreatedAt { get; set; } +} + +public sealed record Eligibility(string? BlockingListingId, DateTimeOffset? NextAllowedAt) +{ + public bool Allowed => BlockingListingId is null && NextAllowedAt is null; +} + +public static class RecruitmentPolicy +{ + public static Eligibility Evaluate(RecruitmentState state, Listing candidate, DateTimeOffset now, int cooldownDays) + { + var blocker = state.Listings.Values + .Where(p => p.OwnerId == candidate.OwnerId && p.Id != candidate.Id && p.Status == ListingStatus.Open) + .Where(p => p.AcceptedAt.HasValue || p.AvailabilityUnknown || + p.CreatedAt < candidate.CreatedAt || (p.CreatedAt == candidate.CreatedAt && + ulong.Parse(p.Id) < ulong.Parse(candidate.Id))) + .OrderBy(p => p.CreatedAt).ThenBy(p => ulong.Parse(p.Id)).FirstOrDefault(); + var next = state.Authors.GetValueOrDefault(candidate.OwnerId)?.LastAcceptedCreation?.AddDays(cooldownDays); + return new Eligibility(blocker?.Id, next > now ? next : null); + } + + public static bool TryAccept(RecruitmentState state, Listing post, DateTimeOffset now, int cooldownDays) + { + if (post.Status != ListingStatus.Open || post.AcceptedAt.HasValue || !post.AcknowledgedAt.HasValue || + post.AvailabilityUnknown || !Evaluate(state, post, now, cooldownDays).Allowed) return false; + post.AcceptedAt = now; + post.LastOutcome = "Accepted"; + post.Version++; + post.UpdatedAt = now; + Author(state, post.OwnerId, now).LastAcceptedCreation = post.CreatedAt; + return true; + } + + public static AuthorHistory Author(RecruitmentState state, string owner, DateTimeOffset now) + { + if (!state.Authors.TryGetValue(owner, out var history)) + state.Authors[owner] = history = new AuthorHistory(); + history.UpdatedAt = now; + return history; + } + + public static bool CanCloseUnanswered(Listing post, DateTimeOffset now, int days) => + post.Status == ListingStatus.Open && post.AcceptedAt.HasValue && !post.Exempt && !post.Pinned && + !post.AvailabilityUnknown && post.ResponseCoverageComplete && !post.HasQualifyingReply && + !post.SuppressUnansweredClose && post.CreatedAt.AddDays(days) <= now; + + public static void Prune(RecruitmentState state, DateTimeOffset now, int postMonths, int authorMonths, int cooldownDays) + { + var floor = now.AddMonths(-postMonths); + if (floor > state.DiscoveryFloor) state.DiscoveryFloor = floor; + foreach (var p in state.Listings.Values.Where(p => p.Status is ListingStatus.Closed or ListingStatus.Deleted && + p.UpdatedAt < floor && p.PendingAction is null && p.Hold is null).ToArray()) + state.Listings.Remove(p.Id); + foreach (var (id, author) in state.Authors.ToArray()) + if (author.UpdatedAt < now.AddMonths(-authorMonths) && + !(author.LastAcceptedCreation?.AddDays(cooldownDays) > now) && + !state.Listings.Values.Any(p => p.OwnerId == id)) state.Authors.Remove(id); + } +} diff --git a/DiscordBot/Services/Recruitment/RecruitmentStateStore.cs b/DiscordBot/Services/Recruitment/RecruitmentStateStore.cs new file mode 100644 index 00000000..1724d819 --- /dev/null +++ b/DiscordBot/Services/Recruitment/RecruitmentStateStore.cs @@ -0,0 +1,125 @@ +using System.IO; +using System.Text; +using Newtonsoft.Json; + +namespace DiscordBot.Services.Recruitment; + +/// Used only by the recruitment worker. A failed write permanently fences this instance. +public sealed class RecruitmentStateStore : IDisposable +{ + private readonly string _path; + private readonly FileStream _lease; + private bool _faulted; + private string? _lastSnapshot; + private static readonly JsonSerializerSettings Json = new() + { + DateParseHandling = DateParseHandling.DateTimeOffset, + MissingMemberHandling = MissingMemberHandling.Error, + Formatting = Formatting.Indented + }; + public RecruitmentState State { get; } + + public RecruitmentStateStore(string root, string guildId, IReadOnlyDictionary forums, DateTimeOffset now) + { + if (string.IsNullOrWhiteSpace(root)) throw new InvalidDataException("Recruitment needs a persistent ServerRootPath."); + var directory = Path.Combine(root, "recruitment"); + Directory.CreateDirectory(directory); + _path = Path.Combine(directory, "recruitment-state.json"); + _lease = new FileStream(Path.Combine(directory, "writer.lock"), FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None); + try + { + var marker = Path.Combine(directory, "initialized"); + if (File.Exists(_path)) + { + if (!File.Exists(marker)) throw new InvalidDataException("State exists without its initialization receipt; operator recovery required."); + State = Read(_path); + Validate(State, guildId, forums); + _lastSnapshot = JsonConvert.SerializeObject(State, Json); + if (File.ReadAllText(marker) != State.InstanceId) throw new InvalidDataException("Recruitment state identity differs from its receipt."); + } + else + { + if (Directory.EnumerateFiles(directory).Any(file => Path.GetFileName(file) != "writer.lock")) + throw new InvalidDataException("Recruitment history is missing; refusing to reset cooldowns."); + State = new RecruitmentState { GuildId = guildId, InitializedAt = now, DiscoveryFloor = now.AddMonths(-120), + Forums = forums.ToDictionary(x => x.Key, x => new ForumReceipt { ForumId = x.Value }) }; + Save(); + using var receipt = new FileStream(marker, FileMode.CreateNew, FileAccess.Write, FileShare.None); + receipt.Write(Encoding.UTF8.GetBytes(State.InstanceId)); + receipt.Flush(true); + } + } + catch { _lease.Dispose(); throw; } + } + + public void Save() + { + if (_faulted) throw new IOException("Recruitment storage is fenced after an earlier write failure."); + try + { + Validate(State, State.GuildId, State.Forums.ToDictionary(x => x.Key, x => x.Value.ForumId)); + var snapshot = JsonConvert.SerializeObject(State, Json); + if (snapshot == _lastSnapshot) return; + WriteDurably(_path + ".tmp", snapshot); + if (File.Exists(_path)) + { + // Never replace a good backup with an externally damaged primary. + var previous = Read(_path); + Validate(previous, State.GuildId, State.Forums.ToDictionary(x => x.Key, x => x.Value.ForumId)); + if (previous.InstanceId != State.InstanceId) throw new InvalidDataException("State identity changed on disk."); + WriteDurably(_path + ".bak.tmp", File.ReadAllText(_path)); + File.Move(_path + ".bak.tmp", _path + ".bak", true); + } + File.Move(_path + ".tmp", _path, true); + _lastSnapshot = snapshot; + } + catch { _faulted = true; throw; } + } + + private static void WriteDurably(string path, string text) + { + using var file = new FileStream(path, FileMode.Create, FileAccess.Write, FileShare.None); + file.Write(Encoding.UTF8.GetBytes(text)); + file.Flush(true); + } + + private static RecruitmentState Read(string path) + { + if (new FileInfo(path).Length > 32 * 1024 * 1024) throw new InvalidDataException("Recruitment state exceeds 32 MiB; operator review required."); + try + { + return JsonConvert.DeserializeObject(File.ReadAllText(path), Json) + ?? throw new InvalidDataException("Recruitment state is empty."); + } + catch (JsonException ex) { throw new InvalidDataException("Recruitment state is malformed or incomplete; operator recovery required.", ex); } + } + + public static void Validate(RecruitmentState state, string guildId, IReadOnlyDictionary forums) + { + static bool Id(string? id) => ulong.TryParse(id, out var value) && value != 0; + if (state.Schema != RecruitmentState.Format || !Guid.TryParseExact(state.InstanceId, "N", out _) || + state.GuildId != guildId || !Id(guildId) || state.InitializedAt == default || state.DiscoveryFloor == default || + state.Forums is null || state.Listings is null || state.Authors is null || + state.Forums.Count != 4 || forums.Count != 4 || forums.Values.Distinct().Count() != 4 || + forums.Any(f => !Id(f.Value) || !state.Forums.TryGetValue(f.Key, out var saved) || saved.ForumId != f.Value)) + throw new InvalidDataException("Recruitment state schema, identity or forum mapping is invalid."); + foreach (var f in state.Forums.Values) + if (f is null || f.Tags is null || f.ConfirmedTopic is null || f.VisibleCode is null || + f.Tags.Values.Any(t => t is null || !Id(t.Id) || string.IsNullOrWhiteSpace(t.Name))) + throw new InvalidDataException("Recruitment forum receipt is invalid."); + foreach (var (id, p) in state.Listings) + if (p is null || id != p.Id || !Id(id) || !Id(p.OwnerId) || !forums.Values.Contains(p.ForumId) || + p.CreatedAt == default || p.CreatedAt.Offset != TimeSpan.Zero || p.UpdatedAt == default || + !Enum.IsDefined(p.Status) || p.ValidCodes is null || p.Facts is null || p.Audit is null || + !state.Authors.ContainsKey(p.OwnerId) || + (p.AcceptedAt.HasValue && !p.AcknowledgedAt.HasValue) || + (p.Deadline.HasValue && !p.PromptDeliveredAt.HasValue) || + (p.PendingAction is not null && !Enum.IsDefined(p.PendingAction.Kind))) + throw new InvalidDataException("Recruitment listing state is invalid."); + foreach (var (id, a) in state.Authors) + if (!Id(id) || a is null || a.ConsecutiveTimeouts < 0 || a.UpdatedAt == default) + throw new InvalidDataException("Recruitment author history is invalid."); + } + + public void Dispose() => _lease.Dispose(); +} diff --git a/DiscordBot/Services/Server/RecruitService.cs b/DiscordBot/Services/Server/RecruitService.cs deleted file mode 100644 index bd2d1fd4..00000000 --- a/DiscordBot/Services/Server/RecruitService.cs +++ /dev/null @@ -1,398 +0,0 @@ -using Discord.WebSocket; -using DiscordBot.Settings; -using DiscordBot.Utils; - -namespace DiscordBot.Services.Server; - -public class RecruitService -{ - private const string ServiceName = "RecruitmentService"; - - private readonly DiscordSocketClient _client; - private SocketRole ModeratorRole { get; set; } - - #region Extra Details - - private readonly ForumTag _tagIsHiring; - private readonly ForumTag _tagWantsWork; - private readonly ForumTag _tagUnpaidCollab; - private readonly ForumTag _tagPosFilled; - - 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 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; - - private readonly int _editTimePermissionInMin; - 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 = null!; - private Embed _userWantsWorkButNoPrice = null!; - - private Embed _userDidntUseTags = null!; - private Embed _userRevShareMentioned = null!; - private Embed _userMoreThanOneTagUsed = null!; - - Dictionary _botSanityCheck = new Dictionary(); - - #endregion // Configuration - - public RecruitService(DiscordSocketClient client, BotSettings settings) - { - _client = client; - ModeratorRole = _client.GetGuild(settings.GuildId).GetRole(settings.Roles.Moderator); - - if (!settings.Recruitment.Enabled) - { - LoggingService.LogServiceDisabled(ServiceName, nameof(settings.Recruitment.Enabled)); - return; - } - _editTimePermissionInMin = settings.Recruitment.EditPermissionAccessTimeMin; - - // Get target channel - _recruitChannel = _client.GetChannel(settings.Channels.Recruitment.Id) as IForumChannel; - if (_recruitChannel == null) - { - LoggingService.LogToConsole("[{ServiceName}] Recruitment channel not found.", LogSeverity.Error); - return; - } - - try - { - 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); - _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.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) - { - LoggingService.LogToConsole($"[{ServiceName}] Error parsing recruitment tags: {e.Message}", LogSeverity.Error); - } - - // Subscribe to events - _client.ThreadCreated += GatewayOnThreadCreated; - _client.MessageReceived += GatewayOnMessageReceived; - - ConstructEmbeds(); - - LoggingService.LogServiceEnabled(ServiceName); - } - - #region Thread Creation - - private async Task GatewayOnThreadCreated(SocketThreadChannel thread) - { - if (_recruitChannel == null) - return; - if (!thread.IsThreadInChannel(_recruitChannel.Id)) - return; - if (thread.Owner.IsUserBotOrWebhook()) - return; - if (thread.Owner.HasRoleGroup(ModeratorRole)) - return; - - #region Sanity Check - // Oddly the thread is sometimes called twice, so we do a sanity check to make sure we don't process it twice. - // Probably a better way to do this, but this is pretty cheap operation. - if (_botSanityCheck.ContainsKey(thread.Id)) - { - _botSanityCheck.Remove(thread.Id); - return; - } - if (_botSanityCheck.Count > 10) - _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(); - if (message == null) - { - LoggingService.LogToConsole($"[{ServiceName}] Thread {thread.Id} has no messages.", LogSeverity.Error); - return; - } - - _ = Task.Run(async () => - { - if (!DoesThreadHaveAValidTag(thread)) - { - await ThreadHandleNoTags(thread); - return; - } - - if (IsThreadUsingMoreThanOneTag(thread)) - { - await ThreadHandleMoreThanOneTag(thread); - return; - } - - bool isPaidWork = (thread.AppliedTags.Contains(_tagWantsWork.Id) || - thread.AppliedTags.Contains(_tagIsHiring.Id)); - - if (isPaidWork) - { - if (!message.Content.ContainsCurrencySymbol()) - { - await ThreadHandleExpectedCurrency(thread); - return; - } - await ThreadHandleRevShare(thread, message); - } - - // Any Notices that we can recommend the user for improvement - if (message.Content.Length < MinimumLengthMessage) - { - _ = Task.Run(() => ThreadHandleShortMessage(thread, message)); - } - - await Task.Delay(millisecondsDelay: 200); - - // If they got this far, they have a valid thread. - await GrantEditPermissions(thread); - // The above method will await for 30~ minutes, so we need to check if the thread is still valid. - - // Confirm user hasn't deleted the thread - var channel = await _client.GetChannelAsync(thread.Id) as SocketThreadChannel; - if (channel == null) - return; - // Confirm the message still exists - 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()) - { - await ThreadHandleExpectedCurrency(channel); - } - }); - } - - 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 (_recruitChannel == null) - return; - if (!thread.IsThreadInChannel(_recruitChannel.Id)) - return; - if (message.Author.IsUserBotOrWebhook()) - return; - if (message.Author.HasRoleGroup(ModeratorRole)) - return; - - // Sanity process, delete any new messages that aren't written from the thread owner, moderators or bots. - if (message.Author.Id != thread.Owner.Id) - { - await message.DeleteAsync(); - } - } - - #endregion // Thread Creation - - #region Basic Handlers for posts - - private async Task ThreadHandleExpectedCurrency(SocketThreadChannel thread) - { - var embedToUse = thread.AppliedTags.Contains(_tagWantsWork.Id) - ? _userWantsWorkButNoPrice - : _userHiringButNoPrice; - await thread.SendMessageAsync(embed: embedToUse); - await DeleteThread(thread); - } - - private async Task ThreadHandleRevShare(SocketThreadChannel thread, IMessage message) - { - if (message.Content.ContainsRevShare()) - { - 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) - { - var ourResponse = await thread.SendMessageAsync(embed: GetShortMessageEmbed()); - await (ourResponse.DeleteAfterSeconds(ShortMessageNoticeDurationInSec) ?? Task.CompletedTask); - } - } - - 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) ?? Task.CompletedTask); - 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 - - private static void StartUpTagMissing(ulong tagId, string tagName) - { - LoggingService.LogToConsole($"[{ServiceName}] Tag {tagId} not found. '{tagName}'", LogSeverity.Error); - } - - #endregion // Basic Logging Assist - - #region Embed Construction - - private void ConstructEmbeds() - { - _userHiringButNoPrice = new EmbedBuilder() - .WithTitle("No payment price detected") - .WithDescription( - $"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( - $"Rev-share isn't considered a valid form of payment for `{_tagIsHiring.Name}` or `{_tagWantsWork.Name}` as it's not guaranteed. " + - $"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( - $"You may only use one of the following tags: `{_tagIsHiring.Name}`, `{_tagWantsWork.Name}` or `{_tagUnpaidCollab.Name}`\n\n" + - "Be sure to read the guidelines before posting.") - .WithColor(DeletedMessageColor) - .Build(); - - _userDidntUseTags = new EmbedBuilder() - .WithTitle("Broken Guideline: No tags used") - .WithDescription( - $"You must use one of the following tags: `{_tagIsHiring.Name}`, `{_tagWantsWork.Name}` or `{_tagUnpaidCollab.Name}`\n\n" + - "Be sure to read the guidelines before posting.") - .WithColor(DeletedMessageColor) - .Build(); - } - - private Embed GetDeletedMessageEmbed() - { - var message = MessageToBeDeleted.Replace("%s", GetDynamicTimeStampString(TimeBeforeDeletingForumInSec)); - return new EmbedBuilder() - .WithTitle("Post does not follow guidelines") - .WithDescription(message) - .WithColor(DeletedMessageColor) - .Build(); - } - - private Embed GetEditPermMessageEmbed() - { - var message = MessageToBeEdited.Replace("%s", GetDynamicTimeStampString(_editTimePermissionInMin * 60)); - return new EmbedBuilder() - .WithTitle("Edit permissions granted") - .WithDescription(message) - .WithColor(EditedMessageColor) - .Build(); - } - - private Embed GetShortMessageEmbed() - { - var timestamp = GetDynamicTimeStampString(ShortMessageNoticeDurationInSec); - return new EmbedBuilder() - .WithTitle("Notice: Post is short") - .WithDescription( - $"Your post should provide more information to convince others, we recommend at least {MinimumLengthMessage} characters, " + - "which is still very short.\n\nYou should consider editing your post to contain more info otherwise it may be deleted by staff.\n\n" + - $"*This is only a notice and will be removed {timestamp}, can be ignored.*") - .WithColor(WarningMessageColor) - .Build(); - } - - #endregion // Embed Construction - - #region Basic Utility - - 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; - } - - 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()); - await (thread.DeleteAfterSeconds(TimeBeforeDeletingForumInSec) ?? Task.CompletedTask); - } - - private string GetDynamicTimeStampString(int addSeconds) - { - var timestamp = DateTimeOffset.Now.AddSeconds(addSeconds); - return $""; - } - - #endregion // Basic Utility - -} \ No newline at end of file diff --git a/DiscordBot/Settings/Deserialized/Settings.cs b/DiscordBot/Settings/Deserialized/Settings.cs index 6e9ea102..7ab2eb33 100644 --- a/DiscordBot/Settings/Deserialized/Settings.cs +++ b/DiscordBot/Settings/Deserialized/Settings.cs @@ -52,17 +52,7 @@ public class BotSettings ValidateChannel(warnings, Channels.BirthdayAnnouncement, nameof(Channels.BirthdayAnnouncement)); if (Recruitment.Enabled) - { - 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"); - } + warnings.AddRange(Recruitment.Validate().Select(error => $"Recruitment stopped: {error}")); if (UnityHelp.BabySitterEnabled) { @@ -94,7 +84,6 @@ public class ChannelSettings 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!; @@ -114,12 +103,46 @@ public class RoleSettings 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; + public bool Enabled { get; set; } + public ulong FeedChannelId { get; set; } + public RecruitmentForums Forums { get; set; } = new(); + public int AcknowledgementMinutes { get; set; } = 30; + public int CooldownDays { get; set; } = 30; + public int UnansweredDays { get; set; } = 30; + public int PostRetentionMonths { get; set; } = 12; + public int AuthorRetentionMonths { get; set; } = 24; + + public List Validate() + { + var errors = new List(); + var forums = Forums?.Mapping(); + if (forums is null || forums.Values.Any(id => id == 0) || forums.Values.Distinct().Count() != 4) + errors.Add("all four named forum IDs must be nonzero and distinct"); + if (FeedChannelId == 0 || (forums?.Values.Contains(FeedChannelId) ?? false)) + errors.Add("FeedChannelId must identify a separate staff text channel"); + if (AcknowledgementMinutes is < 1 or > 1440 || CooldownDays is < 1 or > 365 || UnansweredDays is < 1 or > 365) + errors.Add("durations must be positive (acknowledgement at most 1440 minutes; cooldown/unanswered at most 365 days)"); + if (PostRetentionMonths is < 1 or > 120 || AuthorRetentionMonths < PostRetentionMonths || AuthorRetentionMonths > 120) + errors.Add("retention must be 1–120 months, with author retention at least post retention"); + return errors; + } +} + +public class RecruitmentForums +{ + public ulong PaidRecruitingId { get; set; } + public ulong PaidForHireId { get; set; } + public ulong HobbyRecruitingId { get; set; } + public ulong HobbyForHireId { get; set; } + + public Dictionary Mapping() => new() + { + ["paid-recruiting"] = PaidRecruitingId, ["paid-for-hire"] = PaidForHireId, + ["hobby-recruiting"] = HobbyRecruitingId, ["hobby-for-hire"] = HobbyForHireId + }; + + public bool Contains(ulong channelId, ulong? parentId = null) => + Mapping().Values.Any(id => id != 0 && (id == channelId || id == parentId)); } public class UnityHelpSettings diff --git a/DiscordBot/Settings/Settings.example.json b/DiscordBot/Settings/Settings.example.json index 3871ab2b..2557cee5 100644 --- a/DiscordBot/Settings/Settings.example.json +++ b/DiscordBot/Settings/Settings.example.json @@ -18,7 +18,6 @@ "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" }, @@ -37,11 +36,18 @@ "Recruitment": { "Enabled": false, - "TagLookingToHire": "0", - "TagLookingForWork": "0", - "TagUnpaidCollab": "0", - "TagPositionFilled": "0", - "EditPermissionAccessTimeMin": 3 + "FeedChannelId": "0", + "Forums": { + "PaidRecruitingId": "0", + "PaidForHireId": "0", + "HobbyRecruitingId": "0", + "HobbyForHireId": "0" + }, + "AcknowledgementMinutes": 30, + "CooldownDays": 30, + "UnansweredDays": 30, + "PostRetentionMonths": 12, + "AuthorRetentionMonths": 24 }, "UnityHelp": { diff --git a/Dockerfile b/Dockerfile index 80361dbd..6a17329b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -19,6 +19,7 @@ COPY --from=build /app/publish/ ./ COPY ./DiscordBot/Assets/fonts/ ./Assets/fonts/ COPY ./DiscordBot/Assets/images/ ./Assets/images/ COPY ./DiscordBot/Assets/skins/ ./Assets/skins/ +COPY ./DiscordBot/Assets/recruitment/ ./Assets/recruitment/ # Add contrib repo for MS fonts, matching the base image's Debian codename RUN . /etc/os-release && \ diff --git a/docs/recruitment.md b/docs/recruitment.md new file mode 100644 index 00000000..45a7b464 --- /dev/null +++ b/docs/recruitment.md @@ -0,0 +1,91 @@ +# Recruitment management + +Recruitment is disabled in the example, development and production settings. It uses four **new** forums, one staff text feed, and the existing configured moderator role. Settings and asset changes take effect after a restart. There is no migration from the old single-forum babysitter or `feat/babysitter` state. + +## Member behavior + +- Each member has **one listing total** across paid recruiting, paid for hire, hobby recruiting and hobby for hire. An older pending attempt reserves the slot; changing forums does not bypass it. +- The bot posts one advisory with an accessible initial PNG snapshot, essential text and owner controls. Database/member facts can be **unknown**. Payment and tag suggestions are advisory; there is no trust score or automated scam accusation. +- The owner enters the five-character code from the forum's native Guidelines. The 30-minute window starts after usable controls are delivered. A correct code accepts an eligible listing immediately. An acknowledged blocked attempt can become accepted if its blocker clears during the window. +- At the deadline, an unacknowledged attempt is removed. An acknowledged attempt still blocked by another listing/cooldown is locked and archived. Pins and explicit exemptions protect against automatic actions. +- Only **accepted** listings start a cooldown, measured from the thread's original creation time. Closing/deleting on day 2 leaves 28 days; doing so on day 40 adds no wait. An open listing still occupies the slot after day 30, including when Discord naturally archives it. Removing an unaccepted attempt creates no cooldown. +- The owner can close the listing or confirm removal through an expiring, single-use control. Accepted cooldowns survive removal. The `Closed` tag also requests lock-and-archive. Existing unrelated applied tags are preserved if there is no room for another tag. +- Accepted listings without a qualifying reply close after 30 days. Replies from other humans count; owner, bot, webhook, configured moderator and administrator replies do not. Recorded qualifying evidence survives message deletion. Incomplete response history holds unanswered closure for staff review. +- Public replies and owner editing remain available while a listing is open. There is no short edit lock, reply deletion, or parent permission rewrite. Configured recruitment forums and their threads do not earn new XP, including while recruitment is disabled; existing totals are unchanged. + +## Configure new forums + +Set these fields under `Recruitment` in the existing settings file: + +```json +{ + "Enabled": false, + "FeedChannelId": "0", + "Forums": { + "PaidRecruitingId": "0", + "PaidForHireId": "0", + "HobbyRecruitingId": "0", + "HobbyForHireId": "0" + }, + "AcknowledgementMinutes": 30, + "CooldownDays": 30, + "UnansweredDays": 30, + "PostRetentionMonths": 12, + "AuthorRetentionMonths": 24 +} +``` + +Replace zero IDs with the new channels. Do not reuse the old `Channels.Recruitment` or manually configure tag IDs. All four IDs must be distinct forum channels in `GuildId`. `Roles.Moderator` must resolve to the intended role. + +The bot needs View Channel, Read Message History, Send Messages in Threads, Manage Threads and Manage Channels on each forum. Attach Files enables the PNG; a definitively rejected image upload falls back to text. In the feed it needs View Channel, Read Message History and Send Messages. Deny View Channel to `@everyone`; explicit access grants are limited to the configured moderator role, administrators and the bot. Existing Guild Members and Message Content intents must also be enabled in the Discord application. + +Review `DiscordBot/Assets/recruitment/guidelines/*.md` and `tags.json` before enabling. Templates own the **entire native Guidelines** and contain one `{{code}}` plus the duration placeholders. Each tag definition has a stable key, display name, advisory group and closing flag. Keep keys stable when renaming a tag. The one closing tag is unmoderated. The shipped copy/tag groups are editable defaults pending moderator approval. + +Local build/publish artifacts and Docker include the recruitment assets and bundled font. Use a persistent `ServerRootPath`; production remains one replica with `Recreate` deployment. Do not run two bot processes against the same state directory. + +Keep member posting closed during initial setup. After configuring and restarting with `Enabled=true`, check `/recruitment status`, preview each forum, and verify native Guidelines, Require Tag and tag IDs. Unexpected first-use content, direct Guidelines edits, changed managed tag permissions or missing known tags hold processing. `/recruitment repair` explicitly republishes owned setup after review, preserves unrelated tags, and can recreate a deleted managed tag. Discord cannot restore the old ID or its lost applications after a tag is deleted; inspect affected listings when repairing that case. Removing managed definitions is intentionally held for manual review. + +The persisted code rotates on Monday at 00:00 UTC. Publication is verified per forum; an interrupted rotation retries the same code. A challenge accepts its issued code and codes subsequently confirmed visible during its window. Codes are not included in advisories, staff entries, previews or diagnostic messages. + +Create, edit and pin the moderator reference post yourself. Use `/recruitment manage` → **Designate reference post** with its thread ID. This excludes it from challenges and capacity and removes the bot's provisional advisory. An already accepted cooldown remains until explicitly waived. Pinning alone does not free an accepted member slot, and ordinary moderator personal listings follow the normal listing policy. + +## Moderator controls + +All commands are ephemeral and check the configured guild and the caller's current moderator role or administrator permission. Changes require an audit reason. + +| Command | Purpose | +| --- | --- | +| `/recruitment status` | Availability, setup/storage faults and inventory holds. | +| `/recruitment lookup id` | Retained thread/author history and creation cooldown. | +| `/recruitment preview [forum]` | Proposed Guidelines with a masked code, and managed tag names. | +| `/recruitment repair reason` | Explicitly publish/verify managed setup after previewing it. | +| `/recruitment reconcile reason` | Retry inventory, message delivery and pending actions. | +| `/recruitment manage operation thread-id reason` | Exempt/unexempt, reset a pending acknowledgement, reset timeout streak, designate reference, close/reopen, or review uncertain evidence. | +| `/recruitment waive-cooldown author reason` | Clear the author's creation cooldown; never grants a second slot. | + +**Confirm qualifying reply** preserves positive evidence. **Confirm no qualifying reply** certifies missing coverage after staff review and allows the normal unanswered deadline to apply. **Confirm unavailable thread is absent** releases a held slot only when Discord availability is uncertain; it does not infer a deletion actor or change the cooldown. Reopening checks capacity and suppresses repeated automatic unanswered closure for that listing. A closed-tag or later explicit close still applies. + +The staff feed keeps one entry per attempt, updated with identity, eligibility, pending action, outcome and recent audit reasons. The third consecutive confirmed Guidelines timeout marks that attempt for moderator attention; later timeouts do not repeat the threshold alert until a real acknowledgement resets the streak. Wrong guesses are rate-limited and do not increment it. An independently missing thread or an ambiguous delete response is not counted as a confirmed bot timeout. + +## Persistence and recovery + +State is stored in `{ServerRootPath}/recruitment/`: + +- `recruitment-state.json`: schema `udc-recruitment-v2`, string IDs, UTC dates, per-forum receipts/cursors, listing records and author aggregates. +- `recruitment-state.json.bak`: last validated snapshot before the latest replacement. +- `initialized`: matching store identity, used to detect a missing primary. +- `writer.lock`: exclusive process lease. The file may remain after shutdown; its existence does not itself mean the lease is held. + +Writes are serialized, flushed and replaced atomically. A write failure fences subsequent mutations until storage is repaired and the process restarted. Corrupt, unsupported, incomplete, missing or mismatched history is never silently replaced with empty cooldowns. Back up the directory and its identity receipt together. A completely lost volume cannot be distinguished locally from a new volume; existing nonempty forum setup holds first-use initialization. Restore history before repairing setup after a suspected volume loss. + +For storage recovery, stop the bot, preserve the damaged files, restore a validated primary (or review the `.bak`) with its matching `initialized` receipt, then restart and reconcile. A backup may precede the latest acceptance/action; inspect the staff feed and Discord before restoring it. Do not change forum/guild mappings, delete the identity receipt, or clear author history to bypass a fault. No legacy state converter is provided. + +The worker saves intent and staff evidence before destructive calls, then rechecks current identity and protection. Interrupted message sends recover bot-owned messages by marker before retrying. Recovery scans are bounded; a scan that cannot establish completeness holds instead of duplicating a message. An interrupted delete that finds an absent thread records an unknown actor. Archived-message updates save a restoration intent, temporarily unarchive when necessary, and restore archiving after updating the bot's own advisory. [Discord thread rules](https://docs.discord.com/developers/topics/threads) + +Startup, disconnects, lost queue events and delivery failures invalidate relevant negative reply evidence and restore full usable windows for pending attempts. Timely queued owner submissions and reply facts are processed before automatic actions; acknowledgement uses the Discord interaction timestamp. Archive discovery resumes bounded pages across sweeps, with at most 50 new identities and 50 retained records processed per pass; incomplete inventory holds acceptance/actions. Retained known records are reconciled directly; terminal details expire after 12 months and inactive author aggregates after 24 months, while open/reference/held/action dependencies remain. The discovery floor prevents expired detail from being recreated by old archive scans. + +## Validation before deployment + +The automated tests cover policy, persistence faults, setup/rotation, lost responses, evidence gaps, owner/staff authorization, interaction registration and the real Linux PNG renderer. They do not call live Discord. + +Before opening posting access, exercise the new forums in staging: effective permissions and feed privacy, native Guidelines/tag repair, acknowledgement and immediate acceptance, duplicate/cooldown behavior, removal confirmation, pinned/reference protection, unanswered/Closed closure, restart recovery, image descriptions and mobile readability. Verify the mounted persistent directory and graceful shutdown. No live bot login, guild command registration, staging mutation, deployment or remote push is part of local implementation validation. diff --git a/k8s/dev/bot-config.yaml b/k8s/dev/bot-config.yaml index 1c9f60e7..8123df36 100644 --- a/k8s/dev/bot-config.yaml +++ b/k8s/dev/bot-config.yaml @@ -29,7 +29,6 @@ data: "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", @@ -53,11 +52,19 @@ data: }, "Recruitment": { - "Enabled": true, - "TagLookingToHire": "1134673961779724480", - "TagLookingForWork": "1134673990993051658", - "TagUnpaidCollab": "1134674009292804117", - "TagPositionFilled": "1134674041450545283" + "Enabled": false, + "FeedChannelId": "0", + "Forums": { + "PaidRecruitingId": "0", + "PaidForHireId": "0", + "HobbyRecruitingId": "0", + "HobbyForHireId": "0" + }, + "AcknowledgementMinutes": 30, + "CooldownDays": 30, + "UnansweredDays": 30, + "PostRetentionMonths": 12, + "AuthorRetentionMonths": 24 }, "UnityHelp": { diff --git a/k8s/prod/bot-config.yaml b/k8s/prod/bot-config.yaml index de4335cd..e32dda31 100644 --- a/k8s/prod/bot-config.yaml +++ b/k8s/prod/bot-config.yaml @@ -29,7 +29,6 @@ data: "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", @@ -54,11 +53,18 @@ data: "Recruitment": { "Enabled": false, - "TagLookingToHire": "1019680606067630151", - "TagLookingForWork": "1019680763756695653", - "TagUnpaidCollab": "1019680795641774110", - "TagPositionFilled": "1052258665530408991", - "EditPermissionAccessTimeMin": 3 + "FeedChannelId": "0", + "Forums": { + "PaidRecruitingId": "0", + "PaidForHireId": "0", + "HobbyRecruitingId": "0", + "HobbyForHireId": "0" + }, + "AcknowledgementMinutes": 30, + "CooldownDays": 30, + "UnansweredDays": 30, + "PostRetentionMonths": 12, + "AuthorRetentionMonths": 24 }, "UnityHelp": { From 1637cef1e0bc91c3c79b5e5d2a0eefd9a261ee31 Mon Sep 17 00:00:00 2001 From: James Kellie Date: Tue, 29 Sep 2026 21:22:31 +1000 Subject: [PATCH 02/31] Trim recruitment Guidelines to 1,000 characters --- .../RecruitmentStorageAndSetupTests.cs | 31 +++++++++++++++++++ .../recruitment/guidelines/hobby-for-hire.md | 12 +++---- .../guidelines/hobby-recruiting.md | 12 +++---- .../recruitment/guidelines/paid-for-hire.md | 12 +++---- .../recruitment/guidelines/paid-recruiting.md | 12 +++---- .../Services/Recruitment/RecruitmentAssets.cs | 6 +++- docs/recruitment.md | 2 +- 7 files changed, 61 insertions(+), 26 deletions(-) diff --git a/DiscordBot.Tests/Recruitment/RecruitmentStorageAndSetupTests.cs b/DiscordBot.Tests/Recruitment/RecruitmentStorageAndSetupTests.cs index 45b4a39d..e96998ec 100644 --- a/DiscordBot.Tests/Recruitment/RecruitmentStorageAndSetupTests.cs +++ b/DiscordBot.Tests/Recruitment/RecruitmentStorageAndSetupTests.cs @@ -6,6 +6,37 @@ namespace DiscordBot.Tests.Recruitment; public class RecruitmentStorageAndSetupTests { + [Fact] + public void ShippedGuidelinesFitBudgetWithMaximumDurations() + { + using var rig = new RecruitmentRig(); + var settings = rig.Settings.Recruitment; + settings.AcknowledgementMinutes = 1440; + settings.CooldownDays = settings.UnansweredDays = 365; + var assets = new RecruitmentAssets(rig.Settings.AssetsRootPath, settings); + foreach (var purpose in settings.Forums.Mapping().Keys) + { + var text = assets.Render(purpose, "ABCDE", settings); + Assert.InRange(text.Length, 1, 1000); + Assert.Contains("pinned moderator/admin post", text); + Assert.Contains("ABCDE", text); + } + } + + [Theory] + [InlineData(1000)] + [InlineData(1001)] + public void GuidelinesBudgetChecksRenderedLength(int length) + { + using var rig = new RecruitmentRig(); + var assets = new RecruitmentAssets(rig.Settings.AssetsRootPath, rig.Settings.Recruitment); + assets.Templates["paid-recruiting"] = new string('x', length - 5) + "{{code}}"; + if (length == 1000) + Assert.Equal(length, assets.Render("paid-recruiting", "ABCDE", rig.Settings.Recruitment).Length); + else + Assert.Throws(() => assets.Render("paid-recruiting", "ABCDE", rig.Settings.Recruitment)); + } + [Fact] public async Task StoreRoundTripsCooldownAndRefusesSecondWriter() { diff --git a/DiscordBot/Assets/recruitment/guidelines/hobby-for-hire.md b/DiscordBot/Assets/recruitment/guidelines/hobby-for-hire.md index 2e174d27..68a04ba2 100644 --- a/DiscordBot/Assets/recruitment/guidelines/hobby-for-hire.md +++ b/DiscordBot/Assets/recruitment/guidelines/hobby-for-hire.md @@ -1,13 +1,13 @@ # Hobby for hire -Describe the skills you offer, examples of your work and your available time. State that you are offering unpaid collaboration and explain your expectations. +Describe your skills, examples, availability and expectations for unpaid collaboration. -Use an accurate title and relevant discipline and engagement tags. Missing pricing or recommended tags will receive advice; they are not proof of misconduct. +Read the pinned moderator/admin post at the top of this forum for fuller guidance. Use relevant tags; verify claims and agree terms in writing. -You may have **one open listing total across all four recruitment forums**. If you both recruit and offer your services, combine them in a for-hire listing. An accepted listing starts a **{{cooldownDays}}-day** creation cooldown. Closing or deleting it does not reset or shorten that deadline. Unaccepted attempts do not start a cooldown. +You may have **one open listing total across all four recruitment forums**. Combine hiring and offering services in a for-hire listing. -Read these Guidelines, then use the bot's **Acknowledge** button and enter **{{code}}** within **{{acknowledgementMinutes}} minutes of the bot's prompt**. Unacknowledged posts are removed. Acknowledged duplicates or posts still on cooldown are closed when that window ends. +Enter **{{code}}** using **Acknowledge** within **{{acknowledgementMinutes}} minutes of the bot's prompt**. Unacknowledged posts are removed; acknowledged posts still blocked by another listing or cooldown are closed. -Use **Closed** when your listing is filled or no longer needed. The bot locks and archives closed listings. Accepted listings without a qualifying community reply are closed after **{{unansweredDays}} days**; ordinary auto-archive does not free your listing slot. +Only accepted listings start a **{{cooldownDays}}-day cooldown from creation**. Closing/deleting does not change it; unaccepted attempts add none. -Be respectful. Agree on scope and terms in writing, verify claims independently, and avoid sharing sensitive information. The bot's facts are context, not an endorsement. Contact moderators about concerns. +Mark finished listings **Closed** to lock/archive them. Accepted listings with no qualifying community reply close after **{{unansweredDays}} days**. Natural auto-archive does not free your slot. diff --git a/DiscordBot/Assets/recruitment/guidelines/hobby-recruiting.md b/DiscordBot/Assets/recruitment/guidelines/hobby-recruiting.md index f8b82cf5..4b5da20f 100644 --- a/DiscordBot/Assets/recruitment/guidelines/hobby-recruiting.md +++ b/DiscordBot/Assets/recruitment/guidelines/hobby-recruiting.md @@ -1,13 +1,13 @@ # Hobby recruiting -Describe the project, current progress, required skills and realistic time commitment. State clearly that this is unpaid; explain any revenue-sharing arrangement without promising income. +Describe the project, progress, skills and time needed. Clearly state unpaid or revenue-share terms. -Use an accurate title and relevant discipline and engagement tags. Missing pricing or recommended tags will receive advice; they are not proof of misconduct. +Read the pinned moderator/admin post at the top of this forum for fuller guidance. Use relevant tags; verify claims and agree terms in writing. -You may have **one open listing total across all four recruitment forums**. If you both recruit and offer your services, combine them in a for-hire listing. An accepted listing starts a **{{cooldownDays}}-day** creation cooldown. Closing or deleting it does not reset or shorten that deadline. Unaccepted attempts do not start a cooldown. +You may have **one open listing total across all four recruitment forums**. Combine hiring and offering services in a for-hire listing. -Read these Guidelines, then use the bot's **Acknowledge** button and enter **{{code}}** within **{{acknowledgementMinutes}} minutes of the bot's prompt**. Unacknowledged posts are removed. Acknowledged duplicates or posts still on cooldown are closed when that window ends. +Enter **{{code}}** using **Acknowledge** within **{{acknowledgementMinutes}} minutes of the bot's prompt**. Unacknowledged posts are removed; acknowledged posts still blocked by another listing or cooldown are closed. -Use **Closed** when your listing is filled or no longer needed. The bot locks and archives closed listings. Accepted listings without a qualifying community reply are closed after **{{unansweredDays}} days**; ordinary auto-archive does not free your listing slot. +Only accepted listings start a **{{cooldownDays}}-day cooldown from creation**. Closing/deleting does not change it; unaccepted attempts add none. -Be respectful. Agree on scope and terms in writing, verify claims independently, and avoid sharing sensitive information. The bot's facts are context, not an endorsement. Contact moderators about concerns. +Mark finished listings **Closed** to lock/archive them. Accepted listings with no qualifying community reply close after **{{unansweredDays}} days**. Natural auto-archive does not free your slot. diff --git a/DiscordBot/Assets/recruitment/guidelines/paid-for-hire.md b/DiscordBot/Assets/recruitment/guidelines/paid-for-hire.md index 093c870a..8e6758c2 100644 --- a/DiscordBot/Assets/recruitment/guidelines/paid-for-hire.md +++ b/DiscordBot/Assets/recruitment/guidelines/paid-for-hire.md @@ -1,13 +1,13 @@ # Paid for hire -Describe your services, portfolio, availability, rates and currency. Explain how clients should contact you. +Describe your services, portfolio, availability, rates, currency and contact details. -Use an accurate title and relevant discipline and engagement tags. Missing pricing or recommended tags will receive advice; they are not proof of misconduct. +Read the pinned moderator/admin post at the top of this forum for fuller guidance. Use relevant tags; verify claims and agree terms in writing. -You may have **one open listing total across all four recruitment forums**. If you both recruit and offer your services, combine them in a for-hire listing. An accepted listing starts a **{{cooldownDays}}-day** creation cooldown. Closing or deleting it does not reset or shorten that deadline. Unaccepted attempts do not start a cooldown. +You may have **one open listing total across all four recruitment forums**. Combine hiring and offering services in a for-hire listing. -Read these Guidelines, then use the bot's **Acknowledge** button and enter **{{code}}** within **{{acknowledgementMinutes}} minutes of the bot's prompt**. Unacknowledged posts are removed. Acknowledged duplicates or posts still on cooldown are closed when that window ends. +Enter **{{code}}** using **Acknowledge** within **{{acknowledgementMinutes}} minutes of the bot's prompt**. Unacknowledged posts are removed; acknowledged posts still blocked by another listing or cooldown are closed. -Use **Closed** when your listing is filled or no longer needed. The bot locks and archives closed listings. Accepted listings without a qualifying community reply are closed after **{{unansweredDays}} days**; ordinary auto-archive does not free your listing slot. +Only accepted listings start a **{{cooldownDays}}-day cooldown from creation**. Closing/deleting does not change it; unaccepted attempts add none. -Be respectful. Agree on scope and terms in writing, verify claims independently, and avoid sharing sensitive information. The bot's facts are context, not an endorsement. Contact moderators about concerns. +Mark finished listings **Closed** to lock/archive them. Accepted listings with no qualifying community reply close after **{{unansweredDays}} days**. Natural auto-archive does not free your slot. diff --git a/DiscordBot/Assets/recruitment/guidelines/paid-recruiting.md b/DiscordBot/Assets/recruitment/guidelines/paid-recruiting.md index 3479d411..ddff64e5 100644 --- a/DiscordBot/Assets/recruitment/guidelines/paid-recruiting.md +++ b/DiscordBot/Assets/recruitment/guidelines/paid-recruiting.md @@ -1,13 +1,13 @@ # Paid recruiting -Describe the work, scope, budget, currency and payment schedule. Specify the skills you need and how applicants should contact you. +Describe the work, skills needed, budget, currency, payment schedule and contact details. -Use an accurate title and relevant discipline and engagement tags. Missing pricing or recommended tags will receive advice; they are not proof of misconduct. +Read the pinned moderator/admin post at the top of this forum for fuller guidance. Use relevant tags; verify claims and agree terms in writing. -You may have **one open listing total across all four recruitment forums**. If you both recruit and offer your services, combine them in a for-hire listing. An accepted listing starts a **{{cooldownDays}}-day** creation cooldown. Closing or deleting it does not reset or shorten that deadline. Unaccepted attempts do not start a cooldown. +You may have **one open listing total across all four recruitment forums**. Combine hiring and offering services in a for-hire listing. -Read these Guidelines, then use the bot's **Acknowledge** button and enter **{{code}}** within **{{acknowledgementMinutes}} minutes of the bot's prompt**. Unacknowledged posts are removed. Acknowledged duplicates or posts still on cooldown are closed when that window ends. +Enter **{{code}}** using **Acknowledge** within **{{acknowledgementMinutes}} minutes of the bot's prompt**. Unacknowledged posts are removed; acknowledged posts still blocked by another listing or cooldown are closed. -Use **Closed** when your listing is filled or no longer needed. The bot locks and archives closed listings. Accepted listings without a qualifying community reply are closed after **{{unansweredDays}} days**; ordinary auto-archive does not free your listing slot. +Only accepted listings start a **{{cooldownDays}}-day cooldown from creation**. Closing/deleting does not change it; unaccepted attempts add none. -Be respectful. Agree on scope and terms in writing, verify claims independently, and avoid sharing sensitive information. The bot's facts are context, not an endorsement. Contact moderators about concerns. +Mark finished listings **Closed** to lock/archive them. Accepted listings with no qualifying community reply close after **{{unansweredDays}} days**. Natural auto-archive does not free your slot. diff --git a/DiscordBot/Services/Recruitment/RecruitmentAssets.cs b/DiscordBot/Services/Recruitment/RecruitmentAssets.cs index ae7905e2..cc264644 100644 --- a/DiscordBot/Services/Recruitment/RecruitmentAssets.cs +++ b/DiscordBot/Services/Recruitment/RecruitmentAssets.cs @@ -15,6 +15,8 @@ public sealed class TagDefinition public sealed class RecruitmentAssets { + public const int GuidelinesMaxLength = 1000; + public Dictionary Templates { get; } public Dictionary> Tags { get; } public RecruitmentAssets(string root, RecruitmentSettings settings) @@ -47,7 +49,9 @@ public string Render(string purpose, string code, RecruitmentSettings settings) .Replace("{{acknowledgementMinutes}}", settings.AcknowledgementMinutes.ToString()) .Replace("{{cooldownDays}}", settings.CooldownDays.ToString()) .Replace("{{unansweredDays}}", settings.UnansweredDays.ToString()).Trim(); - if (text.Length > 4096 || text.Contains("{{")) throw new InvalidDataException($"Invalid Guidelines length/placeholders for {purpose}."); + if (text.Length > GuidelinesMaxLength) + throw new InvalidDataException($"Guidelines for {purpose} exceed the {GuidelinesMaxLength}-character budget after rendering."); + if (text.Contains("{{")) throw new InvalidDataException($"Invalid Guidelines placeholders for {purpose}."); return text; } diff --git a/docs/recruitment.md b/docs/recruitment.md index 45a7b464..b2d8522d 100644 --- a/docs/recruitment.md +++ b/docs/recruitment.md @@ -39,7 +39,7 @@ Replace zero IDs with the new channels. Do not reuse the old `Channels.Recruitme The bot needs View Channel, Read Message History, Send Messages in Threads, Manage Threads and Manage Channels on each forum. Attach Files enables the PNG; a definitively rejected image upload falls back to text. In the feed it needs View Channel, Read Message History and Send Messages. Deny View Channel to `@everyone`; explicit access grants are limited to the configured moderator role, administrators and the bot. Existing Guild Members and Message Content intents must also be enabled in the Discord application. -Review `DiscordBot/Assets/recruitment/guidelines/*.md` and `tags.json` before enabling. Templates own the **entire native Guidelines** and contain one `{{code}}` plus the duration placeholders. Each tag definition has a stable key, display name, advisory group and closing flag. Keep keys stable when renaming a tag. The one closing tag is unmoderated. The shipped copy/tag groups are editable defaults pending moderator approval. +Review `DiscordBot/Assets/recruitment/guidelines/*.md` and `tags.json` before enabling. Templates own the **entire native Guidelines** and contain one `{{code}}` plus the duration placeholders. Rendered Guidelines must fit the 1,000-character budget, including the code and configured durations. They point members to the pinned moderator/admin post at the top of the forum for fuller guidance; moderators maintain that post separately. Each tag definition has a stable key, display name, advisory group and closing flag. Keep keys stable when renaming a tag. The one closing tag is unmoderated. The shipped copy/tag groups are editable defaults pending moderator approval. Local build/publish artifacts and Docker include the recruitment assets and bundled font. Use a persistent `ServerRootPath`; production remains one replica with `Recreate` deployment. Do not run two bot processes against the same state directory. From fb9a9da7d9bb1f6688ebe4d174c850bc00b3b1a5 Mon Sep 17 00:00:00 2001 From: James Kellie Date: Wed, 14 Oct 2026 19:48:13 +1000 Subject: [PATCH 03/31] style(editorconfig): reorganize settings for consistency and clarity --- .editorconfig | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/.editorconfig b/.editorconfig index ba610bc3..e677d42b 100644 --- a/.editorconfig +++ b/.editorconfig @@ -1,10 +1,22 @@ root = true +[*] +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true +charset = utf-8 +indent_style = space +indent_size = 4 + [*.cs] # Unused private members dotnet_diagnostic.IDE0051.severity = warning dotnet_diagnostic.IDE0052.severity = warning +tab_width = 4 # Discord.Net discovers command handlers via reflection — they appear unused to static analysis [DiscordBot/Modules/**.cs] dotnet_diagnostic.IDE0051.severity = none + +[*.json] +indent_size = 2 From 988fe7d04cb0175213371babe3e4d774b7f2e1b4 Mon Sep 17 00:00:00 2001 From: James Kellie Date: Wed, 14 Oct 2026 19:48:29 +1000 Subject: [PATCH 04/31] fix(launch): update program path to .NET 8 for DiscordBot --- .vscode/launch.json | 52 ++++++++++++++++++++++----------------------- 1 file changed, 26 insertions(+), 26 deletions(-) diff --git a/.vscode/launch.json b/.vscode/launch.json index 12e91de6..b3cb765a 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -1,27 +1,27 @@ { - // Use IntelliSense to find out which attributes exist for C# debugging - // Use hover for the description of the existing attributes - // For further information visit https://github.com/OmniSharp/omnisharp-vscode/blob/master/debugger-launchjson.md - "version": "0.2.0", - "configurations": [ - { - "name": ".NET Core Launch (console)", - "type": "coreclr", - "request": "launch", - "preLaunchTask": "build", - // If you have changed target frameworks, make sure to update the program path. - "program": "${workspaceFolder}/DiscordBot/bin/Debug/netcoreapp2.1/DiscordBot.dll", - "args": [], - "cwd": "${workspaceFolder}/DiscordBot", - // For more information about the 'console' field, see https://aka.ms/VSCode-CS-LaunchJson-Console - "console": "internalConsole", - "stopAtEntry": false - }, - { - "name": ".NET Core Attach", - "type": "coreclr", - "request": "attach", - "processId": "${command:pickProcess}" - } - ] -} \ No newline at end of file + // Use IntelliSense to find out which attributes exist for C# debugging + // Use hover for the description of the existing attributes + // For further information visit https://github.com/OmniSharp/omnisharp-vscode/blob/master/debugger-launchjson.md + "version": "0.2.0", + "configurations": [ + { + "name": ".NET Core Launch (console)", + "type": "coreclr", + "request": "launch", + "preLaunchTask": "build", + // If you have changed target frameworks, make sure to update the program path. + "program": "${workspaceFolder}/DiscordBot/bin/Debug/net8.0/DiscordBot.dll", + "args": [], + "cwd": "${workspaceFolder}/DiscordBot", + // For more information about the 'console' field, see https://aka.ms/VSCode-CS-LaunchJson-Console + "console": "internalConsole", + "stopAtEntry": false + }, + { + "name": ".NET Core Attach", + "type": "coreclr", + "request": "attach", + "processId": "${command:pickProcess}" + } + ] +} From c42b99609be2209e5adf21a73da20cc43eb500ec Mon Sep 17 00:00:00 2001 From: James Kellie Date: Wed, 14 Oct 2026 20:00:45 +1000 Subject: [PATCH 05/31] fix(vscode): update launch and task configurations for improved debugging and output paths docs: add local development section to README for .NET 8 setup and usage instructions --- .vscode/launch.json | 4 ++-- .vscode/tasks.json | 14 +++++++---- DiscordBot.Tests/DiscordBot.Tests.csproj | 1 + DiscordBot/DiscordBot.csproj | 3 ++- README.md | 30 ++++++++++++++++++++++++ 5 files changed, 45 insertions(+), 7 deletions(-) diff --git a/.vscode/launch.json b/.vscode/launch.json index b3cb765a..997f7a60 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -9,8 +9,8 @@ "type": "coreclr", "request": "launch", "preLaunchTask": "build", - // If you have changed target frameworks, make sure to update the program path. - "program": "${workspaceFolder}/DiscordBot/bin/Debug/net8.0/DiscordBot.dll", + // The build task writes here regardless of the project's target framework. + "program": "${workspaceFolder}/DiscordBot/bin/vscode/DiscordBot.dll", "args": [], "cwd": "${workspaceFolder}/DiscordBot", // For more information about the 'console' field, see https://aka.ms/VSCode-CS-LaunchJson-Console diff --git a/.vscode/tasks.json b/.vscode/tasks.json index adf4d422..eab0d4b2 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -8,6 +8,10 @@ "args": [ "build", "${workspaceFolder}/DiscordBot/DiscordBot.csproj", + "--configuration", + "Debug", + "--output", + "${workspaceFolder}/DiscordBot/bin/vscode", "/property:GenerateFullPaths=true", "/consoleloggerparameters:NoSummary" ], @@ -31,12 +35,14 @@ "type": "process", "args": [ "watch", - "run", + "--project", "${workspaceFolder}/DiscordBot/DiscordBot.csproj", - "/property:GenerateFullPaths=true", - "/consoleloggerparameters:NoSummary" + "run" ], + "options": { + "cwd": "${workspaceFolder}/DiscordBot" + }, "problemMatcher": "$msCompile" } ] -} \ No newline at end of file +} diff --git a/DiscordBot.Tests/DiscordBot.Tests.csproj b/DiscordBot.Tests/DiscordBot.Tests.csproj index 74ab4e74..d4ec071e 100644 --- a/DiscordBot.Tests/DiscordBot.Tests.csproj +++ b/DiscordBot.Tests/DiscordBot.Tests.csproj @@ -2,6 +2,7 @@ net8.0 + Major enable enable false diff --git a/DiscordBot/DiscordBot.csproj b/DiscordBot/DiscordBot.csproj index 7be4ecad..c5ad05c2 100644 --- a/DiscordBot/DiscordBot.csproj +++ b/DiscordBot/DiscordBot.csproj @@ -2,6 +2,7 @@ Exe net8.0 + Major 12 enable true @@ -25,4 +26,4 @@ - \ No newline at end of file + diff --git a/README.md b/README.md index 1d46464b..e748db9f 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ The code is provided as-is and there will be no guaranteed support to help make - [Compiling](#compiling) - [Dependencies](#dependencies) - [Running](#running) + - [Local Development](#local-development) - [Docker](#docker) - [Runtime Dependencies](#runtime-dependencies) - [Notes](#notes) @@ -247,6 +248,35 @@ dotnet build _For production deployment, see the [Deployment Guide](docs/deployment.md)._ +### Local Development + +Install the .NET 8 SDK or a newer SDK, such as .NET 10. The projects compile for +`net8.0`; NuGet restore downloads any missing targeting packs. The bot and tests +use [major-version runtime roll-forward](https://learn.microsoft.com/en-us/dotnet/core/project-sdk/msbuild-props#rollforward): +they prefer .NET 8 when installed and otherwise use the next installed newer +major runtime. Older runtimes cannot run the bot, and newer major versions still +need compatibility testing. + +**VS Code:** Install the Microsoft C# extension, open the repository root, select +`.NET Core Launch (console)` in Run and Debug, and press **F5**. The build task +writes Debug binaries to `DiscordBot/bin/vscode`, and the debugger launches from +that stable path. Changing the project's target framework does not require +editing `launch.json`. The working directory remains `DiscordBot`, so local +settings and asset paths resolve there. + +**Command line:** Run from the bot directory so relative settings and asset paths +resolve consistently with VS Code: + +```bash +cd DiscordBot +dotnet run +# Or rebuild and restart when source files change: +dotnet watch run +``` + +The VS Code `watch` task uses the same working directory. Run the tests from the +repository root with `dotnet test`. + ### Docker **Recommended for development:** Docker simplifies database setup and ensures consistency. From 78be5d6706509dcc4bc2c4691ca60802d78238aa Mon Sep 17 00:00:00 2001 From: James Kellie Date: Wed, 14 Oct 2026 20:58:45 +1000 Subject: [PATCH 06/31] Fix staged recruitment forum setup --- .../RecruitmentStorageAndSetupTests.cs | 45 +++++- .../Recruitment/RecruitmentTestRig.cs | 16 +- .../Recruitment/RecruitmentTransportTests.cs | 90 +++++++++++ DiscordBot/Program.cs | 1 + .../Recruitment/DiscordRecruitmentAdapter.cs | 36 ++++- .../Recruitment/IRecruitmentDiscord.cs | 4 +- .../Services/Recruitment/RecruitmentAssets.cs | 149 +++++++++++------- .../Services/Recruitment/RecruitmentEngine.cs | 6 +- .../Services/Recruitment/RecruitmentFault.cs | 28 ++++ .../Recruitment/RecruitmentForumTransport.cs | 65 ++++++++ .../Services/Recruitment/RecruitmentState.cs | 2 + 11 files changed, 366 insertions(+), 76 deletions(-) create mode 100644 DiscordBot.Tests/Recruitment/RecruitmentTransportTests.cs create mode 100644 DiscordBot/Services/Recruitment/RecruitmentFault.cs create mode 100644 DiscordBot/Services/Recruitment/RecruitmentForumTransport.cs diff --git a/DiscordBot.Tests/Recruitment/RecruitmentStorageAndSetupTests.cs b/DiscordBot.Tests/Recruitment/RecruitmentStorageAndSetupTests.cs index e96998ec..b8f32c8f 100644 --- a/DiscordBot.Tests/Recruitment/RecruitmentStorageAndSetupTests.cs +++ b/DiscordBot.Tests/Recruitment/RecruitmentStorageAndSetupTests.cs @@ -82,8 +82,8 @@ public async Task SetupIsIdempotentAndKeepsTagIdentities() { using var rig = new RecruitmentRig(); await rig.Start(); var ids = rig.State.Forums["paid-recruiting"].Tags.ToDictionary(t => t.Key, t => t.Value.Id); - Assert.Equal(4, rig.Discord.ForumWrites); await rig.Sweep(); await rig.Restart(); - Assert.Equal(4, rig.Discord.ForumWrites); + Assert.Equal(8, rig.Discord.ForumWrites); await rig.Sweep(); await rig.Restart(); + Assert.Equal(8, rig.Discord.ForumWrites); Assert.Equal(ids, rig.State.Forums["paid-recruiting"].Tags.ToDictionary(t => t.Key, t => t.Value.Id)); Assert.All(rig.Discord.Forums.Values, f => Assert.True(f.RequireTag)); } @@ -118,7 +118,46 @@ public async Task FirstUseDoesNotOverwriteUnexpectedForumContent() { using var rig = new RecruitmentRig(); rig.Discord.Forums["11"] = rig.Discord.Forums["11"] with { Topic = "Existing Guidelines" }; await rig.Start(); Assert.Contains("Guidelines changed", rig.Engine.Status); - Assert.Equal(0, rig.Discord.ForumWrites); + Assert.Equal(6, rig.Discord.ForumWrites); // Other forums can complete their independent setup. + Assert.NotNull(rig.State.Forums["paid-recruiting"].Fault); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task InterruptedSetupRecoversAssignedTagIdsBeforeEnablingRequireTag(bool lostResponse) + { + using var rig = new RecruitmentRig(); + if (lostResponse) rig.Discord.LoseNextForumResponse = true; + else { rig.Discord.FailForum = "11"; rig.Discord.FailForumRequireTag = true; } + await rig.Start(); + var ids = rig.Discord.Forums["11"].Tags.Select(t => t.Id).ToArray(); + Assert.NotEmpty(ids); + Assert.False(rig.Discord.Forums["11"].RequireTag); + Assert.NotNull(rig.State.Forums["paid-recruiting"].PendingTopic); + rig.Discord.FailForum = null; + await rig.Restart(); + Assert.Equal(ids, rig.Discord.Forums["11"].Tags.Select(t => t.Id)); + Assert.True(rig.Discord.Forums["11"].RequireTag); + Assert.Null(rig.State.Forums["paid-recruiting"].PendingTopic); + Assert.Single(rig.Discord.ForumPublications, p => p.Id == "11" && p.Tags); + } + + [Fact] + public async Task UnchangedPermanentSetupHoldDoesNotReadOrWriteUntilExplicitRecovery() + { + using var rig = new RecruitmentRig(); + rig.Discord.FailForum = "11"; + await rig.Start(); + var fault = rig.State.Forums["paid-recruiting"].Fault; + Assert.Null(fault!.RetryAt); + var attempts = fault.Attempts; + rig.Discord.FailForum = null; + await rig.Engine.SweepAsync(default, retryHeld: false); + Assert.Equal(attempts, rig.State.Forums["paid-recruiting"].Fault!.Attempts); + Assert.False(rig.Discord.Forums["11"].RequireTag); + await rig.Staff("repair"); + Assert.Null(rig.State.Forums["paid-recruiting"].Fault); } [Fact] diff --git a/DiscordBot.Tests/Recruitment/RecruitmentTestRig.cs b/DiscordBot.Tests/Recruitment/RecruitmentTestRig.cs index 5ae40dfa..f948c259 100644 --- a/DiscordBot.Tests/Recruitment/RecruitmentTestRig.cs +++ b/DiscordBot.Tests/Recruitment/RecruitmentTestRig.cs @@ -63,6 +63,9 @@ internal sealed class FakeRecruitmentDiscord : IRecruitmentDiscord public bool LoseNextDeleteResponse { get; set; } public bool FailPermissions { get; set; } public string? FailForum { get; set; } + public bool? FailForumRequireTag { get; set; } + public bool LoseNextForumResponse { get; set; } + public List<(string Id, bool Tags, bool RequireTag)> ForumPublications { get; } = new(); public Action? BeforeDelete { get; set; } public int Sends { get; private set; } public int ForumWrites { get; private set; } @@ -70,12 +73,17 @@ internal sealed class FakeRecruitmentDiscord : IRecruitmentDiscord public Task ValidateAccessAsync(CancellationToken ct) => FailPermissions ? Task.FromException(new InvalidOperationException("Missing permissions")) : Task.CompletedTask; public Task IsModeratorAsync(string guildId, string userId, CancellationToken ct) => Task.FromResult(guildId == "1" && userId == "900"); public Task GetForumAsync(string id, CancellationToken ct) => Task.FromResult(Forums[id]); - public Task PublishForumAsync(string id, string topic, IReadOnlyList tags, CancellationToken ct) + public Task PublishForumAsync(ForumSnapshot expected, string? topic, IReadOnlyList? tags, bool requireTag, CancellationToken ct) { - if (FailForum == id) throw new InvalidOperationException("Forum write failed"); + var id = expected.Id; + if (FailForum == id && (FailForumRequireTag is null || FailForumRequireTag == requireTag)) throw new InvalidOperationException("Forum write failed"); ForumWrites++; - Forums[id] = new ForumSnapshot(id, topic, true, tags.Select(t => t with { Id = t.Id == "" ? (++_nextId).ToString() : t.Id }).ToArray()); - return Task.CompletedTask; + ForumPublications.Add((id, tags is not null, requireTag)); + Forums[id] = expected with { Topic = topic ?? expected.Topic, RequireTag = requireTag, + Flags = requireTag ? expected.Flags | Discord.ChannelFlags.RequireTag : expected.Flags & ~Discord.ChannelFlags.RequireTag, + Tags = tags?.Select(t => t with { Id = t.Id == "" ? (++_nextId).ToString() : t.Id }).ToArray() ?? expected.Tags }; + if (LoseNextForumResponse) { LoseNextForumResponse = false; throw new TimeoutException("Forum response lost"); } + return Task.FromResult(Forums[id]); } public Task InventoryAsync(DateTimeOffset floor, IReadOnlySet knownIds, IReadOnlyDictionary cursors, CancellationToken ct) => Task.FromResult(new ThreadInventory(Threads.Values.Where(t => t.CreatedAt >= floor).ToArray(), InventoryComplete)); public Task GetThreadAsync(string id, CancellationToken ct) => UnknownThreads.Contains(id) diff --git a/DiscordBot.Tests/Recruitment/RecruitmentTransportTests.cs b/DiscordBot.Tests/Recruitment/RecruitmentTransportTests.cs new file mode 100644 index 00000000..a474efae --- /dev/null +++ b/DiscordBot.Tests/Recruitment/RecruitmentTransportTests.cs @@ -0,0 +1,90 @@ +using System.Net; +using System.Text; +using Discord; +using Discord.Net.Rest; +using Discord.Rest; +using DiscordBot.Services.Recruitment; +using Newtonsoft.Json.Linq; + +namespace DiscordBot.Tests.Recruitment; + +public class RecruitmentTransportTests +{ + [Fact] + public async Task InstalledSdkPreservesModerationEmojisAndFlagsThroughStagedPublication() + { + var transport = new ForumTransport(); + using var client = new DiscordRestClient(new DiscordRestConfig + { RestClientProvider = RecruitmentForumTransport.Wrap(_ => transport) }); + await client.LoginAsync(TokenType.Bot, "offline-placeholder-token", validateToken: false); + var forum = Assert.IsType(await client.GetChannelAsync(11)); + var tags = new[] { new LiveTag("51", "Staff", true, new Emoji("⭐")), new LiveTag("", "Programming", false) }; + var first = await DiscordRecruitmentAdapter.ModifyForumAsync(forum, "Native Guidelines", tags, false, new RequestOptions()); + Assert.False(first.RequireTag); + Assert.All(first.Tags, t => Assert.NotEmpty(t.Id)); + Assert.True(first.Tags.Single(t => t.Id == "51").Moderated); + await DiscordRecruitmentAdapter.ModifyForumAsync(forum, null, null, true, new RequestOptions()); + await DiscordRecruitmentAdapter.ModifyForumAsync(forum, "Rotated Guidelines", null, true, new RequestOptions()); + Assert.Equal(3, transport.Writes.Count); + var tagWrite = transport.Writes[0]; + Assert.True((bool)tagWrite["available_tags"]![0]!["moderated"]!); + Assert.False((bool)tagWrite["available_tags"]![1]!["moderated"]!); + Assert.Equal("⭐", (string?)tagWrite["available_tags"]![0]!["emoji_name"]); + Assert.Equal(32768, (int)tagWrite["flags"]!); + Assert.Equal(32784, (int)transport.Writes[1]["flags"]!); + Assert.Null(transport.Writes[1]["available_tags"]); + Assert.Null(transport.Writes[2]["available_tags"]); + Assert.Equal(32784, (int)transport.Writes[2]["flags"]!); + } + + [Fact] + public async Task TagPublicationWithoutScopeFailsBeforeTransport() + { + var inner = new ForumTransport(); + using var transport = new RecruitmentForumTransport(inner); + await Assert.ThrowsAsync(() => transport.SendAsync("PATCH", "channels/11", "{}", + default, false, RecruitmentForumTransport.TagWriteReason, Array.Empty>>())); + Assert.Empty(inner.Writes); + } + + // Uses the real installed SDK's request queue and serialization, but never a network transport. + private sealed class ForumTransport : IRestClient + { + public List Writes { get; } = new(); + private readonly JObject _forum = JObject.Parse(""" + {"id":"11","guild_id":"1","type":15,"name":"recruitment","topic":"","flags":32768, + "available_tags":[{"id":"51","name":"Staff","moderated":true,"emoji_name":"⭐"}]} + """); + public void Dispose() { } + public void SetHeader(string key, string value) { } + public void SetCancelToken(CancellationToken cancelToken) { } + public Task SendAsync(string method, string endpoint, CancellationToken cancelToken, bool headerOnly, + string reason, IEnumerable>> requestHeaders) => Respond(method, endpoint, null); + public Task SendAsync(string method, string endpoint, string json, CancellationToken cancelToken, bool headerOnly, + string reason, IEnumerable>> requestHeaders) => Respond(method, endpoint, json); + public Task SendAsync(string method, string endpoint, IReadOnlyDictionary multipart, + CancellationToken cancelToken, bool headerOnly, string reason, IEnumerable>> requestHeaders) => + throw new InvalidOperationException("Unexpected multipart request"); + private async Task Respond(string method, string endpoint, string? json) + { + await Task.Yield(); // Verify the SDK preserves the scoped correction across asynchronous work. + string body; + if (method == "GET" && endpoint == "users/@me") body = """{"id":"999","username":"offline","discriminator":"0","bot":true}"""; + else if (method == "GET" && endpoint == "oauth2/applications/@me") body = """{"id":"999","name":"offline","description":"","bot_public":true,"bot_require_code_grant":false,"verify_key":"00"}"""; + else if (endpoint == "channels/11" && method is "GET" or "PATCH") + { + if (method == "PATCH") + { + var patch = JObject.Parse(json!); Writes.Add(patch); + foreach (var property in patch.Properties()) _forum[property.Name] = property.Value.DeepClone(); + var next = 60; + foreach (var tag in (JArray)_forum["available_tags"]!) + if (tag["id"] is null) tag["id"] = (++next).ToString(); + } + body = _forum.ToString(); + } + else throw new InvalidOperationException($"Unexpected offline route: {method} {endpoint}"); + return new RestResponse(HttpStatusCode.OK, new Dictionary(), new MemoryStream(Encoding.UTF8.GetBytes(body))); + } + } +} diff --git a/DiscordBot/Program.cs b/DiscordBot/Program.cs index 4e690da9..5dcd6d9c 100644 --- a/DiscordBot/Program.cs +++ b/DiscordBot/Program.cs @@ -39,6 +39,7 @@ private async Task MainAsync() _client = new DiscordSocketClient(new DiscordSocketConfig { + RestClientProvider = RecruitmentForumTransport.Wrap(Discord.Net.Rest.DefaultRestClientProvider.Create()), LogLevel = LogSeverity.Verbose, AlwaysDownloadUsers = true, MessageCacheSize = 1024, diff --git a/DiscordBot/Services/Recruitment/DiscordRecruitmentAdapter.cs b/DiscordBot/Services/Recruitment/DiscordRecruitmentAdapter.cs index 356bbada..8bda408b 100644 --- a/DiscordBot/Services/Recruitment/DiscordRecruitmentAdapter.cs +++ b/DiscordBot/Services/Recruitment/DiscordRecruitmentAdapter.cs @@ -7,11 +7,13 @@ namespace DiscordBot.Services.Recruitment; +/// Discord I/O and authoritative pre-action checks for the serialized recruitment engine. public sealed class DiscordRecruitmentAdapter : IRecruitmentDiscord { private readonly DiscordSocketClient _client; private readonly BotSettings _settings; private readonly DatabaseService _database; + private readonly Dictionary _forums = new(); public DiscordRecruitmentAdapter(DiscordSocketClient client, BotSettings settings, DatabaseService database) => (_client, _settings, _database) = (client, settings, database); private static RequestOptions Options(CancellationToken ct) => new() { CancelToken = ct, Timeout = 15000 }; @@ -68,24 +70,42 @@ private async Task ForumAsync(string id, CancellationToken ct) public async Task GetForumAsync(string forumId, CancellationToken ct) { var forum = await ForumAsync(forumId, ct); - return new ForumSnapshot(forumId, forum.Topic ?? "", (forum.Flags & ChannelFlags.RequireTag) != 0, - forum.Tags.Select(t => new LiveTag(t.Id.ToString(), t.Name, t.IsModerated, t.Emoji)).ToArray()); + var snapshot = Snapshot(forum); + _forums[forumId] = (snapshot, forum); + return snapshot; } - public async Task PublishForumAsync(string forumId, string topic, IReadOnlyList tags, CancellationToken ct) + private static ForumSnapshot Snapshot(RestForumChannel forum) => new(forum.Id.ToString(), forum.Topic ?? "", + (forum.Flags & ChannelFlags.RequireTag) != 0, + forum.Tags.Select(t => new LiveTag(t.Id.ToString(), t.Name, t.IsModerated, t.Emoji)).ToArray(), forum.Flags); + + public async Task PublishForumAsync(ForumSnapshot expected, string? topic, IReadOnlyList? tags, bool requireTag, CancellationToken ct) { - var forum = await ForumAsync(forumId, ct); + if (!_forums.TryGetValue(expected.Id, out var observed) || !ReferenceEquals(observed.Snapshot, expected)) + throw new InvalidOperationException("Forum publication needs a current pre-write observation."); + var snapshot = await ModifyForumAsync(observed.Channel, topic, tags, requireTag, Options(ct)); + _forums[expected.Id] = (snapshot, observed.Channel); + return snapshot; + } + + internal static async Task ModifyForumAsync(RestForumChannel forum, string? topic, + IReadOnlyList? tags, bool requireTag, RequestOptions options) + { + using var scope = tags is null ? null : RecruitmentForumTransport.PreserveTags(forum.Id, tags); + if (tags is not null) options.AuditLogReason = RecruitmentForumTransport.TagWriteReason; await forum.ModifyAsync(p => { - p.Topic = topic; - p.Flags = forum.Flags | ChannelFlags.RequireTag; - p.Tags = tags.Select(t => + if (topic is not null) p.Topic = topic; + // Discord.Net 3.20.1 sends flags:0 when Flags is omitted, even for a topic-only edit. + p.Flags = requireTag ? forum.Flags | ChannelFlags.RequireTag : forum.Flags & ~ChannelFlags.RequireTag; + if (tags is not null) p.Tags = tags.Select(t => { var builder = new ForumTagBuilder(t.Name, string.IsNullOrEmpty(t.Id) ? null : ulong.Parse(t.Id), t.Moderated); if (t.Emoji is not null) builder.WithEmoji(t.Emoji); return builder.Build(); }).ToArray(); - }, Options(ct)); + }, options); + return Snapshot(forum); } public async Task InventoryAsync(DateTimeOffset floor, IReadOnlySet knownIds, diff --git a/DiscordBot/Services/Recruitment/IRecruitmentDiscord.cs b/DiscordBot/Services/Recruitment/IRecruitmentDiscord.cs index 340931ba..2eeb561e 100644 --- a/DiscordBot/Services/Recruitment/IRecruitmentDiscord.cs +++ b/DiscordBot/Services/Recruitment/IRecruitmentDiscord.cs @@ -1,7 +1,7 @@ namespace DiscordBot.Services.Recruitment; public sealed record LiveTag(string Id, string Name, bool Moderated, IEmote? Emoji = null); -public sealed record ForumSnapshot(string Id, string Topic, bool RequireTag, IReadOnlyList Tags); +public sealed record ForumSnapshot(string Id, string Topic, bool RequireTag, IReadOnlyList Tags, ChannelFlags Flags = ChannelFlags.None); public sealed record ThreadSnapshot(string Id, string ForumId, string OwnerId, string Title, DateTimeOffset CreatedAt, bool OwnerIsBot, bool Pinned, bool Archived, bool Locked, IReadOnlyList Tags, string? Starter, DateTimeOffset? AccountCreatedAt, DateTimeOffset? JoinedAt); @@ -17,7 +17,7 @@ public interface IRecruitmentDiscord Task ValidateAccessAsync(CancellationToken ct); Task IsModeratorAsync(string guildId, string userId, CancellationToken ct); Task GetForumAsync(string forumId, CancellationToken ct); - Task PublishForumAsync(string forumId, string topic, IReadOnlyList tags, CancellationToken ct); + Task PublishForumAsync(ForumSnapshot expected, string? topic, IReadOnlyList? tags, bool requireTag, CancellationToken ct); Task InventoryAsync(DateTimeOffset floor, IReadOnlySet knownIds, IReadOnlyDictionary cursors, CancellationToken ct); Task GetThreadAsync(string threadId, CancellationToken ct); Task HasReplyAsync(ThreadSnapshot thread, CancellationToken ct); diff --git a/DiscordBot/Services/Recruitment/RecruitmentAssets.cs b/DiscordBot/Services/Recruitment/RecruitmentAssets.cs index cc264644..bae8432d 100644 --- a/DiscordBot/Services/Recruitment/RecruitmentAssets.cs +++ b/DiscordBot/Services/Recruitment/RecruitmentAssets.cs @@ -70,6 +70,7 @@ public string TagAdvice(string purpose, IEnumerable appliedIds, ForumRec } } +/// Publishes native Guidelines and managed tags, retaining intent across partial Discord writes. public sealed class RecruitmentSetup { private readonly RecruitmentStateStore _store; @@ -79,7 +80,7 @@ public sealed class RecruitmentSetup public RecruitmentSetup(RecruitmentStateStore store, RecruitmentAssets assets, RecruitmentSettings settings, IRecruitmentDiscord discord) => (_store, _assets, _settings, _discord) = (store, assets, settings, discord); - public async Task ReconcileAsync(DateTimeOffset now, bool repair, CancellationToken ct) + public async Task ReconcileAsync(DateTimeOffset now, bool repair, CancellationToken ct, bool retryHeld = true, string? target = null) { var state = _store.State; if (state.RotationCode.Length == 0 || (now >= state.RotateAt && state.Forums.Values.All(f => f.PendingTopic is null && f.VisibleCode == state.RotationCode))) @@ -92,70 +93,104 @@ public async Task ReconcileAsync(DateTimeOffset now, bool repair, CancellationTo } foreach (var (purpose, receipt) in state.Forums) { - var live = await _discord.GetForumAsync(receipt.ForumId, ct); - var topic = receipt.PendingTopic ?? _assets.Render(purpose, state.RotationCode, _settings); - var code = receipt.PendingCode ?? state.RotationCode; - var definitions = receipt.PendingTags ?? _assets.Tags[purpose]; - if (!repair && live.Topic != receipt.ConfirmedTopic && live.Topic != receipt.PendingTopic) - throw new InvalidOperationException($"Guidelines changed outside the bot in {purpose}; preview and repair setup."); - if (!repair && receipt.ConfirmedTopic.Length > 0 && receipt.PendingTopic is null && !live.RequireTag) - throw new InvalidOperationException($"Require Tag was disabled outside the bot in {purpose}; preview and repair setup."); - if (!repair && receipt.ConfirmedTopic.Length == 0 && receipt.PendingTopic is null && live.Tags.Count > 0) - throw new InvalidOperationException($"Unexpected existing tags in {purpose}; preview and repair setup."); - if (receipt.Tags.Keys.Except(definitions.Select(d => d.Key)).Any()) - throw new InvalidOperationException($"Removing managed tags in {purpose} needs manual review; existing relationships must be preserved."); - var desired = live.Tags.ToList(); - foreach (var definition in definitions) + if (target is not null && target != purpose) continue; + if (!repair && !retryHeld && receipt.Fault is { } fault && !fault.IsDue(now)) continue; + try { - receipt.Tags.TryGetValue(definition.Key, out var saved); - var existing = saved is null ? null : live.Tags.SingleOrDefault(t => t.Id == saved.Id); - if (saved is not null && existing is null && !repair && !receipt.PendingRepair) - throw new InvalidOperationException($"A managed tag is missing in {purpose}; preview and repair setup to recreate it."); - if (!repair && existing is not null && existing.Moderated != saved!.Moderated) - throw new InvalidOperationException($"Managed tag permissions changed outside the bot in {purpose}."); - if (!repair && existing is not null && existing.Name != saved!.Name && existing.Name != definition.Name) - throw new InvalidOperationException($"A managed tag was renamed outside the bot in {purpose}."); - var matches = live.Tags.Where(t => t.Name.Equals(definition.Name, StringComparison.OrdinalIgnoreCase) && t.Id != existing?.Id).ToArray(); - if (existing is null && matches.Length == 1 && (repair || receipt.PendingTags is not null)) existing = matches[0]; - else if (matches.Length > 0) throw new InvalidOperationException($"Conflicting tag names in {purpose}."); - if (existing is not null) desired.Remove(existing); - desired.Add(new LiveTag(existing?.Id ?? "", definition.Name, false)); + await ReconcileForumAsync(purpose, receipt, repair, ct); + receipt.Fault = null; } - if (desired.Count > 20) throw new InvalidOperationException($"Tag capacity exceeded in {purpose}; unrelated tags were preserved."); - if (live.Topic != topic || !live.RequireTag || !SameTags(live.Tags, desired)) + catch (Exception ex) when (ex is not OperationCanceledException && ex is not IOException) { - receipt.PendingRepair |= repair; - receipt.PendingTopic = topic; - receipt.PendingCode = code; - receipt.PendingTags = definitions; - _store.Save(); - // Re-read immediately before writing to detect edits made during setup preparation. - var beforeWrite = await _discord.GetForumAsync(receipt.ForumId, ct); - if (beforeWrite.Topic != live.Topic || !SameTags(beforeWrite.Tags, live.Tags) || beforeWrite.RequireTag != live.RequireTag) - throw new InvalidOperationException($"Concurrent forum edit in {purpose}; retry setup preview."); - await _discord.PublishForumAsync(receipt.ForumId, topic, desired, ct); - live = await _discord.GetForumAsync(receipt.ForumId, ct); + receipt.Fault = RecruitmentFault.From(ex, now, receipt.Fault); } - if (live.Topic != topic || !live.RequireTag || desired.Count != live.Tags.Count || - desired.Any(t => !live.Tags.Any(v => v.Name == t.Name && v.Moderated == t.Moderated && (t.Id == "" || t.Id == v.Id)))) - throw new InvalidOperationException($"Setup verification failed in {purpose}."); - receipt.Tags = definitions.ToDictionary(d => d.Key, d => - { - var tag = live.Tags.Single(t => t.Name == d.Name); - return new ManagedTag { Id = tag.Id, Name = tag.Name, Moderated = tag.Moderated }; - }); - receipt.ConfirmedTopic = topic; - receipt.VisibleCode = code; - receipt.PendingTopic = receipt.PendingCode = null; - receipt.PendingTags = null; - receipt.PendingRepair = false; - // Any code actually visible during a delivered challenge remains valid for that challenge. - foreach (var post in state.Listings.Values.Where(p => p.ForumId == receipt.ForumId && p.Status == ListingStatus.Open && !p.AcceptedAt.HasValue)) - if (!post.ValidCodes.Contains(code)) post.ValidCodes.Add(code); _store.Save(); } } + private async Task ReconcileForumAsync(string purpose, ForumReceipt receipt, bool repair, CancellationToken ct) + { + var state = _store.State; + var live = await _discord.GetForumAsync(receipt.ForumId, ct); + var topic = receipt.PendingTopic ?? _assets.Render(purpose, state.RotationCode, _settings); + var code = receipt.PendingCode ?? state.RotationCode; + var definitions = receipt.PendingTags ?? _assets.Tags[purpose]; + if (!repair && live.Topic != receipt.ConfirmedTopic && live.Topic != receipt.PendingTopic) + throw new InvalidOperationException($"Guidelines changed outside the bot in {purpose}; preview and repair setup."); + if (!repair && receipt.ConfirmedTopic.Length > 0 && receipt.PendingTopic is null && !live.RequireTag) + throw new InvalidOperationException($"Require Tag was disabled outside the bot in {purpose}; preview and repair setup."); + if (!repair && receipt.ConfirmedTopic.Length == 0 && receipt.PendingTopic is null && live.Tags.Count > 0) + throw new InvalidOperationException($"Unexpected existing tags in {purpose}; preview and repair setup."); + if (receipt.Tags.Keys.Except(definitions.Select(d => d.Key)).Any()) + throw new InvalidOperationException($"Removing managed tags in {purpose} needs manual review; existing relationships must be preserved."); + var desired = live.Tags.ToList(); + foreach (var definition in definitions) + { + receipt.Tags.TryGetValue(definition.Key, out var saved); + var existing = saved is null ? null : live.Tags.SingleOrDefault(t => t.Id == saved.Id); + if (saved is not null && existing is null && !repair && !receipt.PendingRepair) + throw new InvalidOperationException($"A managed tag is missing in {purpose}; preview and repair setup to recreate it."); + if (!repair && existing is not null && existing.Moderated != saved!.Moderated) + throw new InvalidOperationException($"Managed tag permissions changed outside the bot in {purpose}."); + if (!repair && existing is not null && existing.Name != saved!.Name && existing.Name != definition.Name) + throw new InvalidOperationException($"A managed tag was renamed outside the bot in {purpose}."); + var matches = live.Tags.Where(t => t.Name.Equals(definition.Name, StringComparison.OrdinalIgnoreCase) && t.Id != existing?.Id).ToArray(); + if (existing is null && matches.Length == 1 && (repair || receipt.PendingTags is not null)) existing = matches[0]; + else if (matches.Length > 0) throw new InvalidOperationException($"Conflicting tag names in {purpose}."); + if (existing is not null) desired.Remove(existing); + desired.Add(new LiveTag(existing?.Id ?? "", definition.Name, false)); + } + if (desired.Count > 20) throw new InvalidOperationException($"Tag capacity exceeded in {purpose}; unrelated tags were preserved."); + if (live.Topic != topic || !live.RequireTag || !SameTags(live.Tags, desired)) + { + receipt.PendingRepair |= repair; + receipt.PendingTopic = topic; + receipt.PendingCode = code; + receipt.PendingTags = definitions; + _store.Save(); + // Re-read immediately before writing to detect edits made during setup preparation. + var beforeWrite = await _discord.GetForumAsync(receipt.ForumId, ct); + if (beforeWrite.Topic != live.Topic || !SameTags(beforeWrite.Tags, live.Tags) || beforeWrite.Flags != live.Flags) + throw new InvalidOperationException($"Concurrent forum edit in {purpose}; retry setup preview."); + // Discord validates Require Tag against selectable tags. Establish them first, including + // their assigned IDs, before enabling that flag on an initially empty forum. + live = beforeWrite; + if (live.Topic != topic || !SameTags(live.Tags, desired)) + live = await _discord.PublishForumAsync(live, live.Topic == topic ? null : topic, + SameTags(live.Tags, desired) ? null : desired, live.RequireTag, ct); + if (!MatchesPublication(live, topic, desired) || !live.Tags.Any(t => !t.Moderated && t.Id.Length > 0)) + throw new InvalidOperationException($"Tag/Guidelines verification failed in {purpose}; Require Tag was not enabled."); + if (!live.RequireTag) + { + beforeWrite = await _discord.GetForumAsync(receipt.ForumId, ct); + if (beforeWrite.Topic != live.Topic || !SameTags(beforeWrite.Tags, live.Tags) || beforeWrite.Flags != live.Flags) + throw new InvalidOperationException($"Concurrent forum edit in {purpose}; retry setup preview."); + live = await _discord.PublishForumAsync(beforeWrite, null, null, true, ct); + } + } + if (!live.RequireTag || !MatchesPublication(live, topic, desired)) + throw new InvalidOperationException($"Setup verification failed in {purpose}."); + receipt.Tags = definitions.ToDictionary(d => d.Key, d => + { + var tag = live.Tags.Single(t => t.Name == d.Name); + return new ManagedTag { Id = tag.Id, Name = tag.Name, Moderated = tag.Moderated }; + }); + receipt.ConfirmedTopic = topic; + receipt.VisibleCode = code; + receipt.PendingTopic = receipt.PendingCode = null; + receipt.PendingTags = null; + receipt.PendingRepair = false; + // Any code actually visible during a delivered challenge remains valid for that challenge. + foreach (var post in state.Listings.Values.Where(p => p.ForumId == receipt.ForumId && p.Status == ListingStatus.Open && !p.AcceptedAt.HasValue)) + if (!post.ValidCodes.Contains(code)) post.ValidCodes.Add(code); + _store.Save(); + } + + private static bool MatchesPublication(ForumSnapshot live, string topic, IReadOnlyList desired) => + live.Topic == topic && desired.Count == live.Tags.Count && + desired.All(t => live.Tags.Any(v => v.Name == t.Name && v.Moderated == t.Moderated && + v.Emoji?.ToString() == t.Emoji?.ToString() && (t.Id == "" || t.Id == v.Id))); + private static bool SameTags(IReadOnlyList a, IReadOnlyList b) => a.OrderBy(t => t.Id).Select(t => (t.Id, t.Name, t.Moderated, Emoji: t.Emoji?.ToString())) .SequenceEqual(b.OrderBy(t => t.Id).Select(t => (t.Id, t.Name, t.Moderated, Emoji: t.Emoji?.ToString()))); diff --git a/DiscordBot/Services/Recruitment/RecruitmentEngine.cs b/DiscordBot/Services/Recruitment/RecruitmentEngine.cs index 85c147d9..c79486f1 100644 --- a/DiscordBot/Services/Recruitment/RecruitmentEngine.cs +++ b/DiscordBot/Services/Recruitment/RecruitmentEngine.cs @@ -59,14 +59,16 @@ public void MarkEvidenceGap() Save(); } - public async Task SweepAsync(CancellationToken ct) + public async Task SweepAsync(CancellationToken ct, bool retryHeld = true) { if (_store is null || _setup is null) return; _ready = false; try { await _discord.ValidateAccessAsync(ct); - await _setup.ReconcileAsync(Now, false, ct); + await _setup.ReconcileAsync(Now, false, ct, retryHeld); + if (State!.Forums.Values.FirstOrDefault(f => f.Fault is not null) is { } failed) + throw new InvalidOperationException(failed.Fault!.Message); _ready = true; await DiscoverAsync(ct); var cursor = ulong.TryParse(State!.SweepAfterId, out var savedCursor) ? savedCursor : 0; diff --git a/DiscordBot/Services/Recruitment/RecruitmentFault.cs b/DiscordBot/Services/Recruitment/RecruitmentFault.cs new file mode 100644 index 00000000..1fb53eff --- /dev/null +++ b/DiscordBot/Services/Recruitment/RecruitmentFault.cs @@ -0,0 +1,28 @@ +using Discord.Net; +using System.Net; + +namespace DiscordBot.Services.Recruitment; + +/// A durable hold or retry deadline. Null RetryAt requires a relevant change or explicit recovery. +public sealed record RecruitmentFault(string Message, DateTimeOffset? RetryAt, int Attempts = 1) +{ + public bool IsDue(DateTimeOffset now) => RetryAt.HasValue && RetryAt <= now; + + public static RecruitmentFault From(Exception error, DateTimeOffset now, RecruitmentFault? previous = null) + { + var attempts = Math.Min((previous?.Attempts ?? 0) + 1, 10); + var permanent = error is InvalidOperationException || error is HttpException http && + (int)http.HttpCode is >= 400 and < 500 && http.HttpCode != HttpStatusCode.TooManyRequests; + var message = error is HttpException discord + ? $"Discord {(int)discord.HttpCode}/{(int?)discord.DiscordCode}: {discord.Message}" + : error.Message; + var retry = error is RecruitmentDeferredException deferred ? deferred.RetryAt + : permanent ? (DateTimeOffset?)null : now.AddSeconds(Math.Min(900, 15 * Math.Pow(2, attempts - 1)) + Random.Shared.Next(1, 6)); + return new RecruitmentFault(RecruitmentPresentation.Safe(message, 400), retry, attempts); + } +} + +internal sealed class RecruitmentDeferredException(DateTimeOffset retryAt) : Exception("Discord rate limit; retry scheduled") +{ + public DateTimeOffset RetryAt { get; } = retryAt; +} diff --git a/DiscordBot/Services/Recruitment/RecruitmentForumTransport.cs b/DiscordBot/Services/Recruitment/RecruitmentForumTransport.cs new file mode 100644 index 00000000..a7a6c009 --- /dev/null +++ b/DiscordBot/Services/Recruitment/RecruitmentForumTransport.cs @@ -0,0 +1,65 @@ +using Discord.Net.Rest; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace DiscordBot.Services.Recruitment; + +/// Corrects recruitment tag serialization without bypassing Discord.Net's request queue. +internal sealed class RecruitmentForumTransport(IRestClient inner) : IRestClient +{ + internal const string TagWriteReason = "Recruitment: publish forum tags"; + private static readonly AsyncLocal Current = new(); + private sealed record TagScope(string Endpoint, IReadOnlyList Tags); + + public static RestClientProvider Wrap(RestClientProvider provider) => url => new RecruitmentForumTransport(provider(url)); + + internal static IDisposable PreserveTags(ulong forumId, IReadOnlyList tags) + { + var previous = Current.Value; + Current.Value = new TagScope($"channels/{forumId}", tags); + return new Scope(() => Current.Value = previous); + } + + private sealed class Scope(Action restore) : IDisposable + { + public void Dispose() => restore(); + } + + public void Dispose() => inner.Dispose(); + public void SetHeader(string key, string value) => inner.SetHeader(key, value); + public void SetCancelToken(CancellationToken cancelToken) => inner.SetCancelToken(cancelToken); + + public Task SendAsync(string method, string endpoint, CancellationToken cancelToken, + bool headerOnly, string reason, IEnumerable>> requestHeaders) => + inner.SendAsync(method, endpoint, cancelToken, headerOnly, reason, requestHeaders); + + public Task SendAsync(string method, string endpoint, IReadOnlyDictionary multipart, + CancellationToken cancelToken, bool headerOnly, string reason, + IEnumerable>> requestHeaders) => + inner.SendAsync(method, endpoint, multipart, cancelToken, headerOnly, reason, requestHeaders); + + public Task SendAsync(string method, string endpoint, string json, CancellationToken cancelToken, + bool headerOnly, string reason, IEnumerable>> requestHeaders) + { + if (reason == TagWriteReason) + { + // 3.20.1 ForumHelper drops IsModerated. Fail closed if a future SDK stops carrying + // the execution context or changes the payload, rather than demoting unrelated tags. + var scope = Current.Value; + if (scope is null || method != "PATCH" || endpoint != scope.Endpoint) + throw new InvalidOperationException("Recruitment tag publication context is unavailable."); + var body = JObject.Parse(json); + if (body["available_tags"] is not JArray tags || tags.Count != scope.Tags.Count) + throw new InvalidOperationException("Recruitment tag payload changed unexpectedly."); + for (var i = 0; i < tags.Count; i++) + { + var expected = scope.Tags[i]; + if ((string?)tags[i]["name"] != expected.Name || ((string?)tags[i]["id"] ?? "") != expected.Id) + throw new InvalidOperationException("Recruitment tag payload identity changed unexpectedly."); + tags[i]["moderated"] = expected.Moderated; + } + json = body.ToString(Formatting.None); + } + return inner.SendAsync(method, endpoint, json, cancelToken, headerOnly, reason, requestHeaders); + } +} diff --git a/DiscordBot/Services/Recruitment/RecruitmentState.cs b/DiscordBot/Services/Recruitment/RecruitmentState.cs index 993a884a..ddf0d5ee 100644 --- a/DiscordBot/Services/Recruitment/RecruitmentState.cs +++ b/DiscordBot/Services/Recruitment/RecruitmentState.cs @@ -34,10 +34,12 @@ public sealed class RecruitmentState public Dictionary Authors { get; set; } = new(); } +/// Verified forum publication and any unfinished desired state; never contains an inferred success. public sealed class ForumReceipt { [JsonProperty(Required = Required.AllowNull)] public string ForumId { get; set; } = ""; + public RecruitmentFault? Fault { get; set; } public ArchiveCursor? ArchiveCursor { get; set; } public string ConfirmedTopic { get; set; } = ""; public string VisibleCode { get; set; } = ""; From 40b67f42f13cc9d354a5238642100e8fecad5b59 Mon Sep 17 00:00:00 2001 From: James Kellie Date: Wed, 14 Oct 2026 21:47:02 +1000 Subject: [PATCH 07/31] Drive recruitment from events and scheduled deadlines - Cache observations and defer Discord throttles - Add responsive status and a recovering feed overview - Preserve queued evidence before automatic actions --- .../Recruitment/RecruitmentOverviewTests.cs | 104 ++++++ .../Recruitment/RecruitmentRecoveryTests.cs | 2 +- .../Recruitment/RecruitmentSchedulingTests.cs | 203 ++++++++++ .../RecruitmentStorageAndSetupTests.cs | 20 + .../Recruitment/RecruitmentTestRig.cs | 99 ++++- .../Recruitment/RecruitmentTransportTests.cs | 108 +++++- .../Recruitment/RecruitmentWorkflowTests.cs | 2 +- .../Modules/Recruitment/RecruitmentModule.cs | 16 +- .../Recruitment/DiscordRecruitmentAdapter.cs | 119 ++++-- .../Recruitment/IRecruitmentDiscord.cs | 15 +- .../Services/Recruitment/RecruitmentAssets.cs | 2 + .../Services/Recruitment/RecruitmentEngine.cs | 147 +++++--- .../RecruitmentEngineScheduling.cs | 150 ++++++++ .../Recruitment/RecruitmentForumTransport.cs | 46 ++- .../Services/Recruitment/RecruitmentHealth.cs | 33 ++ .../Recruitment/RecruitmentOverview.cs | 103 ++++++ .../Recruitment/RecruitmentRequests.cs | 67 ++++ .../Recruitment/RecruitmentService.cs | 346 +++++++++++++----- .../Services/Recruitment/RecruitmentState.cs | 24 ++ .../Recruitment/RecruitmentStateStore.cs | 8 +- 20 files changed, 1417 insertions(+), 197 deletions(-) create mode 100644 DiscordBot.Tests/Recruitment/RecruitmentOverviewTests.cs create mode 100644 DiscordBot.Tests/Recruitment/RecruitmentSchedulingTests.cs create mode 100644 DiscordBot/Services/Recruitment/RecruitmentEngineScheduling.cs create mode 100644 DiscordBot/Services/Recruitment/RecruitmentHealth.cs create mode 100644 DiscordBot/Services/Recruitment/RecruitmentOverview.cs create mode 100644 DiscordBot/Services/Recruitment/RecruitmentRequests.cs diff --git a/DiscordBot.Tests/Recruitment/RecruitmentOverviewTests.cs b/DiscordBot.Tests/Recruitment/RecruitmentOverviewTests.cs new file mode 100644 index 00000000..bcc413ec --- /dev/null +++ b/DiscordBot.Tests/Recruitment/RecruitmentOverviewTests.cs @@ -0,0 +1,104 @@ +using DiscordBot.Services.Recruitment; + +namespace DiscordBot.Tests.Recruitment; + +public class RecruitmentOverviewTests +{ + [Fact] + public async Task FeedActivityReplacesOverviewOnlyAfterCooldownAndRetainsListingAudit() + { + using var rig = new RecruitmentRig(); await rig.Start(); var listing = await rig.Post(); + await rig.Engine.RefreshOverviewAsync(default); + var first = rig.State.Overview.MessageId; + Assert.NotNull(first); + rig.Engine.FeedActivity("999999"); + rig.Clock.Advance(TimeSpan.FromMinutes(4)); await rig.Engine.RefreshOverviewAsync(default); + Assert.Equal(first, rig.State.Overview.MessageId); + rig.Clock.Advance(TimeSpan.FromMinutes(1)); await rig.Engine.RefreshOverviewAsync(default); + Assert.NotEqual(first, rig.State.Overview.MessageId); + Assert.Contains(first, rig.Discord.DeletedMessages); + Assert.DoesNotContain(listing.StaffMessageId, rig.Discord.DeletedMessages); + Assert.Single(rig.Discord.Messages.Values, m => m.Message.Marker.StartsWith("recruitment:overview:")); + var upserts = rig.Discord.Upserts; + rig.Engine.FeedActivity(rig.State.Overview.MessageId!, overview: true); + rig.Clock.Advance(TimeSpan.FromMinutes(1)); await rig.Engine.RefreshOverviewAsync(default); + Assert.Equal(upserts, rig.Discord.Upserts); + } + + [Fact] + public async Task ContinuousActivityCannotStarveReplacementAfterCooldown() + { + using var rig = new RecruitmentRig(); await rig.Start(); await rig.Engine.RefreshOverviewAsync(default); + var first = rig.State.Overview.MessageId; + for (var i = 0; i < 73; i++) + { + rig.Engine.FeedActivity((900000 + i).ToString()); + rig.Clock.Advance(TimeSpan.FromSeconds(5)); await rig.Engine.RefreshOverviewAsync(default); + if (i < 59) Assert.Equal(first, rig.State.Overview.MessageId); + } + Assert.NotEqual(first, rig.State.Overview.MessageId); + Assert.Single(rig.Discord.DeletedMessages); + } + + [Fact] + public async Task LostReplacementResponseRecoversSameMessageAfterRestartBeforeRetiringOldOne() + { + using var rig = new RecruitmentRig(); await rig.Start(); await rig.Engine.RefreshOverviewAsync(default); + var first = rig.State.Overview.MessageId; + rig.Engine.FeedActivity("999999"); rig.Clock.Advance(TimeSpan.FromMinutes(6)); + rig.Discord.LoseNextSendResponse = true; + await rig.Engine.RefreshOverviewAsync(default); + Assert.True(rig.State.Overview.SendPending); Assert.Equal(first, rig.State.Overview.MessageId); + Assert.DoesNotContain(first, rig.Discord.DeletedMessages); + await rig.Restart(); + await rig.Staff("reconcile"); + var sends = rig.Discord.Sends; + await rig.Engine.RefreshOverviewAsync(default); + Assert.Equal(sends, rig.Discord.Sends); + Assert.NotEqual(first, rig.State.Overview.MessageId); + Assert.Contains(first, rig.Discord.DeletedMessages); + Assert.False(rig.State.Overview.SendPending); + } + + [Fact] + public async Task CleanupAndCosmeticFailureDoNotFreezeListingAcceptance() + { + using var rig = new RecruitmentRig(); await rig.Start(); await rig.Engine.RefreshOverviewAsync(default); + rig.Discord.FailCleanup = true; rig.Engine.FeedActivity("999999"); rig.Clock.Advance(TimeSpan.FromMinutes(6)); + await rig.Engine.RefreshOverviewAsync(default); + Assert.NotNull(rig.State.Overview.RetiredMessageId); + var current = rig.State.Overview.MessageId; + await rig.Post(); Assert.Contains("accepted", await rig.Ack()); + rig.Discord.FailCleanup = false; + rig.Clock.Now = rig.State.Overview.Fault!.RetryAt!.Value; + await rig.Engine.RefreshOverviewAsync(default); + Assert.Null(rig.State.Overview.RetiredMessageId); + Assert.Equal(current, rig.State.Overview.MessageId); + } + + [Fact] + public async Task MissingEmbedPermissionFallsBackToTextAndUnsafeFeedPublishesNothing() + { + using var rig = new RecruitmentRig(); rig.Discord.FeedEmbeds = false; + await rig.Start(); await rig.Engine.RefreshOverviewAsync(default); + var message = Assert.Single(rig.Discord.Messages.Values).Message; + Assert.Null(message.Embed); Assert.Contains("Recruitment status", message.Content); + rig.Discord.FailFeed = true; rig.Engine.InvalidateAccess(); await rig.Sweep(); + var sends = rig.Discord.Sends; + rig.Engine.FeedActivity("999999"); rig.Clock.Advance(TimeSpan.FromMinutes(6)); + await rig.Engine.RefreshOverviewAsync(default); + Assert.Equal(sends, rig.Discord.Sends); + Assert.Contains("privacy invalid", rig.Engine.Health().Status); + } + + [Fact] + public async Task OverviewCanShowForumSetupHoldWithoutRevealingChallengeAnswers() + { + using var rig = new RecruitmentRig(); rig.Discord.FailForum = "11"; await rig.Start(); + await rig.Engine.RefreshOverviewAsync(default); + var message = Assert.Single(rig.Discord.Messages.Values).Message; + Assert.Contains("held", message.Embed!.Description); + Assert.DoesNotContain(rig.State.RotationCode, message.Embed.Description); + Assert.Contains("<#11>", message.Embed.Description); + } +} diff --git a/DiscordBot.Tests/Recruitment/RecruitmentRecoveryTests.cs b/DiscordBot.Tests/Recruitment/RecruitmentRecoveryTests.cs index 353076fc..52a41263 100644 --- a/DiscordBot.Tests/Recruitment/RecruitmentRecoveryTests.cs +++ b/DiscordBot.Tests/Recruitment/RecruitmentRecoveryTests.cs @@ -17,7 +17,7 @@ public async Task InteractionModuleRegistersWithoutConnectingOrStartingTheFeatur var service = new RecruitmentService(client, new BotSettings(), null!); using var provider = new ServiceCollection().AddSingleton(service).BuildServiceProvider(); var module = await interactions.AddModuleAsync(provider); - Assert.Equal(7, module.SlashCommands.Count); + Assert.Equal(8, module.SlashCommands.Count); Assert.Equal(4, module.ComponentCommands.Count); Assert.Single(module.ModalCommands); } diff --git a/DiscordBot.Tests/Recruitment/RecruitmentSchedulingTests.cs b/DiscordBot.Tests/Recruitment/RecruitmentSchedulingTests.cs new file mode 100644 index 00000000..7f4a7202 --- /dev/null +++ b/DiscordBot.Tests/Recruitment/RecruitmentSchedulingTests.cs @@ -0,0 +1,203 @@ +using DiscordBot.Services.Recruitment; + +namespace DiscordBot.Tests.Recruitment; + +public class RecruitmentSchedulingTests +{ + [Fact] + public async Task AcknowledgementAndUnchangedThreadEventDoNotScanForumsOrRewriteMessages() + { + using var rig = new RecruitmentRig(); await rig.Start(); var post = await rig.Post(); + var forums = rig.Discord.ForumReads; var inventories = rig.Discord.Inventories; + Assert.Contains("accepted", await rig.Ack()); + Assert.Equal(forums, rig.Discord.ForumReads); Assert.Equal(inventories, rig.Discord.Inventories); + var publications = rig.Discord.Upserts; var history = rig.Discord.ReplyReads; + await rig.Engine.ThreadChangedAsync(post.Id, default); + Assert.Equal(publications, rig.Discord.Upserts); Assert.Equal(history, rig.Discord.ReplyReads); + } + + [Fact] + public async Task PositiveReplyEvidenceDoesNotNeedFurtherHistoryScans() + { + using var rig = new RecruitmentRig(); await rig.Start(); var post = await rig.Post(); await rig.Ack(); + rig.Engine.ObserveReply(new ReplyObservation(post.Id, "201", false, false, false)); + var reads = rig.Discord.ReplyReads; + await rig.Sweep(); await rig.Engine.ThreadChangedAsync(post.Id, default); + Assert.Equal(reads, rig.Discord.ReplyReads); + } + + [Fact] + public async Task DeadlineRunsWithoutInventoryOrSetupAndCooldownWakeupAcceptsWaitingListing() + { + using var rig = new RecruitmentRig(); await rig.Start(); var first = await rig.Post(); await rig.Ack(); await rig.Staff("close"); + rig.Clock.Now = first.CreatedAt.AddDays(rig.Settings.Recruitment.CooldownDays).AddMinutes(-2); + var waiting = await rig.Post("101"); await rig.Ack("101"); + Assert.Null(waiting.AcceptedAt); + var forums = rig.Discord.ForumReads; var inventories = rig.Discord.Inventories; + rig.Clock.Advance(TimeSpan.FromMinutes(2)); await rig.Engine.RunDueAsync(default); + Assert.NotNull(waiting.AcceptedAt); + Assert.Equal(forums, rig.Discord.ForumReads); Assert.Equal(inventories, rig.Discord.Inventories); + } + + [Fact] + public async Task ConfirmedBlockerDeletionWakesOtherAttemptBySameAuthor() + { + using var rig = new RecruitmentRig(); await rig.Start(); await rig.Post(); await rig.Ack(); + await rig.Staff("waive-cooldown", "200"); var waiting = await rig.Post("101"); await rig.Ack("101"); + Assert.Null(waiting.AcceptedAt); + rig.Discord.Threads.Remove("100"); + await rig.Engine.ThreadChangedAsync("100", default); + Assert.NotNull(waiting.AcceptedAt); + } + + [Fact] + public async Task DeletedControlsReceiveFullUsableWindowWithoutForumScan() + { + using var rig = new RecruitmentRig(); await rig.Start(); var post = await rig.Post(); + rig.Clock.Advance(TimeSpan.FromMinutes(29)); var old = post.PublicMessageId!; + await rig.Discord.DeleteAdvisoryAsync(post.Id, old, default); + var reads = rig.Discord.ForumReads; + await rig.Engine.MessageDeletedAsync(post.Id, new HashSet { old }, default); + Assert.NotEqual(old, post.PublicMessageId); + Assert.Equal(rig.Clock.Now.AddMinutes(30), post.Deadline); + Assert.Equal(reads, rig.Discord.ForumReads); + } + + [Fact] + public async Task MissingStarterHasBoundedRetryAndStartsWindowOnlyAfterDelivery() + { + using var rig = new RecruitmentRig(); await rig.Start(); + rig.Discord.Threads["100"] = Snapshot(rig.Clock.Now) with { Starter = null }; + await rig.Engine.ThreadChangedAsync("100", default); + Assert.Null(rig.State.Listings["100"].Deadline); + var reads = rig.Discord.ThreadReads; + rig.Clock.Advance(TimeSpan.FromSeconds(10)); await rig.Engine.RunDueAsync(default); + Assert.Equal(reads, rig.Discord.ThreadReads); + rig.Discord.Threads["100"] = rig.Discord.Threads["100"] with { Starter = "Available" }; + rig.Clock.Advance(TimeSpan.FromSeconds(5)); await rig.Engine.RunDueAsync(default); + Assert.Equal(rig.Clock.Now.AddMinutes(30), rig.State.Listings["100"].Deadline); + } + + [Fact] + public async Task ServiceIsQuietForTenIdleMinutesAndCoalescesRepeatedThreadEvents() + { + using var rig = new RecruitmentRig(); + var service = new RecruitmentService(rig.Settings, rig.Discord, rig.Clock); + try + { + await service.StartAsync(); + var baseline = (rig.Discord.ForumReads, rig.Discord.Inventories, rig.Discord.Upserts, rig.Discord.ThreadReads); + for (var i = 0; i < 10; i++) + { + rig.Clock.Advance(TimeSpan.FromMinutes(1)); + await service.RunAsync((_, _) => Task.FromResult(true)); + } + Assert.Equal(baseline, (rig.Discord.ForumReads, rig.Discord.Inventories, rig.Discord.Upserts, rig.Discord.ThreadReads)); + var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var blocked = service.RunAsync(async (_, _) => { entered.SetResult(); await release.Task; return true; }); + await entered.Task; + rig.Discord.Threads["100"] = Snapshot(rig.Clock.Now); + for (var i = 0; i < 100; i++) service.NotifyThread("100"); + release.SetResult(); await blocked; + await service.RunAsync((_, _) => Task.FromResult(true)); + Assert.Equal(1, service.Health.Open); + Assert.Equal(baseline.Inventories, rig.Discord.Inventories); + Assert.InRange(rig.Discord.ThreadReads - baseline.ThreadReads, 1, 2); + } + finally { await service.StopAsync(default); } + } + + [Fact] + public async Task QueuedUnknownIdentityPreventsAcceptingANewerAttemptByTheSameAuthor() + { + using var rig = new RecruitmentRig(); + var service = new RecruitmentService(rig.Settings, rig.Discord, rig.Clock); + try + { + await service.StartAsync(); + rig.Discord.Threads["101"] = Snapshot(rig.Clock.Now.AddSeconds(1)) with { Id = "101" }; + service.NotifyThread("101"); await service.RunAsync((_, _) => Task.FromResult(true)); + var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var acknowledgement = service.RunAsync(async (engine, ct) => + { + entered.SetResult(); await release.Task; + return await engine.AcknowledgeAsync("1", "101", "200", engine.State!.Listings["101"].ValidCodes.Last(), ct); + }, "101"); + await entered.Task; + rig.Discord.Threads["100"] = Snapshot(rig.Clock.Now); + service.NotifyThread("100"); release.SetResult(); + Assert.DoesNotContain("is accepted", await acknowledgement); + var accepted = await service.RunAsync((engine, _) => Task.FromResult(engine.State!.Listings["101"].AcceptedAt)); + Assert.Null(accepted); + Assert.Equal(2, service.Health.Open); + Assert.Equal(1, rig.Discord.Inventories); + } + finally { await service.StopAsync(default); } + } + + [Fact] + public async Task StatusRemainsReadableDuringStartupAndShutdownCancelsPendingWork() + { + using var rig = new RecruitmentRig(); + var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + rig.Discord.BeforeAccess = () => { entered.TrySetResult(); return release.Task; }; + var service = new RecruitmentService(rig.Settings, rig.Discord, rig.Clock); + var startup = service.StartAsync(); await entered.Task.WaitAsync(TimeSpan.FromSeconds(2)); + Assert.False(startup.IsCompleted); + Assert.NotNull(service.Health); + Assert.False(service.Health.InventoryComplete); + Assert.False(service.CanReadStatus("other-guild", "900")); + var queued = service.RunAsync((_, _) => Task.FromResult(true)); + await service.StopAsync(default).WaitAsync(TimeSpan.FromSeconds(2)); + await Assert.ThrowsAnyAsync(() => startup); + await Assert.ThrowsAnyAsync(() => queued); + Assert.Equal("Stopped", service.Health.Status); + } + + [Fact] + public async Task QueueOverflowImmediatelyInvalidatesEvidenceBeforeAutomaticDelete() + { + using var rig = new RecruitmentRig(); + var service = new RecruitmentService(rig.Settings, rig.Discord, rig.Clock); + try + { + await service.StartAsync(); rig.Discord.Threads["100"] = Snapshot(rig.Clock.Now); service.NotifyThread("100"); + await service.RunAsync((_, _) => Task.FromResult(true)); + rig.Discord.BeforeDelete = () => { for (var i = 0; i < 260; i++) service.NotifyThread((2000 + i).ToString()); }; + rig.Clock.Advance(TimeSpan.FromMinutes(31)); + await service.RunAsync(async (engine, ct) => { await engine.RunDueAsync(ct); return true; }); + Assert.Empty(rig.Discord.Actions); + } + finally { await service.StopAsync(default); } + } + + [Fact] + public async Task QueuedControlDeletionFencesTimeoutUntilReplacementIsDelivered() + { + using var rig = new RecruitmentRig(); + var service = new RecruitmentService(rig.Settings, rig.Discord, rig.Clock); + try + { + await service.StartAsync(); rig.Discord.Threads["100"] = Snapshot(rig.Clock.Now); service.NotifyThread("100"); + var control = await service.RunAsync((engine, _) => Task.FromResult(engine.State!.Listings["100"].PublicMessageId!)); + rig.Discord.BeforeDelete = () => + { + rig.Discord.Messages.Remove("100:recruitment:100:public"); + service.NotifyMessageDeletion("100", new HashSet { control }); + }; + rig.Clock.Advance(TimeSpan.FromMinutes(31)); + await service.RunAsync((_, _) => Task.FromResult(true)); + await service.RunAsync(async (engine, ct) => { await engine.RunDueAsync(ct); return true; }); + Assert.Empty(rig.Discord.Actions); + var deadline = await service.RunAsync((engine, _) => Task.FromResult(engine.State!.Listings["100"].Deadline)); + Assert.Equal(rig.Clock.Now.AddMinutes(30), deadline); + } + finally { await service.StopAsync(default); } + } + + private static ThreadSnapshot Snapshot(DateTimeOffset now) => new("100", "11", "200", "Example", now, false, false, false, false, + Array.Empty(), "A listing", now.AddYears(-1), now.AddMonths(-1)); +} diff --git a/DiscordBot.Tests/Recruitment/RecruitmentStorageAndSetupTests.cs b/DiscordBot.Tests/Recruitment/RecruitmentStorageAndSetupTests.cs index b8f32c8f..3ec3a3db 100644 --- a/DiscordBot.Tests/Recruitment/RecruitmentStorageAndSetupTests.cs +++ b/DiscordBot.Tests/Recruitment/RecruitmentStorageAndSetupTests.cs @@ -69,6 +69,26 @@ public async Task LostOrMalformedHistoryStopsFeatureWithoutSilentlyResetting(str Assert.Equal(damaged, File.Exists(path) ? File.ReadAllText(path) : null); } + [Fact] + public async Task ExistingV2StateLoadsWithoutNewOptionalOperationalReceipts() + { + using var rig = new RecruitmentRig(); await rig.Start(); await rig.Post(); await rig.Ack(); + var accepted = rig.State.Authors["200"].LastAcceptedCreation; + rig.Engine.Dispose(); + var path = Path.Combine(rig.Root, "recruitment/recruitment-state.json"); + using var reader = new Newtonsoft.Json.JsonTextReader(new StringReader(File.ReadAllText(path))) { DateParseHandling = Newtonsoft.Json.DateParseHandling.None }; + var json = JObject.Load(reader); + json.Remove("Overview"); json.Remove("RecoveryFault"); + foreach (var forum in json["Forums"]!.Children()) ((JObject)forum.Value).Remove("Fault"); + foreach (var listing in json["Listings"]!.Children()) ((JObject)listing.Value).Remove("Fault"); + File.WriteAllText(path, json.ToString()); + await rig.Restart(); + Assert.True(rig.Engine.State is not null, rig.Engine.Status); + Assert.Equal(accepted, rig.State.Authors["200"].LastAcceptedCreation); + Assert.NotNull(rig.State.Overview); + Assert.NotNull(rig.State.Listings["100"].AcceptedAt); + } + [Fact] public async Task ChangedForumIdsCannotAdoptExistingHistory() { diff --git a/DiscordBot.Tests/Recruitment/RecruitmentTestRig.cs b/DiscordBot.Tests/Recruitment/RecruitmentTestRig.cs index f948c259..8864e57f 100644 --- a/DiscordBot.Tests/Recruitment/RecruitmentTestRig.cs +++ b/DiscordBot.Tests/Recruitment/RecruitmentTestRig.cs @@ -6,8 +6,49 @@ namespace DiscordBot.Tests.Recruitment; internal sealed class ManualClock : TimeProvider { public DateTimeOffset Now { get; set; } = new(2026, 9, 29, 10, 0, 0, TimeSpan.Zero); + private readonly List _timers = new(); public override DateTimeOffset GetUtcNow() => Now; - public void Advance(TimeSpan duration) => Now += duration; + public override ITimer CreateTimer(TimerCallback callback, object? state, TimeSpan dueTime, TimeSpan period) + { + var timer = new ManualTimer(this, callback, state); + lock (_timers) _timers.Add(timer); + timer.Change(dueTime, period); + return timer; + } + public void Advance(TimeSpan duration) + { + Now += duration; + ManualTimer[] timers; + lock (_timers) timers = _timers.ToArray(); + foreach (var timer in timers) timer.Fire(); + } + private sealed class ManualTimer(ManualClock clock, TimerCallback callback, object? state) : ITimer + { + private DateTimeOffset? _due; + private TimeSpan _period; + private bool _disposed; + public bool Change(TimeSpan dueTime, TimeSpan period) + { + lock (this) + { + if (_disposed) return false; + _due = dueTime == Timeout.InfiniteTimeSpan ? null : clock.Now + dueTime; + _period = period; + return true; + } + } + public void Fire() + { + lock (this) + { + if (_disposed || !_due.HasValue || _due > clock.Now) return; + _due = _period == Timeout.InfiniteTimeSpan ? null : clock.Now + _period; + } + callback(state); + } + public void Dispose() { lock (this) { _disposed = true; _due = null; } } + public ValueTask DisposeAsync() { Dispose(); return ValueTask.CompletedTask; } + } } internal sealed class RecruitmentRig : IDisposable @@ -44,7 +85,7 @@ public async Task Post(string id = "100", string owner = "200", string public Task Ack(string id = "100", string owner = "200", string? code = null) => Engine.AcknowledgeAsync("1", id, owner, code ?? State.Listings[id].ValidCodes.Last(), default); public Task Staff(string operation, string target = "100", string reason = "Test recovery", string actor = "900") => - Engine.StaffAsync("1", actor, operation, target, reason, default); + Engine.StaffAsync("1", actor, operation, operation == "repair" && target == "100" ? "" : target, reason, default); public void Dispose() { Engine.Dispose(); if (Directory.Exists(Root)) Directory.Delete(Root, true); } } @@ -56,6 +97,18 @@ internal sealed class FakeRecruitmentDiscord : IRecruitmentDiscord public List<(string Thread, string Kind)> Actions { get; } = new(); public HashSet UnknownThreads { get; } = new(); public HashSet Replies { get; } = new(); + public int AccessReads { get; private set; } + public int ForumReads { get; private set; } + public int Inventories { get; private set; } + public int ReplyReads { get; private set; } + public int Upserts { get; private set; } + public int ThreadReads { get; private set; } + public Func? BeforeAccess { get; set; } + public bool FeedEmbeds { get; set; } = true; + public bool FailFeed { get; set; } + public bool FailOverview { get; set; } + public bool FailCleanup { get; set; } + public List DeletedMessages { get; } = new(); public bool InventoryComplete { get; set; } = true; public bool FailStaff { get; set; } public bool LoseNextSendResponse { get; set; } @@ -70,9 +123,17 @@ internal sealed class FakeRecruitmentDiscord : IRecruitmentDiscord public int Sends { get; private set; } public int ForumWrites { get; private set; } private int _nextId = 1000; - public Task ValidateAccessAsync(CancellationToken ct) => FailPermissions ? Task.FromException(new InvalidOperationException("Missing permissions")) : Task.CompletedTask; + public async Task ValidateAccessAsync(CancellationToken ct) + { + AccessReads++; + if (BeforeAccess is not null) await BeforeAccess().WaitAsync(ct); + if (FailPermissions) throw new InvalidOperationException("Missing permissions"); + } + public Task ValidateFeedAsync(CancellationToken ct) => FailFeed ? Task.FromException(new InvalidOperationException("Feed privacy invalid")) : Task.FromResult(FeedEmbeds); + public Task LatestFeedMessageAsync(CancellationToken ct) => Task.FromResult(Messages.Where(m => m.Key.StartsWith("99:")).Select(m => m.Value.Id).OrderByDescending(ulong.Parse).FirstOrDefault()); public Task IsModeratorAsync(string guildId, string userId, CancellationToken ct) => Task.FromResult(guildId == "1" && userId == "900"); - public Task GetForumAsync(string id, CancellationToken ct) => Task.FromResult(Forums[id]); + public bool IsExpectedForumUpdate(ForumSnapshot snapshot) => Forums.GetValueOrDefault(snapshot.Id) == snapshot; + public Task GetForumAsync(string id, CancellationToken ct) { ForumReads++; return Task.FromResult(Forums[id]); } public Task PublishForumAsync(ForumSnapshot expected, string? topic, IReadOnlyList? tags, bool requireTag, CancellationToken ct) { var id = expected.Id; @@ -85,18 +146,22 @@ public Task PublishForumAsync(ForumSnapshot expected, string? top if (LoseNextForumResponse) { LoseNextForumResponse = false; throw new TimeoutException("Forum response lost"); } return Task.FromResult(Forums[id]); } - public Task InventoryAsync(DateTimeOffset floor, IReadOnlySet knownIds, IReadOnlyDictionary cursors, CancellationToken ct) => Task.FromResult(new ThreadInventory(Threads.Values.Where(t => t.CreatedAt >= floor).ToArray(), InventoryComplete)); - public Task GetThreadAsync(string id, CancellationToken ct) => UnknownThreads.Contains(id) - ? Task.FromException(new InvalidOperationException("Forbidden")) : Task.FromResult(Threads.GetValueOrDefault(id)); - public Task HasReplyAsync(ThreadSnapshot thread, CancellationToken ct) => Task.FromResult(Replies.Contains(thread.Id)); + public Task InventoryAsync(DateTimeOffset floor, IReadOnlySet knownIds, IReadOnlyDictionary cursors, CancellationToken ct) { Inventories++; return Task.FromResult(new ThreadInventory(Threads.Values.Where(t => t.CreatedAt >= floor).ToArray(), InventoryComplete)); } + public Task GetThreadAsync(string id, CancellationToken ct, bool refresh = false) + { ThreadReads++; return UnknownThreads.Contains(id) ? Task.FromException(new InvalidOperationException("Forbidden")) : Task.FromResult(Threads.GetValueOrDefault(id)); } + public void InvalidateThread(string id) { } + public void InvalidateCache() { } + public Task HasReplyAsync(ThreadSnapshot thread, CancellationToken ct) { ReplyReads++; return Task.FromResult(Replies.Contains(thread.Id)); } public Task GetActivityAsync(string userId, CancellationToken ct) => Task.FromResult("unknown"); public Task UpsertAsync(string channelId, string? messageId, bool recovering, DateTimeOffset since, RecruitmentMessage message, Action beforeWrite, CancellationToken ct) { + Upserts++; + if (FailOverview && message.Marker.StartsWith("recruitment:overview:")) throw new TimeoutException("Overview unavailable"); if (FailStaff && channelId == "99") throw new InvalidOperationException("Feed unavailable"); var key = channelId + ":" + message.Marker; if (Messages.TryGetValue(key, out var existing)) { - if (existing.Message.Content != message.Content) beforeWrite(); + if (existing.Message.Content != message.Content || existing.Message.Embed?.Description != message.Embed?.Description) beforeWrite(); Messages[key] = (existing.Id, message); return Task.FromResult(new Delivery(existing.Id, false)); } beforeWrite(); @@ -104,21 +169,27 @@ public Task UpsertAsync(string channelId, string? messageId, bool reco if (LoseNextSendResponse) { LoseNextSendResponse = false; throw new InvalidOperationException("Ambiguous send"); } return Task.FromResult(new Delivery(id, true)); } - public Task CloseAsync(ThreadSnapshot thread, string? closedTagId, bool protectPinned, CancellationToken ct) + public Task CloseAsync(ThreadSnapshot thread, string? closedTagId, bool protectPinned, CancellationToken ct, Func? allowWrite = null) { + if (allowWrite?.Invoke() == false) throw new InvalidOperationException("Evidence changed"); Threads[thread.Id] = thread with { Archived = true, Locked = true }; Actions.Add((thread.Id, "close")); if (LoseNextCloseResponse) { LoseNextCloseResponse = false; throw new InvalidOperationException("Ambiguous close"); } return Task.CompletedTask; } public Task ReopenAsync(ThreadSnapshot thread, string? closedTagId, CancellationToken ct) { Threads[thread.Id] = thread with { Archived = false, Locked = false }; Actions.Add((thread.Id, "reopen")); return Task.CompletedTask; } - public Task DeleteAsync(ThreadSnapshot thread, string reason, bool protectPinned, CancellationToken ct) + public Task DeleteAsync(ThreadSnapshot thread, string reason, bool protectPinned, CancellationToken ct, Func? allowWrite = null) { var id = thread.Id; - BeforeDelete?.Invoke(); Threads.Remove(id); Actions.Add((id, "delete")); + BeforeDelete?.Invoke(); + if (allowWrite?.Invoke() == false) throw new InvalidOperationException("Evidence changed"); + Threads.Remove(id); Actions.Add((id, "delete")); if (LoseNextDeleteResponse) { LoseNextDeleteResponse = false; throw new InvalidOperationException("Ambiguous delete"); } return Task.CompletedTask; } - public Task DeleteAdvisoryAsync(string id, string messageId, CancellationToken ct) - { foreach (var key in Messages.Where(x => x.Value.Id == messageId).Select(x => x.Key).ToArray()) Messages.Remove(key); return Task.CompletedTask; } + public Task DeleteAdvisoryAsync(string id, string messageId, CancellationToken ct, string? marker = null) + { + if (FailCleanup) throw new TimeoutException("Cleanup delayed"); + DeletedMessages.Add(messageId); + foreach (var key in Messages.Where(x => x.Value.Id == messageId && (marker is null || x.Value.Message.Content.Contains(marker))).Select(x => x.Key).ToArray()) Messages.Remove(key); return Task.CompletedTask; } } diff --git a/DiscordBot.Tests/Recruitment/RecruitmentTransportTests.cs b/DiscordBot.Tests/Recruitment/RecruitmentTransportTests.cs index a474efae..b97e163f 100644 --- a/DiscordBot.Tests/Recruitment/RecruitmentTransportTests.cs +++ b/DiscordBot.Tests/Recruitment/RecruitmentTransportTests.cs @@ -3,6 +3,8 @@ using Discord; using Discord.Net.Rest; using Discord.Rest; +using Discord.WebSocket; +using DiscordBot.Settings; using DiscordBot.Services.Recruitment; using Newtonsoft.Json.Linq; @@ -47,9 +49,100 @@ await Assert.ThrowsAsync(() => transport.SendAsync("P Assert.Empty(inner.Writes); } + [Fact] + public async Task InventoryReusesForumsAndFetchesGuildActiveThreadsOnce() + { + var transport = new ForumTransport { InventoryMode = true }; + using var client = new DiscordSocketClient(new DiscordSocketConfig + { RestClientProvider = RecruitmentForumTransport.Wrap(_ => transport) }); + await client.LoginAsync(TokenType.Bot, "offline-placeholder-token", validateToken: false); + var settings = new BotSettings { GuildId = 1, Recruitment = new RecruitmentSettings + { Forums = new RecruitmentForums { PaidRecruitingId = 11, PaidForHireId = 12, HobbyRecruitingId = 13, HobbyForHireId = 14 } } }; + var adapter = new DiscordRecruitmentAdapter(client, settings, null!); + ForumSnapshot? observed = null; + foreach (var id in settings.Recruitment.Forums.Mapping().Values) + { + var snapshot = await adapter.GetForumAsync(id.ToString(), default); + if (id == 11) observed = snapshot; + } + var floor = DateTimeOffset.UtcNow.AddDays(-30); + var inventory = await adapter.InventoryAsync(floor, new HashSet(), + settings.Recruitment.Forums.Mapping().Values.ToDictionary(id => id.ToString(), _ => new ArchiveCursor(floor)), default); + Assert.True(inventory.Complete); + Assert.Single(transport.Requests, r => r.Contains("/threads/active")); + Assert.Equal(4, transport.Requests.Count(r => r is "GET channels/11" or "GET channels/12" or "GET channels/13" or "GET channels/14")); + var updated = await adapter.PublishForumAsync(observed!, "Updated Guidelines", null, false, default); + Assert.True(adapter.IsExpectedForumUpdate(updated)); + Assert.False(adapter.IsExpectedForumUpdate(updated with { Topic = "External edit" })); + } + + [Fact] + public async Task InventoryObservationRetainsIdentitiesDiscardedBySdkParentFilter() + { + var transport = new ForumTransport { InventoryMode = true, IncludeOtherForumThread = true }; + using var client = new DiscordRestClient(new DiscordRestConfig { RestClientProvider = RecruitmentForumTransport.Wrap(_ => transport) }); + await client.LoginAsync(TokenType.Bot, "offline-placeholder-token", validateToken: false); + var forum = Assert.IsType(await client.GetChannelAsync(11)); + using var observed = RecruitmentForumTransport.ObserveActiveInventory(1); + Assert.Empty(await forum.GetActiveThreadsAsync()); + Assert.Equal((100UL, 12UL), Assert.Single(observed.Identities!)); + Assert.Single(transport.Requests, r => r.Contains("/threads/active")); + } + + [Theory] + [InlineData(235)] + [InlineData(537)] + public async Task RateLimitReturnsDeadlineWithoutBlockingWorkerOrResending(int seconds) + { + var transport = new ForumTransport { RetrySeconds = seconds }; + using var client = new DiscordRestClient(new DiscordRestConfig + { RestClientProvider = RecruitmentForumTransport.Wrap(_ => transport) }); + await client.LoginAsync(TokenType.Bot, "offline-placeholder-token", validateToken: false); + var started = DateTimeOffset.UtcNow; + var error = await Assert.ThrowsAsync(async () => + await client.GetChannelAsync((ulong)seconds, RecruitmentRequests.Options(default)).WaitAsync(TimeSpan.FromSeconds(2))); + Assert.True(error.RetryAt >= started.AddSeconds(seconds)); + await Assert.ThrowsAsync(async () => + await client.GetChannelAsync((ulong)seconds, RecruitmentRequests.Options(default)).WaitAsync(TimeSpan.FromSeconds(2))); + using var sameChannel = new RecruitmentForumTransport(transport); + _ = RecruitmentRequests.Options(default); // Mark this direct transport call as recruitment work too. + await Assert.ThrowsAsync(() => sameChannel.SendAsync("GET", $"channels/{seconds}/messages?limit=100", + default, false, "", Array.Empty>>()).WaitAsync(TimeSpan.FromSeconds(2))); + Assert.Equal(1, transport.RateLimitRequests); + } + + [Fact] + public async Task GlobalRateLimitDefersOtherRecruitmentRoutesBeforeSdkWaiting() + { + var transport = new ForumTransport { RetrySeconds = 1, GlobalLimit = true }; + using var client = new DiscordRestClient(new DiscordRestConfig { RestClientProvider = RecruitmentForumTransport.Wrap(_ => transport) }); + await client.LoginAsync(TokenType.Bot, "offline-placeholder-token", validateToken: false); + var error = await Assert.ThrowsAsync(async () => + await client.GetChannelAsync(1, RecruitmentRequests.Options(default)).WaitAsync(TimeSpan.FromSeconds(2))); + try + { + var other = Assert.Throws(() => RecruitmentRequests.Options(default)); + Assert.Equal(error.RetryAt, other.RetryAt); + Assert.Equal(1, transport.RateLimitRequests); + } + finally + { + // This process-wide limiter is shared by all SDK instances, as it is by production callers. + // Let this short synthetic global window expire before another test uses the transport. + var remaining = error.RetryAt - DateTimeOffset.UtcNow + TimeSpan.FromMilliseconds(20); + if (remaining > TimeSpan.Zero) await Task.Delay(remaining); + } + } + // Uses the real installed SDK's request queue and serialization, but never a network transport. private sealed class ForumTransport : IRestClient { + public bool InventoryMode { get; init; } + public bool IncludeOtherForumThread { get; init; } + public List Requests { get; } = new(); + public int RetrySeconds { get; init; } + public bool GlobalLimit { get; init; } + public int RateLimitRequests { get; private set; } public List Writes { get; } = new(); private readonly JObject _forum = JObject.Parse(""" {"id":"11","guild_id":"1","type":15,"name":"recruitment","topic":"","flags":32768, @@ -67,11 +160,21 @@ public Task SendAsync(string method, string endpoint, IReadOnlyDic throw new InvalidOperationException("Unexpected multipart request"); private async Task Respond(string method, string endpoint, string? json) { + Requests.Add(method + " " + endpoint); await Task.Yield(); // Verify the SDK preserves the scoped correction across asynchronous work. string body; + if (RetrySeconds > 0 && endpoint == $"channels/{RetrySeconds}") + { + RateLimitRequests++; + return new RestResponse(HttpStatusCode.TooManyRequests, new Dictionary(), + new MemoryStream(Encoding.UTF8.GetBytes($"{{\"retry_after\":{RetrySeconds},\"global\":{GlobalLimit.ToString().ToLowerInvariant()}}}"))); + } if (method == "GET" && endpoint == "users/@me") body = """{"id":"999","username":"offline","discriminator":"0","bot":true}"""; else if (method == "GET" && endpoint == "oauth2/applications/@me") body = """{"id":"999","name":"offline","description":"","bot_public":true,"bot_require_code_grant":false,"verify_key":"00"}"""; - else if (endpoint == "channels/11" && method is "GET" or "PATCH") + else if (IncludeOtherForumThread && endpoint.EndsWith("/threads/active")) body = """{"threads":[{"id":"100","parent_id":"12","guild_id":"1","type":11}],"members":[]}"""; + else if (InventoryMode && endpoint.Contains("/threads/")) body = """{"threads":[],"members":[],"has_more":false}"""; + else if (InventoryMode && endpoint.StartsWith("guilds/1")) body = """{"id":"1","name":"Offline guild","owner_id":"999","roles":[],"emojis":[],"features":[],"stickers":[],"afk_timeout":300,"preferred_locale":"en-US"}"""; + else if ((endpoint == "channels/11" || InventoryMode && endpoint is "channels/12" or "channels/13" or "channels/14") && method is "GET" or "PATCH") { if (method == "PATCH") { @@ -81,7 +184,8 @@ private async Task Respond(string method, string endpoint, string? foreach (var tag in (JArray)_forum["available_tags"]!) if (tag["id"] is null) tag["id"] = (++next).ToString(); } - body = _forum.ToString(); + var result = (JObject)_forum.DeepClone(); result["id"] = endpoint.Split('/')[1]; + body = result.ToString(); } else throw new InvalidOperationException($"Unexpected offline route: {method} {endpoint}"); return new RestResponse(HttpStatusCode.OK, new Dictionary(), new MemoryStream(Encoding.UTF8.GetBytes(body))); diff --git a/DiscordBot.Tests/Recruitment/RecruitmentWorkflowTests.cs b/DiscordBot.Tests/Recruitment/RecruitmentWorkflowTests.cs index 0b7aadc5..10ce5b35 100644 --- a/DiscordBot.Tests/Recruitment/RecruitmentWorkflowTests.cs +++ b/DiscordBot.Tests/Recruitment/RecruitmentWorkflowTests.cs @@ -180,7 +180,7 @@ public async Task AccessFailureDoesNotFreeAcceptedSlot() public async Task IncompleteInventoryPreventsAcceptanceAndAutomaticActions() { using var rig = new RecruitmentRig(); await rig.Start(); var post = await rig.Post(); - rig.Discord.InventoryComplete = false; await rig.Ack(); rig.Clock.Advance(TimeSpan.FromHours(1)); await rig.Sweep(); + rig.Discord.InventoryComplete = false; rig.Engine.MarkEvidenceGap(); await rig.Ack(); rig.Clock.Advance(TimeSpan.FromHours(1)); await rig.Sweep(); Assert.Null(post.AcceptedAt); Assert.Empty(rig.Discord.Actions); } diff --git a/DiscordBot/Modules/Recruitment/RecruitmentModule.cs b/DiscordBot/Modules/Recruitment/RecruitmentModule.cs index 2d3064c6..daabfbec 100644 --- a/DiscordBot/Modules/Recruitment/RecruitmentModule.cs +++ b/DiscordBot/Modules/Recruitment/RecruitmentModule.cs @@ -3,6 +3,7 @@ namespace DiscordBot.Modules.Recruitment; +/// Owner controls and moderator entry points; status reads a detached snapshot without joining the work queue. [Group("recruitment", "Recruitment moderation and recovery")] public sealed class RecruitmentModule : InteractionModuleBase { @@ -12,14 +13,23 @@ public sealed class RecruitmentModule : InteractionModuleBase Context.Channel.Id.ToString(); [SlashCommand("status", "Show recruitment availability")] - public Task Status() => Staff("status"); + public async Task Status() + { + await DeferAsync(ephemeral: true); + if (!Recruitment.CanReadStatus(Guild, Actor)) + { await FollowupAsync("Moderator access is required in the configured server.", ephemeral: true); return; } + await FollowupAsync(embed: Recruitment.Health.Embed(), ephemeral: true, allowedMentions: AllowedMentions.None); + } + [SlashCommand("help", "Show recruitment status and moderator commands")] + public Task Help() => Status(); [SlashCommand("lookup", "Look up a managed thread or author ID")] public Task Lookup(string id) => Staff("lookup", id); [SlashCommand("preview", "Preview managed forum setup")] public Task Preview([Choice("Paid recruiting", "paid-recruiting"), Choice("Paid for hire", "paid-for-hire"), Choice("Hobby recruiting", "hobby-recruiting"), Choice("Hobby for hire", "hobby-for-hire")] string forum = "") => Staff("preview", forum); [SlashCommand("repair", "Publish and verify managed Guidelines and tags")] - public Task Repair(string reason) => Staff("repair", reason: reason); + public Task Repair(string reason, [Choice("All forums", ""), Choice("Paid recruiting", "paid-recruiting"), Choice("Paid for hire", "paid-for-hire"), + Choice("Hobby recruiting", "hobby-recruiting"), Choice("Hobby for hire", "hobby-for-hire")] string forum = "") => Staff("repair", forum, reason); [SlashCommand("reconcile", "Reconcile Discord state and pending actions")] public Task Reconcile(string reason) => Staff("reconcile", reason: reason); [SlashCommand("manage", "Change a listing or review uncertain evidence")] @@ -35,6 +45,8 @@ public Task Manage( private async Task Staff(string operation, string target = "", string reason = "") { await DeferAsync(ephemeral: true); + if (operation is "repair" or "reconcile") + { await FollowupAsync(Recruitment.QueueRecovery(Guild, Actor, operation, target, reason), ephemeral: true, allowedMentions: AllowedMentions.None); return; } await Reply(() => Recruitment.RunAsync((engine, ct) => engine.StaffAsync(Guild, Actor, operation, target, reason, ct))); } diff --git a/DiscordBot/Services/Recruitment/DiscordRecruitmentAdapter.cs b/DiscordBot/Services/Recruitment/DiscordRecruitmentAdapter.cs index 8bda408b..3921c03d 100644 --- a/DiscordBot/Services/Recruitment/DiscordRecruitmentAdapter.cs +++ b/DiscordBot/Services/Recruitment/DiscordRecruitmentAdapter.cs @@ -1,4 +1,5 @@ using System.IO; +using System.Collections.Concurrent; using System.Net; using Discord.Net; using Discord.Rest; @@ -14,23 +15,34 @@ public sealed class DiscordRecruitmentAdapter : IRecruitmentDiscord private readonly BotSettings _settings; private readonly DatabaseService _database; private readonly Dictionary _forums = new(); + private readonly ConcurrentDictionary _expectedForums = new(); + private readonly Dictionary _threads = new(); + private readonly Dictionary _initialFacts = new(); + private readonly Dictionary _messageChannels = new(); + public void InvalidateThread(string threadId) { _threads.Remove(threadId); _messageChannels.Remove(threadId); } + public void InvalidateCache() { _threads.Clear(); _messageChannels.Clear(); _forums.Clear(); } public DiscordRecruitmentAdapter(DiscordSocketClient client, BotSettings settings, DatabaseService database) => (_client, _settings, _database) = (client, settings, database); - private static RequestOptions Options(CancellationToken ct) => new() { CancelToken = ct, Timeout = 15000 }; + private static RequestOptions Options(CancellationToken ct) => RecruitmentRequests.Options(ct); private SocketGuild Guild => _client.GetGuild(_settings.GuildId) ?? throw new InvalidOperationException("Configured guild is unavailable."); - public async Task ValidateAccessAsync(CancellationToken ct) + public Task ValidateAccessAsync(CancellationToken ct) { + ct.ThrowIfCancellationRequested(); if (_settings.Roles.Moderator == 0 || Guild.GetRole(_settings.Roles.Moderator) is null) throw new InvalidOperationException("The configured moderator role is unavailable."); foreach (var id in _settings.Recruitment.Forums.Mapping().Values) { - var forum = await ForumAsync(id.ToString(), ct); - var cached = Guild.GetChannel(id) ?? throw new InvalidOperationException("Forum permission cache unavailable."); + var cached = Guild.GetChannel(id) as SocketForumChannel ?? throw new InvalidOperationException("Forum permission cache unavailable."); var perms = Guild.CurrentUser.GetPermissions(cached); if (!perms.ViewChannel || !perms.ReadMessageHistory || !perms.SendMessagesInThreads || !perms.ManageThreads || !perms.ManageChannel) throw new InvalidOperationException("Recruitment needs View Channel, Read Message History, Send Messages in Threads, Manage Threads and Manage Channels on each forum."); } + return Task.CompletedTask; + } + + public async Task ValidateFeedAsync(CancellationToken ct) + { var feed = await _client.Rest.GetChannelAsync(_settings.Recruitment.FeedChannelId, Options(ct)) as RestTextChannel; if (feed is null || feed.GuildId != _settings.GuildId) throw new InvalidOperationException("Recruitment feed must be a text channel in the configured guild."); var cachedFeed = Guild.GetTextChannel(feed.Id) ?? throw new InvalidOperationException("Feed permission cache unavailable."); @@ -51,6 +63,14 @@ public async Task ValidateAccessAsync(CancellationToken ct) else if (overwrite.TargetId == _client.CurrentUser.Id || await IsModeratorAsync(_settings.GuildId.ToString(), overwrite.TargetId.ToString(), ct)) continue; throw new InvalidOperationException("The recruitment feed grants access outside the configured moderator/admin roles."); } + _messageChannels[feed.Id.ToString()] = feed; + return access.EmbedLinks; + } + + public async Task LatestFeedMessageAsync(CancellationToken ct) + { + var channel = await MessageChannelAsync(_settings.Recruitment.FeedChannelId.ToString(), ct); + return (await channel.GetMessagesAsync(1, CacheMode.AllowDownload, Options(ct)).FlattenAsync()).FirstOrDefault()?.Id.ToString(); } public async Task IsModeratorAsync(string guildId, string userId, CancellationToken ct) @@ -72,6 +92,7 @@ public async Task GetForumAsync(string forumId, CancellationToken var forum = await ForumAsync(forumId, ct); var snapshot = Snapshot(forum); _forums[forumId] = (snapshot, forum); + _expectedForums[forumId] = snapshot; return snapshot; } @@ -83,11 +104,20 @@ public async Task PublishForumAsync(ForumSnapshot expected, strin { if (!_forums.TryGetValue(expected.Id, out var observed) || !ReferenceEquals(observed.Snapshot, expected)) throw new InvalidOperationException("Forum publication needs a current pre-write observation."); + _expectedForums[expected.Id] = expected with { Topic = topic ?? expected.Topic, Tags = tags ?? expected.Tags, + RequireTag = requireTag, Flags = requireTag ? expected.Flags | ChannelFlags.RequireTag : expected.Flags & ~ChannelFlags.RequireTag }; var snapshot = await ModifyForumAsync(observed.Channel, topic, tags, requireTag, Options(ct)); _forums[expected.Id] = (snapshot, observed.Channel); return snapshot; } + // Read by gateway callbacks; compare the prepared publication as well as completed REST observations + // so our own setup/rotation events cannot invalidate reply coverage or reset challenge windows. + public bool IsExpectedForumUpdate(ForumSnapshot actual) => _expectedForums.TryGetValue(actual.Id, out var expected) && + expected.Topic == actual.Topic && expected.Flags == actual.Flags && expected.Tags.Count == actual.Tags.Count && + expected.Tags.All(t => actual.Tags.Any(a => (t.Id.Length == 0 || t.Id == a.Id) && t.Name == a.Name && + t.Moderated == a.Moderated && t.Emoji?.ToString() == a.Emoji?.ToString())); + internal static async Task ModifyForumAsync(RestForumChannel forum, string? topic, IReadOnlyList? tags, bool requireTag, RequestOptions options) { @@ -111,13 +141,21 @@ await forum.ModifyAsync(p => public async Task InventoryAsync(DateTimeOffset floor, IReadOnlySet knownIds, IReadOnlyDictionary cursors, CancellationToken ct) { - var found = new Dictionary(); + var found = new Dictionary(); var progress = new Dictionary(); var complete = true; + var firstId = _settings.Recruitment.Forums.Mapping().Values.First().ToString(); + var firstForum = _forums.TryGetValue(firstId, out var first) ? first.Channel : await ForumAsync(firstId, ct); + using (var active = RecruitmentForumTransport.ObserveActiveInventory(_settings.GuildId)) + { + foreach (var thread in await firstForum.GetActiveThreadsAsync(Options(ct))) found[thread.Id] = thread; + if (active.Identities is null) throw new InvalidOperationException("Active-thread inventory response was not observed; acceptance is held."); + foreach (var identity in active.Identities.Where(t => _settings.Recruitment.Forums.Contains(t.Parent))) + found.TryAdd(identity.Id, null); + } foreach (var id in _settings.Recruitment.Forums.Mapping().Values) { - var forum = await ForumAsync(id.ToString(), ct); - foreach (var thread in await forum.GetActiveThreadsAsync(Options(ct))) found[thread.Id] = thread; + var forum = _forums.TryGetValue(id.ToString(), out var observed) ? observed.Channel : await ForumAsync(id.ToString(), ct); var cursor = cursors[id.ToString()]; var started = cursor.StartedAt ?? DateTimeOffset.UtcNow; var since = cursor.Since > floor ? cursor.Since : floor; @@ -136,14 +174,19 @@ public async Task InventoryAsync(DateTimeOffset floor, IReadOnl progress[id.ToString()] = finished ? new ArchiveCursor(started.AddMinutes(-5)) : new ArchiveCursor(since, before, started); } var snapshots = new List(); - var unseen = found.Values.Where(t => t.CreatedAt >= floor && !knownIds.Contains(t.Id.ToString())).ToArray(); - foreach (var thread in unseen.Take(50)) snapshots.Add(await SnapshotAsync(thread, ct)); + var unseen = found.Where(t => SnowflakeUtils.FromSnowflake(t.Key) >= floor && !knownIds.Contains(t.Key.ToString())).ToArray(); + foreach (var thread in unseen.Take(50)) + { + var snapshot = thread.Value is not null ? await SnapshotAsync(thread.Value, ct) : await GetThreadAsync(thread.Key.ToString(), ct); + if (snapshot is not null) snapshots.Add(snapshot); + } // Do not advance past an archive page until all its unknown identities have been retained. return unseen.Length > 50 ? new ThreadInventory(snapshots, false, cursors) : new ThreadInventory(snapshots, complete, progress); } - public async Task GetThreadAsync(string threadId, CancellationToken ct) + public async Task GetThreadAsync(string threadId, CancellationToken ct, bool refresh = false) { + if (!refresh && _threads.TryGetValue(threadId, out var cached)) return cached; try { var channel = await _client.Rest.GetChannelAsync(ulong.Parse(threadId), Options(ct)); @@ -151,19 +194,33 @@ public async Task InventoryAsync(DateTimeOffset floor, IReadOnl throw new InvalidOperationException("Thread is outside configured recruitment forums."); return await SnapshotAsync(thread, ct); } - catch (HttpException ex) when (ex.DiscordCode == DiscordErrorCode.UnknownChannel) { return null; } + catch (HttpException ex) when (ex.DiscordCode == DiscordErrorCode.UnknownChannel) { _threads.Remove(threadId); return null; } } private async Task SnapshotAsync(RestThreadChannel thread, CancellationToken ct) { var ownerId = ((IThreadChannel)thread).OwnerId; - var user = await _client.Rest.GetGuildUserAsync(_settings.GuildId, ownerId, Options(ct)); - var starter = await thread.GetMessageAsync(thread.Id, Options(ct)); - return new ThreadSnapshot(thread.Id.ToString(), thread.ParentChannelId.ToString(), ownerId.ToString(), thread.Name, - thread.CreatedAt.ToUniversalTime(), user?.IsBot ?? starter?.Author.IsBot ?? false, - (thread.Flags & ChannelFlags.Pinned) != 0, thread.IsArchived, thread.IsLocked, - thread.AppliedTags.Select(t => t.ToString()).ToArray(), starter?.Content, - SnowflakeUtils.FromSnowflake(ownerId), user?.JoinedAt); + var id = thread.Id.ToString(); + if (!_initialFacts.TryGetValue(id, out var facts)) + { + var user = await _client.Rest.GetGuildUserAsync(_settings.GuildId, ownerId, Options(ct)); + var starter = await thread.GetMessageAsync(thread.Id, Options(ct)); + facts = new ThreadSnapshot(id, thread.ParentChannelId.ToString(), ownerId.ToString(), thread.Name, + thread.CreatedAt.ToUniversalTime(), user?.IsBot ?? starter?.Author.IsBot ?? false, false, false, false, + Array.Empty(), starter?.Content, SnowflakeUtils.FromSnowflake(ownerId), user?.JoinedAt); + // Missing starters remain retryable. Bound cached initial facts independently of retained history. + if (starter is not null) + { + if (_initialFacts.Count >= 4096) _initialFacts.Remove(_initialFacts.Keys.First()); + _initialFacts[id] = facts; + } + } + var snapshot = facts with { ForumId = thread.ParentChannelId.ToString(), OwnerId = ownerId.ToString(), Title = thread.Name, + Pinned = (thread.Flags & ChannelFlags.Pinned) != 0, Archived = thread.IsArchived, Locked = thread.IsLocked, + Tags = thread.AppliedTags.Select(t => t.ToString()).ToArray() }; + _threads[id] = snapshot; + _messageChannels[id] = thread; + return snapshot; } public async Task HasReplyAsync(ThreadSnapshot thread, CancellationToken ct) @@ -197,9 +254,11 @@ public async Task GetActivityAsync(string userId, CancellationToken ct) private async Task MessageChannelAsync(string id, CancellationToken ct) { + if (_messageChannels.TryGetValue(id, out var cached)) return cached; var channel = await _client.Rest.GetChannelAsync(ulong.Parse(id), Options(ct)); if (channel is not IGuildChannel guildChannel || guildChannel.GuildId != _settings.GuildId || channel is not IMessageChannel messages) throw new InvalidOperationException("Recruitment message channel unavailable."); + _messageChannels[id] = messages; return messages; } @@ -232,7 +291,8 @@ public async Task UpsertAsync(string channelId, string? messageId, boo if (existing is not null) { var unarchivedForEdit = false; - var edit = existing.Content != message.Content || existing.Components.Count == 0 && message.OwnerControls || existing.Components.Count > 0 && !message.OwnerControls; + var edit = existing.Content != message.Content || existing.Embeds.FirstOrDefault()?.Description != message.Embed?.Description || + existing.Embeds.FirstOrDefault()?.Title != message.Embed?.Title || existing.Components.Count == 0 && message.OwnerControls || existing.Components.Count > 0 && !message.OwnerControls; if (edit) { beforeWrite(); @@ -242,7 +302,7 @@ public async Task UpsertAsync(string channelId, string? messageId, boo await archived.ModifyAsync(p => p.Archived = false, Options(ct)); unarchivedForEdit = true; } - await existing.ModifyAsync(p => { p.Content = message.Content; p.Components = components; p.AllowedMentions = AllowedMentions.None; }, Options(ct)); + await existing.ModifyAsync(p => { p.Content = message.Content; p.Components = components; p.Embeds = message.Embed is null ? Array.Empty() : new[] { message.Embed }; p.AllowedMentions = AllowedMentions.None; }, Options(ct)); } // The caller persists this restoration intent before publication, so a restart can finish it. if (message.RestoreArchive && channel is IThreadChannel thread && (unarchivedForEdit || !thread.IsArchived)) @@ -265,27 +325,29 @@ public async Task UpsertAsync(string channelId, string? messageId, boo try { sent = await channel.SendFileAsync(new FileAttachment(stream, "recruitment.png", message.ImageDescription), message.Content, - options: Options(ct), allowedMentions: AllowedMentions.None, components: components); + options: Options(ct), allowedMentions: AllowedMentions.None, components: components, embed: message.Embed); } catch (HttpException ex) when (ex.HttpCode == HttpStatusCode.BadRequest || ex.DiscordCode == DiscordErrorCode.MissingPermissions) { // These are definitive rejections, so a text fallback cannot duplicate an ambiguous successful send. - sent = await channel.SendMessageAsync(message.Content, options: Options(ct), allowedMentions: AllowedMentions.None, components: components); + sent = await channel.SendMessageAsync(message.Content, options: Options(ct), allowedMentions: AllowedMentions.None, components: components, embed: message.Embed); } } - else sent = await channel.SendMessageAsync(message.Content, options: Options(ct), allowedMentions: AllowedMentions.None, components: components); + else sent = await channel.SendMessageAsync(message.Content, options: Options(ct), allowedMentions: AllowedMentions.None, components: components, embed: message.Embed); if (message.RestoreArchive && channel is IThreadChannel restoreThread) await restoreThread.ModifyAsync(p => p.Archived = true, Options(ct)); return new Delivery(sent.Id.ToString(), true); } - public async Task CloseAsync(ThreadSnapshot thread, string? closedTagId, bool protectPinned, CancellationToken ct) + public async Task CloseAsync(ThreadSnapshot thread, string? closedTagId, bool protectPinned, CancellationToken ct, Func? allowWrite = null) { var live = await ThreadAsync(thread.Id, ct); GuardWrite(live, thread, protectPinned); + if (allowWrite?.Invoke() == false) throw new InvalidOperationException("New recruitment evidence requires reconciliation before this action."); var tags = live.AppliedTags.ToList(); if (closedTagId is not null && tags.Count < 5 && !tags.Contains(ulong.Parse(closedTagId))) tags.Add(ulong.Parse(closedTagId)); await live.ModifyAsync(p => { p.AppliedTags = tags; p.Locked = true; p.Archived = true; }, Options(ct)); + InvalidateThread(thread.Id); var verified = await ThreadAsync(thread.Id, ct); if (!verified.IsLocked || !verified.IsArchived) throw new InvalidOperationException("Thread closure was not confirmed."); } @@ -294,17 +356,20 @@ public async Task ReopenAsync(ThreadSnapshot thread, string? closedTagId, Cancel var live = await ThreadAsync(thread.Id, ct); GuardWrite(live, thread, false); await live.ModifyAsync(p => { p.AppliedTags = live.AppliedTags.Where(t => t.ToString() != closedTagId).ToArray(); p.Locked = false; p.Archived = false; }, Options(ct)); + InvalidateThread(thread.Id); var verified = await ThreadAsync(thread.Id, ct); if (verified.IsLocked || verified.IsArchived) throw new InvalidOperationException("Thread reopening was not confirmed."); } private async Task ThreadAsync(string id, CancellationToken ct) => await _client.Rest.GetChannelAsync(ulong.Parse(id), Options(ct)) as RestThreadChannel ?? throw new InvalidOperationException("Thread unavailable."); - public async Task DeleteAsync(ThreadSnapshot thread, string reason, bool protectPinned, CancellationToken ct) + public async Task DeleteAsync(ThreadSnapshot thread, string reason, bool protectPinned, CancellationToken ct, Func? allowWrite = null) { var live = await ThreadAsync(thread.Id, ct); GuardWrite(live, thread, protectPinned); + if (allowWrite?.Invoke() == false) throw new InvalidOperationException("New recruitment evidence requires reconciliation before this action."); var options = Options(ct); options.AuditLogReason = "Recruitment: " + RecruitmentPresentation.Safe(reason); await live.DeleteAsync(options); + InvalidateThread(thread.Id); } private void GuardWrite(RestThreadChannel live, ThreadSnapshot expected, bool protectPinned) { @@ -313,10 +378,10 @@ private void GuardWrite(RestThreadChannel live, ThreadSnapshot expected, bool pr protectPinned && (live.Flags & ChannelFlags.Pinned) != 0) throw new InvalidOperationException("Thread identity or protection changed before the action."); } - public async Task DeleteAdvisoryAsync(string threadId, string messageId, CancellationToken ct) + public async Task DeleteAdvisoryAsync(string threadId, string messageId, CancellationToken ct, string? marker = null) { var channel = await MessageChannelAsync(threadId, ct); - if (await channel.GetMessageAsync(ulong.Parse(messageId), CacheMode.AllowDownload, Options(ct)) is IUserMessage message && message.Author.Id == _client.CurrentUser.Id) + if (await channel.GetMessageAsync(ulong.Parse(messageId), CacheMode.AllowDownload, Options(ct)) is IUserMessage message && message.Author.Id == _client.CurrentUser.Id && (marker is null || message.Content.Contains(marker))) await message.DeleteAsync(Options(ct)); } } diff --git a/DiscordBot/Services/Recruitment/IRecruitmentDiscord.cs b/DiscordBot/Services/Recruitment/IRecruitmentDiscord.cs index 2eeb561e..b53e0c96 100644 --- a/DiscordBot/Services/Recruitment/IRecruitmentDiscord.cs +++ b/DiscordBot/Services/Recruitment/IRecruitmentDiscord.cs @@ -8,25 +8,30 @@ public sealed record ThreadSnapshot(string Id, string ForumId, string OwnerId, s public sealed record ArchiveCursor(DateTimeOffset Since, DateTimeOffset? Before = null, DateTimeOffset? StartedAt = null); public sealed record ThreadInventory(IReadOnlyList Threads, bool Complete, IReadOnlyDictionary? Cursors = null); public sealed record ReplyObservation(string ThreadId, string AuthorId, bool IsBot, bool IsWebhook, bool? IsStaff); -public sealed record RecruitmentMessage(string Content, string Marker, bool OwnerControls, byte[]? Image = null, string? ImageDescription = null, bool RestoreArchive = false); +public sealed record RecruitmentMessage(string Content, string Marker, bool OwnerControls, byte[]? Image = null, string? ImageDescription = null, bool RestoreArchive = false, Embed? Embed = null); public sealed record Delivery(string Id, bool NewlySent); /// Null thread means confirmed Discord Unknown Channel, never a cache miss or permission failure. public interface IRecruitmentDiscord { Task ValidateAccessAsync(CancellationToken ct); + Task ValidateFeedAsync(CancellationToken ct); + Task LatestFeedMessageAsync(CancellationToken ct); Task IsModeratorAsync(string guildId, string userId, CancellationToken ct); + bool IsExpectedForumUpdate(ForumSnapshot snapshot); Task GetForumAsync(string forumId, CancellationToken ct); Task PublishForumAsync(ForumSnapshot expected, string? topic, IReadOnlyList? tags, bool requireTag, CancellationToken ct); Task InventoryAsync(DateTimeOffset floor, IReadOnlySet knownIds, IReadOnlyDictionary cursors, CancellationToken ct); - Task GetThreadAsync(string threadId, CancellationToken ct); + Task GetThreadAsync(string threadId, CancellationToken ct, bool refresh = false); + void InvalidateThread(string threadId); + void InvalidateCache(); Task HasReplyAsync(ThreadSnapshot thread, CancellationToken ct); Task GetActivityAsync(string userId, CancellationToken ct); // Ambiguous sends must search the entire bounded recovery interval or hold; never blindly resend. Task UpsertAsync(string channelId, string? messageId, bool recovering, DateTimeOffset since, RecruitmentMessage message, Action beforeWrite, CancellationToken ct); - Task CloseAsync(ThreadSnapshot thread, string? closedTagId, bool protectPinned, CancellationToken ct); + Task CloseAsync(ThreadSnapshot thread, string? closedTagId, bool protectPinned, CancellationToken ct, Func? allowWrite = null); Task ReopenAsync(ThreadSnapshot thread, string? closedTagId, CancellationToken ct); - Task DeleteAsync(ThreadSnapshot thread, string reason, bool protectPinned, CancellationToken ct); - Task DeleteAdvisoryAsync(string threadId, string messageId, CancellationToken ct); + Task DeleteAsync(ThreadSnapshot thread, string reason, bool protectPinned, CancellationToken ct, Func? allowWrite = null); + Task DeleteAdvisoryAsync(string threadId, string messageId, CancellationToken ct, string? marker = null); } diff --git a/DiscordBot/Services/Recruitment/RecruitmentAssets.cs b/DiscordBot/Services/Recruitment/RecruitmentAssets.cs index bae8432d..3ccaacba 100644 --- a/DiscordBot/Services/Recruitment/RecruitmentAssets.cs +++ b/DiscordBot/Services/Recruitment/RecruitmentAssets.cs @@ -73,6 +73,7 @@ public string TagAdvice(string purpose, IEnumerable appliedIds, ForumRec /// Publishes native Guidelines and managed tags, retaining intent across partial Discord writes. public sealed class RecruitmentSetup { + public event Action? Progress; private readonly RecruitmentStateStore _store; private readonly RecruitmentAssets _assets; private readonly RecruitmentSettings _settings; @@ -105,6 +106,7 @@ public async Task ReconcileAsync(DateTimeOffset now, bool repair, CancellationTo receipt.Fault = RecruitmentFault.From(ex, now, receipt.Fault); } _store.Save(); + Progress?.Invoke(); } } diff --git a/DiscordBot/Services/Recruitment/RecruitmentEngine.cs b/DiscordBot/Services/Recruitment/RecruitmentEngine.cs index c79486f1..552b3221 100644 --- a/DiscordBot/Services/Recruitment/RecruitmentEngine.cs +++ b/DiscordBot/Services/Recruitment/RecruitmentEngine.cs @@ -4,7 +4,7 @@ namespace DiscordBot.Services.Recruitment; /// All entry points are serialized by RecruitmentService, including staff and owner commands. -public sealed class RecruitmentEngine : IDisposable +public sealed partial class RecruitmentEngine : IDisposable { private readonly BotSettings _bot; private readonly RecruitmentSettings _settings; @@ -23,15 +23,19 @@ public sealed class RecruitmentEngine : IDisposable private DateTimeOffset Now => _time.GetUtcNow(); private sealed record RemovalConfirmation(string Guild, string Thread, string Owner, long Version, DateTimeOffset Expires); - public RecruitmentEngine(BotSettings bot, IRecruitmentDiscord discord, TimeProvider? time = null, Func? pendingThreadWork = null) + public RecruitmentEngine(BotSettings bot, IRecruitmentDiscord discord, TimeProvider? time = null, Func? pendingThreadWork = null, Func? evidenceGeneration = null, Func? gatewayReady = null, Func? pendingIdentities = null) { (_bot, _settings, _discord, _time) = (bot, bot.Recruitment, discord, time ?? TimeProvider.System); _pendingThreadWork = pendingThreadWork ?? (_ => false); + _evidenceGeneration = evidenceGeneration ?? (() => 0); + _gatewayReady = gatewayReady ?? (() => true); + _pendingIdentities = pendingIdentities ?? (() => false); } public async Task StartAsync(CancellationToken ct) { if (!_settings.Enabled) { Status = "Disabled"; return; } + Status = "Starting recruitment"; var errors = _settings.Validate(); if (errors.Count > 0) { Status = string.Join("; ", errors); return; } try @@ -40,6 +44,7 @@ public async Task StartAsync(CancellationToken ct) _store = new RecruitmentStateStore(_bot.ServerRootPath, _bot.GuildId.ToString(), _settings.Forums.Mapping().ToDictionary(x => x.Key, x => x.Value.ToString()), Now); _setup = new RecruitmentSetup(_store, _assets, _settings, _discord); + _setup.Progress += () => Changed?.Invoke(); MarkEvidenceGap(); await SweepAsync(ct); } @@ -47,10 +52,14 @@ public async Task StartAsync(CancellationToken ct) { Status = $"Recruitment stopped: {ex.GetType().Name}: {ex.Message}"; _ready = false; } } - public void MarkEvidenceGap() + public void MarkEvidenceGap(bool invalidateAccess = true) { if (State is null) return; _inventoryComplete = false; + _inventoryGeneration = -1; + _accessInvalid = true; + if (invalidateAccess) _feedReady = false; + _discord.InvalidateCache(); foreach (var post in State.Listings.Values.Where(p => p.Status == ListingStatus.Open)) { post.ResponseCoverageComplete = false; @@ -62,59 +71,77 @@ public void MarkEvidenceGap() public async Task SweepAsync(CancellationToken ct, bool retryHeld = true) { if (_store is null || _setup is null) return; - _ready = false; + var previousFault = State!.RecoveryFault; + var auditDue = _nextAudit <= Now; + var held = !retryHeld && State.RecoveryFault is { } recovery && !recovery.IsDue(Now); + _nextAudit = Now.AddMinutes(15 + Random.Shared.NextDouble() * 3); + _publicRenders.Clear(); _staffRenders.Clear(); _replyScanned.Clear(); + _discord.InvalidateCache(); try { - await _discord.ValidateAccessAsync(ct); + if (_accessInvalid || auditDue || retryHeld) + { + _feedReady = false; + var feedGeneration = _evidenceGeneration(); + _feedEmbeds = await _discord.ValidateFeedAsync(ct); + _feedGeneration = feedGeneration; + _feedReady = true; + if (State!.Overview.MessageId is not null && await _discord.LatestFeedMessageAsync(ct) is { } latest) FeedActivity(latest); + if (!held) await _discord.ValidateAccessAsync(ct); + _accessInvalid = false; + } + if (held) { Changed?.Invoke(); return; } + State!.RecoveryFault = null; await _setup.ReconcileAsync(Now, false, ct, retryHeld); if (State!.Forums.Values.FirstOrDefault(f => f.Fault is not null) is { } failed) - throw new InvalidOperationException(failed.Fault!.Message); + { + _ready = false; + Status = "Recruitment held: " + failed.Fault!.Message; + Changed?.Invoke(); + return; + } _ready = true; + State.RecoveryFault = null; await DiscoverAsync(ct); - var cursor = ulong.TryParse(State!.SweepAfterId, out var savedCursor) ? savedCursor : 0; + var cursor = ulong.TryParse(State.SweepAfterId, out var savedCursor) ? savedCursor : 0; var batch = State.Listings.Values .Where(p => p.Status == ListingStatus.Open || p.PendingAction is not null || p.StaffSendPending || p.PublicSendPending || p.PublicationPending) - .OrderBy(p => ulong.Parse(p.Id) <= cursor).ThenBy(p => ulong.Parse(p.Id)).Take(50).ToArray(); + .OrderBy(p => ulong.Parse(p.Id) <= cursor).ThenBy(p => ulong.Parse(p.Id)).Take(10).ToArray(); foreach (var post in batch) { ct.ThrowIfCancellationRequested(); - try { await ProcessAsync(post, ct); } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not IOException) - { - post.ResponseCoverageComplete = false; - post.Hold = $"Discord operation incomplete ({ex.GetType().Name}); retrying reconciliation"; - if (!post.AcceptedAt.HasValue && post.Status == ListingStatus.Open) _recoverWindows.Add(post.Id); - Save(); - } + if (retryHeld) post.Fault = null; + await ProcessSafelyAsync(post, ct); } if (batch.Length > 0) State.SweepAfterId = batch[^1].Id; RecruitmentPolicy.Prune(State, Now, _settings.PostRetentionMonths, _settings.AuthorRetentionMonths, _settings.CooldownDays); - State.LastSweepAt = Now; + if (EvidenceCurrent) State.LastSweepAt = Now; + else _nextAudit = Now.AddSeconds(10); Save(); - Status = _inventoryComplete ? $"Operating; {State.Listings.Count} retained listings; {State.Listings.Values.Count(p => p.Hold is not null)} held for recovery/review" : "Inventory incomplete; acceptance and automatic actions held"; + Status = EvidenceCurrent ? $"Operating; {State.Listings.Count} retained listings; {State.Listings.Values.Count(p => p.Hold is not null)} held for recovery/review" : "Inventory incomplete; acceptance and automatic actions held"; } catch (Exception ex) when (ex is not OperationCanceledException) { - _ready = false; _inventoryComplete = false; + _ready = false; + try { MarkEvidenceGap(invalidateAccess: false); } catch (IOException) { return; } if (State is not null) { - foreach (var p in State.Listings.Values.Where(p => p.Status == ListingStatus.Open)) - { - p.ResponseCoverageComplete = false; - if (!p.AcceptedAt.HasValue) _recoverWindows.Add(p.Id); - } + State.RecoveryFault = RecruitmentFault.From(ex, Now, previousFault); try { Save(); } catch { return; } } Status = $"Recruitment held: {ex.GetType().Name}: {ex.Message}"; } + Changed?.Invoke(); } private async Task DiscoverAsync(CancellationToken ct) { var state = State!; + var generation = _evidenceGeneration(); var cursors = state.Forums.Values.ToDictionary(f => f.ForumId, f => f.ArchiveCursor ?? new ArchiveCursor(state.DiscoveryFloor)); var inventory = await _discord.InventoryAsync(state.DiscoveryFloor, state.Listings.Keys.ToHashSet(), cursors, ct); _inventoryComplete = inventory.Complete; + _inventoryGeneration = generation; foreach (var live in inventory.Threads.OrderBy(t => t.CreatedAt).ThenBy(t => ulong.Parse(t.Id))) await ObserveAsync(live, false, ct); if (inventory.Cursors is not null) @@ -126,7 +153,11 @@ private async Task DiscoverAsync(CancellationToken ct) public async Task ReconcileThreadAsync(string thread, CancellationToken ct) { - if (_ready && State?.Listings.GetValueOrDefault(thread) is { } post) await ProcessAsync(post, ct); + if (!_ready || State?.Listings.GetValueOrDefault(thread) is not { } post) return; + await ProcessSafelyAsync(post, ct); + foreach (var waiting in State.Listings.Values.Where(p => p.Id != post.Id && p.OwnerId == post.OwnerId && + p.Status == ListingStatus.Open && !p.AcceptedAt.HasValue).OrderBy(p => p.CreatedAt).ToArray()) + await ProcessSafelyAsync(waiting, ct); } public async Task ObserveAsync(ThreadSnapshot live, bool observedFromCreation, CancellationToken ct) @@ -179,14 +210,18 @@ private async Task ProcessAsync(Listing post, CancellationToken ct) if (!post.PublicSendPending) post.PublicRestoreArchive = live.Archived && post.AcceptedAt.HasValue; post.Hold = null; if (live.Starter is null && !post.PromptDeliveredAt.HasValue && !post.AcceptedAt.HasValue) - { post.Hold = "Starter message is not available yet"; Save(); return; } + { + post.Hold = "Starter message is not available yet"; + post.Fault = new RecruitmentFault(post.Hold, Now.AddSeconds(15)); + _discord.InvalidateThread(post.Id); Save(); return; + } if (post.PendingAction is not null) { await ExecuteActionAsync(post, ct); return; } if (post.Status != ListingStatus.Open) { if (post.StaffSendPending || post.PublicSendPending || post.PublicationPending) await PublishAsync(post, ct); return; } - var reply = await _discord.HasReplyAsync(live, ct); + var reply = post.HasQualifyingReply ? true : _replyScanned.Add(post.Id) ? await _discord.HasReplyAsync(live, ct) : false; if (reply == true) post.HasQualifyingReply = true; if (reply is null) post.ResponseCoverageComplete = false; if (_recoverWindows.Remove(post.Id)) @@ -206,11 +241,11 @@ private async Task ProcessAsync(Listing post, CancellationToken ct) post.Version++; Save(); await PublishAsync(post, ct); } - if (_inventoryComplete && post.AcknowledgedAt.HasValue && !post.AcceptedAt.HasValue) + if (EvidenceCurrent && post.AcknowledgedAt.HasValue && !post.AcceptedAt.HasValue) await RefreshBlockersAsync(post, ct); - if (_inventoryComplete && RecruitmentPolicy.TryAccept(State, post, Now, _settings.CooldownDays)) + if (EvidenceCurrent && RecruitmentPolicy.TryAccept(State, post, Now, _settings.CooldownDays)) { Save(); await PublishAsync(post, ct); } - if (!_inventoryComplete || post.Exempt || post.Pinned) return; + if (!EvidenceCurrent || post.Exempt || post.Pinned) return; if (live.Tags.Contains(ClosingTag(post))) await BeginActionAsync(post, RecruitmentActionKind.Close, "system", "Closed tag applied", "closed-tag", true, ct); else if (!post.AcceptedAt.HasValue && post.Deadline <= Now) @@ -241,16 +276,21 @@ private async Task RefreshBlockersAsync(Listing candidate, CancellationToken ct) var id = RecruitmentPolicy.Evaluate(State!, candidate, Now, _settings.CooldownDays).BlockingListingId; if (id is null) return; var blocker = State!.Listings[id]; - if (await ReadAsync(blocker, ct) is not null) return; - RecordAbsence(blocker); + try + { + if (await ReadAsync(blocker, ct, refresh: true) is not null) return; + RecordAbsence(blocker); + } + catch (Exception ex) when (ex is not IOException && ex is not OperationCanceledException) + { blocker.Hold = "Author capacity could not be verified; existing slot retained"; Save(); return; } } } - private async Task ReadAsync(Listing post, CancellationToken ct) + private async Task ReadAsync(Listing post, CancellationToken ct, bool refresh = false) { try { - var live = await _discord.GetThreadAsync(post.Id, ct); + var live = await _discord.GetThreadAsync(post.Id, ct, refresh); if (live is not null && (live.OwnerId != post.OwnerId || live.ForumId != post.ForumId)) throw new InvalidOperationException("Thread identity changed; staff review required."); post.AvailabilityUnknown = false; @@ -262,15 +302,20 @@ private async Task RefreshBlockersAsync(Listing candidate, CancellationToken ct) private async Task PublishStaffAsync(Listing post, CancellationToken ct) { + var message = RecruitmentPresentation.Staff(State!, post, _settings); + if (!post.StaffSendPending && post.StaffMessageId is not null && _staffRenders.GetValueOrDefault(post.Id) == message.Content) return; var recovering = post.StaffSendPending; var delivery = await _discord.UpsertAsync(_settings.FeedChannelId.ToString(), post.StaffMessageId, recovering, - post.StaffSendStartedAt ?? post.CreatedAt, RecruitmentPresentation.Staff(State!, post, _settings), () => + post.StaffSendStartedAt ?? post.CreatedAt, message, () => { + GuardFeedWrite(); if (!recovering) post.StaffSendStartedAt = Now; post.StaffSendPending = true; Save(); }, ct); var changed = post.StaffMessageId != delivery.Id || post.StaffSendPending; post.StaffMessageId = delivery.Id; post.StaffSendPending = false; + _staffRenders[post.Id] = message.Content; + if (delivery.NewlySent) FeedActivity(delivery.Id); if (changed) Save(); } @@ -279,6 +324,8 @@ private async Task PublishAsync(Listing post, CancellationToken ct) await PublishStaffAsync(post, ct); if (post.Status is ListingStatus.Deleted or ListingStatus.Reference) { post.PublicationPending = false; post.PublicSendPending = false; Save(); return; } + var message = RecruitmentPresentation.Public(State!, post, _settings, Now); + if (!post.PublicSendPending && !post.PublicationPending && post.PublicMessageId is not null && _publicRenders.GetValueOrDefault(post.Id) == message.Content) return; var recovering = post.PublicSendPending; var image = post.PublicMessageId is null ? RecruitmentPresentation.RenderBanner(post, _bot.AssetsRootPath) : null; var delivery = await _discord.UpsertAsync(post.Id, post.PublicMessageId, recovering, post.PublicSendStartedAt ?? post.CreatedAt, @@ -290,6 +337,7 @@ private async Task PublishAsync(Listing post, CancellationToken ct) }, ct); var changed = post.PublicMessageId != delivery.Id || post.PublicSendPending || post.PublicationPending; post.PublicMessageId = delivery.Id; post.PublicSendPending = false; post.PublicationPending = false; + _publicRenders[post.Id] = message.Content; if (delivery.NewlySent && post.Deadline.HasValue && !post.AcceptedAt.HasValue) { post.PromptDeliveredAt = null; post.Deadline = null; post.Version++; changed = true; } if (changed) Save(); @@ -299,9 +347,13 @@ public async Task AcknowledgeAsync(string guild, string thread, string o { var post = Owner(guild, thread, owner); if (post is null) return "This control is unavailable or does not belong to you."; - await SweepAsync(ct); // Catch up cross-forum capacity before committing acceptance. - if (!_ready) return "Recruitment is waiting for recovery. Please try again shortly."; + // Startup/recovery establishes cross-forum identity coverage; ordinary acknowledgements only + // refresh the candidate and its blocking author records. + if (!_ready || !_feedReady || _feedGeneration != _evidenceGeneration()) return "Recruitment is waiting for recovery. Please try again shortly."; + _discord.InvalidateThread(post.Id); + post.Fault = null; await ProcessAsync(post, ct); + await RefreshBlockersAsync(post, ct); if (post.Status != ListingStatus.Open || post.AcceptedAt.HasValue) return "This listing no longer needs acknowledgement."; var submittedAt = receivedAt ?? Now; if (!post.Deadline.HasValue || post.Deadline < submittedAt || post.PromptDeliveredAt > submittedAt) @@ -312,9 +364,9 @@ public async Task AcknowledgeAsync(string guild, string thread, string o post.AcknowledgedAt = Now; post.Version++; post.UpdatedAt = Now; var author = RecruitmentPolicy.Author(State!, owner, Now); author.ConsecutiveTimeouts = 0; author.TimeoutAlerted = false; - var accepted = _inventoryComplete && RecruitmentPolicy.TryAccept(State!, post, Now, _settings.CooldownDays); + var accepted = EvidenceCurrent && RecruitmentPolicy.TryAccept(State!, post, Now, _settings.CooldownDays); Save(); await PublishAsync(post, ct); - return accepted ? "Guidelines acknowledged. Your listing is accepted." : !_inventoryComplete + return accepted ? "Guidelines acknowledged. Your listing is accepted." : !EvidenceCurrent ? "Guidelines acknowledged. Acceptance is waiting for inventory recovery." : "Guidelines acknowledged. The existing listing or creation cooldown still blocks acceptance; see the advisory."; } @@ -374,7 +426,7 @@ private async Task ExecuteActionAsync(Listing post, CancellationToken ct) } // Intent and staff evidence must exist before removing or changing member content. await PublishStaffAsync(post, ct); - var live = await ReadAsync(post, ct); + var live = await ReadAsync(post, ct, refresh: true); if (live is null) { RecordAbsence(post); @@ -398,7 +450,7 @@ private async Task ExecuteActionAsync(Listing post, CancellationToken ct) post.LastOutcome = "Closure confirmed after interrupted action"; post.UpdatedAt = Now; post.Version++; post.PublicationPending = true; Save(); await PublishAsync(post, ct); return; } - if (action.Automatic && (!_inventoryComplete || post.Exempt || post.Pinned || post.Status != ListingStatus.Open || + if (action.Automatic && (!EvidenceCurrent || post.Exempt || post.Pinned || post.Status != ListingStatus.Open || (action.Condition == "timeout" && (post.AcknowledgedAt.HasValue || post.AcceptedAt.HasValue || post.Deadline > Now || _recoverWindows.Contains(post.Id))) || (action.Condition == "ineligible" && (post.AcceptedAt.HasValue || RecruitmentPolicy.Evaluate(State!, post, Now, _settings.CooldownDays).Allowed)) || (action.Condition == "closed-tag" && !live.Tags.Contains(ClosingTag(post))) || @@ -416,13 +468,13 @@ private async Task ExecuteActionAsync(Listing post, CancellationToken ct) if (action.Kind == RecruitmentActionKind.Reopen && State!.Listings.Values.Any(p => p.OwnerId == post.OwnerId && p.Id != post.Id && p.Status == ListingStatus.Open)) throw new InvalidOperationException("Another listing occupies this author's slot."); // Gateway facts and timely owner interactions already queued must be applied before an automatic action. - if (action.Automatic && _pendingThreadWork(post.Id)) + if (action.Automatic && (!EvidenceCurrent || _pendingThreadWork(post.Id))) { post.PendingAction = null; Save(); return; } action.CallStarted = true; Save(); switch (action.Kind) { case RecruitmentActionKind.Delete: - await _discord.DeleteAsync(live, action.Reason, action.Automatic, ct); + await _discord.DeleteAsync(live, action.Reason, action.Automatic, ct, () => !action.Automatic || EvidenceCurrent && !_pendingThreadWork(post.Id)); post.Status = ListingStatus.Deleted; if (action.GuidelineTimeout) { @@ -433,7 +485,7 @@ private async Task ExecuteActionAsync(Listing post, CancellationToken ct) } break; case RecruitmentActionKind.Close: - await _discord.CloseAsync(live, ClosingTag(post), action.Automatic, ct); + await _discord.CloseAsync(live, ClosingTag(post), action.Automatic, ct, () => !action.Automatic || EvidenceCurrent && !_pendingThreadWork(post.Id)); post.Status = ListingStatus.Closed; break; case RecruitmentActionKind.Reopen: @@ -470,16 +522,19 @@ public async Task StaffAsync(string guild, string actor, string operatio reason = RecruitmentPresentation.Safe(reason, 300); if (operation == "repair") { + if (target.Length > 0 && !State.Forums.ContainsKey(target)) return "Unknown recruitment forum purpose."; + State.Overview.Fault = null; State.SetupAudit.Add(new RecruitmentAudit(Now, actor, "Repair forum setup: " + reason)); if (State.SetupAudit.Count > 30) State.SetupAudit.RemoveAt(0); Save(); await _discord.ValidateAccessAsync(ct); - await _setup!.ReconcileAsync(Now, true, ct); + await _setup!.ReconcileAsync(Now, true, ct, target: string.IsNullOrEmpty(target) ? null : target); Status = "Setup repaired"; await SweepAsync(ct); return Status; } if (operation == "reconcile") { + State.Overview.Fault = null; State.SetupAudit.Add(new RecruitmentAudit(Now, actor, "Reconcile: " + reason)); if (State.SetupAudit.Count > 30) State.SetupAudit.RemoveAt(0); Save(); await SweepAsync(ct); return Status; @@ -556,7 +611,7 @@ private void Audit(Listing post, string actor, string reason) } private void Save() { - try { _store!.Save(); } + try { _store!.Save(); Changed?.Invoke(); } catch { _ready = false; diff --git a/DiscordBot/Services/Recruitment/RecruitmentEngineScheduling.cs b/DiscordBot/Services/Recruitment/RecruitmentEngineScheduling.cs new file mode 100644 index 00000000..2d34c61f --- /dev/null +++ b/DiscordBot/Services/Recruitment/RecruitmentEngineScheduling.cs @@ -0,0 +1,150 @@ +namespace DiscordBot.Services.Recruitment; + +public sealed partial class RecruitmentEngine +{ + private readonly Func _evidenceGeneration; + private readonly Func _gatewayReady; + private readonly Func _pendingIdentities; + private long _inventoryGeneration = -1; + private DateTimeOffset _nextAudit = DateTimeOffset.MaxValue; + private bool _accessInvalid = true; + private readonly HashSet _replyScanned = new(); + private readonly Dictionary _publicRenders = new(); + private readonly Dictionary _staffRenders = new(); + private bool EvidenceCurrent => _gatewayReady() && !_pendingIdentities() && _inventoryComplete && _inventoryGeneration == _evidenceGeneration(); + public event Action? Changed; + + public RecruitmentHealth Health(string work = "Idle") => new(_ready ? EvidenceCurrent ? "Operating" : "Inventory incomplete; acceptance and automatic actions held" : Status, work, Now, + State?.Listings.Values.Count(p => p.Status == ListingStatus.Open) ?? 0, + State?.Listings.Values.Count(p => p.Status == ListingStatus.Open && !p.AcknowledgedAt.HasValue) ?? 0, + State?.Listings.Values.Count(p => p.Hold is not null) ?? 0, EvidenceCurrent, + State?.LastSweepAt, NextWorkAt, + State?.Forums.Select(f => new ForumHealth(f.Key, f.Value.ForumId, + f.Value.Fault is not null ? "held" : f.Value.PendingTopic is not null ? "publication pending" : + f.Value.ConfirmedTopic.Length == 0 ? "not verified" : "Guidelines and tags verified", + f.Value.Fault?.Message, f.Value.Fault?.RetryAt)).ToArray() ?? Array.Empty(), State?.Overview.Fault?.Message); + + private DateTimeOffset? NextWorkAt + { + get + { + if (State is null || !_settings.Enabled) return null; + var times = new List(); + if (_nextAudit != DateTimeOffset.MaxValue) times.Add(_nextAudit); + if (State.RecoveryFault?.RetryAt is { } retry) times.Add(retry); + times.AddRange(State.Forums.Values.Where(f => f.Fault?.RetryAt is not null).Select(f => f.Fault!.RetryAt!.Value)); + if (_ready && EvidenceCurrent) + { + times.Add(State.RotateAt); + times.AddRange(State.Listings.Values.Select(ListingDue).Where(d => d.HasValue).Select(d => d!.Value)); + } + return times.Count == 0 ? null : times.Min(); + } + } + + private DateTimeOffset? ListingDue(Listing post) + { + if (post.Fault is { } fault) return fault.RetryAt; + if (post.PendingAction is not null || post.PublicSendPending || post.StaffSendPending || post.PublicationPending) return Now; + if (post.Status != ListingStatus.Open) return null; + if (_recoverWindows.Contains(post.Id)) return Now; + if (!post.PromptDeliveredAt.HasValue && !post.AcceptedAt.HasValue) return Now; + var dates = new List(); + if (!post.Exempt && !post.Pinned) + { + if (!post.AcceptedAt.HasValue && post.Deadline.HasValue) dates.Add(post.Deadline.Value); + if (post.AcceptedAt.HasValue && post.ResponseCoverageComplete && !post.HasQualifyingReply && !post.SuppressUnansweredClose) + dates.Add(post.CreatedAt.AddDays(_settings.UnansweredDays)); + } + if (post.AcknowledgedAt.HasValue && !post.AcceptedAt.HasValue) + { + var next = RecruitmentPolicy.Evaluate(State!, post, Now, _settings.CooldownDays); + if (next.BlockingListingId is null) dates.Add(next.NextAllowedAt ?? Now); + } + return dates.Count == 0 ? null : dates.Min(); + } + + public async Task RunDueAsync(CancellationToken ct) + { + if (State is null) return; + if (_nextAudit <= Now || State.RecoveryFault?.IsDue(Now) == true || + State.Forums.Values.Any(f => f.Fault?.IsDue(Now) == true) || _ready && State.RotateAt <= Now) + { + await SweepAsync(ct, retryHeld: false); + return; + } + if (!_ready || !EvidenceCurrent) return; + foreach (var post in State.Listings.Values.Where(p => ListingDue(p) <= Now).OrderBy(p => p.CreatedAt).Take(20).ToArray()) + await ProcessSafelyAsync(post, ct); + } + + public void RequestRecovery() => _nextAudit = Now; + + public void InvalidateAccess(string? forum = null) + { + _accessInvalid = true; + _feedReady = false; + _ready = false; + _discord.InvalidateCache(); + _publicRenders.Clear(); _staffRenders.Clear(); + if (State is null) return; + State.RecoveryFault = null; + State.Overview.Fault = null; + foreach (var f in State.Forums.Values.Where(f => forum is null || f.ForumId == forum)) f.Fault = null; + foreach (var post in State.Listings.Values.Where(p => forum is null || p.ForumId == forum)) post.Fault = null; + _nextAudit = Now; + MarkEvidenceGap(); + } + + public async Task ThreadChangedAsync(string id, CancellationToken ct) + { + _discord.InvalidateThread(id); + if (State is null || !_ready) return; + if (!State.Listings.TryGetValue(id, out var post)) + { + var live = await _discord.GetThreadAsync(id, ct); + // ThreadCreated also signals access to an existing thread; it cannot certify reply coverage. + if (live is not null) await ObserveAsync(live, false, ct); + if (!State.Listings.TryGetValue(id, out post)) return; + } + post.Fault = null; + await ReconcileThreadAsync(id, ct); + } + + public async Task MessageDeletedAsync(string channel, IReadOnlySet ids, CancellationToken ct) + { + if (State is null) return; + if (channel == _settings.FeedChannelId.ToString() && State.Overview.MessageId is { } overview && ids.Contains(overview)) + { State.Overview.MessageId = null; State.Overview.Fault = null; _overviewRender = null; Save(); } + foreach (var post in State.Listings.Values.Where(p => + channel == _settings.FeedChannelId.ToString() && p.StaffMessageId is not null && ids.Contains(p.StaffMessageId) || + channel == p.Id && p.PublicMessageId is not null && ids.Contains(p.PublicMessageId)).ToArray()) + { + if (channel == post.Id) { post.PublicMessageId = null; _publicRenders.Remove(post.Id); } + else { post.StaffMessageId = null; _staffRenders.Remove(post.Id); } + post.PublicationPending = true; post.Fault = null; Save(); + if (_ready) await ProcessSafelyAsync(post, ct); + } + } + + private async Task ProcessSafelyAsync(Listing post, CancellationToken ct) + { + if (post.Fault is { } fault && !fault.IsDue(Now)) return; + var previousFault = post.Fault; + post.Fault = null; + try + { + await ProcessAsync(post, ct); + if (previousFault is not null) Save(); + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not System.IO.IOException) + { + post.ResponseCoverageComplete = false; + _discord.InvalidateThread(post.Id); + post.Fault = RecruitmentFault.From(ex, Now, previousFault); + post.Hold = post.Fault.Message; + if (!post.AcceptedAt.HasValue && post.Status == ListingStatus.Open) _recoverWindows.Add(post.Id); + Save(); + } + } +} diff --git a/DiscordBot/Services/Recruitment/RecruitmentForumTransport.cs b/DiscordBot/Services/Recruitment/RecruitmentForumTransport.cs index a7a6c009..36f99a4b 100644 --- a/DiscordBot/Services/Recruitment/RecruitmentForumTransport.cs +++ b/DiscordBot/Services/Recruitment/RecruitmentForumTransport.cs @@ -1,4 +1,7 @@ using Discord.Net.Rest; +using System.IO; +using System.Net; +using System.Text; using Newtonsoft.Json; using Newtonsoft.Json.Linq; @@ -9,6 +12,19 @@ internal sealed class RecruitmentForumTransport(IRestClient inner) : IRestClient { internal const string TagWriteReason = "Recruitment: publish forum tags"; private static readonly AsyncLocal Current = new(); + private static readonly AsyncLocal Inventory = new(); + internal sealed class ActiveInventory(ulong guildId, ActiveInventory? previous) : IDisposable + { + public ulong GuildId { get; } = guildId; + public IReadOnlyList<(ulong Id, ulong Parent)>? Identities { get; set; } + public void Dispose() => Inventory.Value = previous; + } + internal static ActiveInventory ObserveActiveInventory(ulong guildId) + { + var scope = new ActiveInventory(guildId, Inventory.Value); + Inventory.Value = scope; + return scope; + } private sealed record TagScope(string Endpoint, IReadOnlyList Tags); public static RestClientProvider Wrap(RestClientProvider provider) => url => new RecruitmentForumTransport(provider(url)); @@ -29,14 +45,34 @@ private sealed class Scope(Action restore) : IDisposable public void SetHeader(string key, string value) => inner.SetHeader(key, value); public void SetCancelToken(CancellationToken cancelToken) => inner.SetCancelToken(cancelToken); - public Task SendAsync(string method, string endpoint, CancellationToken cancelToken, - bool headerOnly, string reason, IEnumerable>> requestHeaders) => - inner.SendAsync(method, endpoint, cancelToken, headerOnly, reason, requestHeaders); + public async Task SendAsync(string method, string endpoint, CancellationToken cancelToken, + bool headerOnly, string reason, IEnumerable>> requestHeaders) + { + var response = await RecruitmentRequests.SendAsync(method, endpoint, cancelToken, + () => inner.SendAsync(method, endpoint, cancelToken, headerOnly, reason, requestHeaders)); + var scope = Inventory.Value; + if (scope is null || method != "GET" || endpoint != $"guilds/{scope.GuildId}/threads/active" || response.StatusCode != HttpStatusCode.OK) return response; + using var stream = response.Stream; + using var reader = new StreamReader(stream); + var body = await reader.ReadToEndAsync(cancelToken); + // Forum.GetActiveThreadsAsync downloads the entire guild, then discards other parents. + // Retain their identities from that same response without private SDK APIs or another request. + var threads = JObject.Parse(body)["threads"] as JArray ?? throw new InvalidOperationException("Active-thread inventory is incomplete."); + scope.Identities = threads.Select(t => + { + if (!ulong.TryParse((string?)t["id"], out var id) || id == 0 || + !ulong.TryParse((string?)t["parent_id"], out var parent) || parent == 0 || + t["guild_id"] is { Type: not JTokenType.Null } guild && (ulong)guild != scope.GuildId) + throw new InvalidOperationException("Active-thread inventory identity is invalid."); + return (id, parent); + }).ToArray(); + return new RestResponse(response.StatusCode, response.Headers, new MemoryStream(Encoding.UTF8.GetBytes(body))); + } public Task SendAsync(string method, string endpoint, IReadOnlyDictionary multipart, CancellationToken cancelToken, bool headerOnly, string reason, IEnumerable>> requestHeaders) => - inner.SendAsync(method, endpoint, multipart, cancelToken, headerOnly, reason, requestHeaders); + RecruitmentRequests.SendAsync(method, endpoint, cancelToken, () => inner.SendAsync(method, endpoint, multipart, cancelToken, headerOnly, reason, requestHeaders)); public Task SendAsync(string method, string endpoint, string json, CancellationToken cancelToken, bool headerOnly, string reason, IEnumerable>> requestHeaders) @@ -60,6 +96,6 @@ public Task SendAsync(string method, string endpoint, string json, } json = body.ToString(Formatting.None); } - return inner.SendAsync(method, endpoint, json, cancelToken, headerOnly, reason, requestHeaders); + return RecruitmentRequests.SendAsync(method, endpoint, cancelToken, () => inner.SendAsync(method, endpoint, json, cancelToken, headerOnly, reason, requestHeaders)); } } diff --git a/DiscordBot/Services/Recruitment/RecruitmentHealth.cs b/DiscordBot/Services/Recruitment/RecruitmentHealth.cs new file mode 100644 index 00000000..80ea8f5f --- /dev/null +++ b/DiscordBot/Services/Recruitment/RecruitmentHealth.cs @@ -0,0 +1,33 @@ +namespace DiscordBot.Services.Recruitment; + +public sealed record ForumHealth(string Purpose, string Id, string Setup, string? Fault, DateTimeOffset? RetryAt); + +/// Detached from mutable state so moderators can inspect progress while the worker awaits Discord. +public sealed record RecruitmentHealth(string Status, string Work, DateTimeOffset ObservedAt, + int Open, int Pending, int Held, bool InventoryComplete, DateTimeOffset? LastRecovery, + DateTimeOffset? NextDue, IReadOnlyList Forums, string? OverviewFault = null, string? Job = null) +{ + public static RecruitmentHealth Starting(DateTimeOffset now) => new("Starting", "Opening recruitment state", now, + 0, 0, 0, false, null, null, Array.Empty()); + + public Embed Embed(bool includeFreshness = true) + { + var embed = new EmbedBuilder().WithTitle("Recruitment status").WithColor(InventoryComplete && Status == "Operating" ? Color.Green : Color.Orange) + .WithDescription(Text()); + if (includeFreshness) embed.WithFooter($"Snapshot captured {ObservedAt:u}"); + return embed.Build(); + } + + public string Text() + { + var forums = string.Join("\n", Forums.Select(f => $"• <#{f.Id}> · {f.Purpose}: {f.Setup}" + + (f.Fault is null ? "" : $" — {f.Fault}" + (f.RetryAt.HasValue ? $"; retry {RecruitmentPresentation.Timestamp(f.RetryAt.Value)}" : "; preview and repair required")))); + return RecruitmentPresentation.Safe(Status, 400) + $"\nOpen: {Open} · awaiting acknowledgement: {Pending} · held: {Held}\n" + + $"Inventory: {(InventoryComplete ? "complete" : "recovery needed")} · work: {Work}\n" + + (LastRecovery.HasValue ? $"Last recovery: {RecruitmentPresentation.Timestamp(LastRecovery.Value)}\n" : "No completed recovery yet.\n") + + (NextDue.HasValue ? $"Next scheduled work: {RecruitmentPresentation.Timestamp(NextDue.Value)}\n" : "") + forums + + (OverviewFault is null ? "" : "\nOverview: " + OverviewFault) + + (Job is null ? "" : "\nJob: " + Job) + + "\nUse /recruitment status, lookup, preview, repair or reconcile. Repairs and changes require a reason."; + } +} diff --git a/DiscordBot/Services/Recruitment/RecruitmentOverview.cs b/DiscordBot/Services/Recruitment/RecruitmentOverview.cs new file mode 100644 index 00000000..a01ac4e2 --- /dev/null +++ b/DiscordBot/Services/Recruitment/RecruitmentOverview.cs @@ -0,0 +1,103 @@ +namespace DiscordBot.Services.Recruitment; + +public sealed partial class RecruitmentEngine +{ + private bool _feedReady; + private long _feedGeneration = -1; + private bool _feedEmbeds; + private string? _overviewRender; + private DateTimeOffset? _overviewDirtyAt; + private DateTimeOffset? _feedActivityFirst; + private DateTimeOffset? _feedActivityLast; + + private void GuardFeedWrite() + { + if (!_feedReady || !_gatewayReady() || _feedGeneration != _evidenceGeneration()) + throw new InvalidOperationException("Feed access changed; recruitment recovery is required before publication."); + } + + public DateTimeOffset? NextDueAt => new[] { NextWorkAt, OverviewDueAt }.Where(t => t.HasValue).Min(); + private DateTimeOffset? OverviewDueAt + { + get + { + if (!_feedReady || !_gatewayReady() || _feedGeneration != _evidenceGeneration() || State is null) return null; + var receipt = State.Overview; + if (receipt.Fault is not null) return receipt.Fault.RetryAt; + if (receipt.SendPending || receipt.RetiredMessageId is not null || receipt.MessageId is null) return Now; + var times = new List(); + if (_overviewDirtyAt is { } dirty) + times.Add(Max(dirty.AddSeconds(5), (receipt.LastEditedAt ?? DateTimeOffset.MinValue).AddSeconds(30))); + if (_feedActivityLast is { } activity) + { + var eligible = (receipt.LastPostedAt ?? Now).AddMinutes(5); + var quiet = activity.AddSeconds(10); + var bound = Max(_feedActivityFirst ?? activity, eligible).AddSeconds(60); + times.Add(Max(eligible, quiet < bound ? quiet : bound)); + } + return times.Count == 0 ? null : times.Min(); + } + } + private static DateTimeOffset Max(DateTimeOffset a, DateTimeOffset b) => a > b ? a : b; + + public void FeedActivity(string messageId, bool overview = false) + { + if (State is null || overview || !ulong.TryParse(messageId, out var id)) return; + if (ulong.TryParse(State.Overview.MessageId, out var current) && id <= current) return; + _feedActivityFirst ??= Now; + _feedActivityLast = Now; + } + + public async Task RefreshOverviewAsync(CancellationToken ct) + { + if (!_feedReady || !_gatewayReady() || _feedGeneration != _evidenceGeneration() || State is null) return; + var receipt = State.Overview; + var health = Health(); + var render = health.Text(); + if (render != _overviewRender) _overviewDirtyAt ??= Now; + if (!(OverviewDueAt <= Now)) return; + if (receipt.Fault is not null && !receipt.Fault.IsDue(Now)) return; + try + { + if (receipt.RetiredMessageId is not null) + { + await _discord.DeleteAdvisoryAsync(_settings.FeedChannelId.ToString(), receipt.RetiredMessageId, ct, receipt.RetiredMarker); + receipt.RetiredMessageId = receipt.RetiredMarker = null; + Save(); + } + var replace = receipt.SendPending ? receipt.PendingReplacement : receipt.MessageId is null || + _feedActivityLast.HasValue && (receipt.LastPostedAt?.AddMinutes(5) ?? Now) <= Now && + (Now >= _feedActivityLast.Value.AddSeconds(10) || Now >= Max(_feedActivityFirst!.Value, receipt.LastPostedAt!.Value.AddMinutes(5)).AddSeconds(60)); + if (!receipt.SendPending) + { + receipt.PendingReplacement = replace; + receipt.PendingMarker = replace ? $"recruitment:overview:{State.InstanceId}:{++receipt.Sequence}" : receipt.Marker; + receipt.SendStartedAt = Now; + } + var marker = receipt.PendingMarker ?? throw new InvalidOperationException("Overview delivery marker is missing; reconcile required."); + var message = new RecruitmentMessage(_feedEmbeds ? $"-# {marker}" : "**Recruitment status**\n" + render[..Math.Min(1800, render.Length)] + $"\n-# {marker}", + marker, false, Embed: _feedEmbeds ? health.Embed(includeFreshness: false) : null); + var delivery = await _discord.UpsertAsync(_settings.FeedChannelId.ToString(), replace ? null : receipt.MessageId, + receipt.SendPending, receipt.SendStartedAt ?? Now, message, () => { GuardFeedWrite(); receipt.SendPending = true; Save(); }, ct); + if (replace && receipt.MessageId is not null && receipt.MessageId != delivery.Id) + { receipt.RetiredMessageId = receipt.MessageId; receipt.RetiredMarker = receipt.Marker; } + receipt.MessageId = delivery.Id; receipt.Marker = marker; receipt.SendPending = false; + receipt.PendingMarker = null; receipt.PendingReplacement = false; receipt.Fault = null; + receipt.LastEditedAt = Now; + if (replace || delivery.NewlySent) + { receipt.LastPostedAt = Now; _feedActivityFirst = _feedActivityLast = null; } + _overviewRender = render; _overviewDirtyAt = null; + Save(); + if (receipt.RetiredMessageId is not null) + { + await _discord.DeleteAdvisoryAsync(_settings.FeedChannelId.ToString(), receipt.RetiredMessageId, ct, receipt.RetiredMarker); + receipt.RetiredMessageId = receipt.RetiredMarker = null; Save(); + } + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not System.IO.IOException) + { + receipt.Fault = RecruitmentFault.From(ex, Now, receipt.Fault); + Save(); // Cosmetic failures never change listing eligibility or evidence. + } + } +} diff --git a/DiscordBot/Services/Recruitment/RecruitmentRequests.cs b/DiscordBot/Services/Recruitment/RecruitmentRequests.cs new file mode 100644 index 00000000..61cf2745 --- /dev/null +++ b/DiscordBot/Services/Recruitment/RecruitmentRequests.cs @@ -0,0 +1,67 @@ +using System.Collections.Concurrent; +using System.Globalization; +using System.IO; +using System.Net; +using Discord.Net.Rest; +using Newtonsoft.Json.Linq; + +namespace DiscordBot.Services.Recruitment; + +/// Hands Discord retry deadlines back to the worker instead of sleeping inside the SDK queue. +internal static class RecruitmentRequests +{ + private static readonly AsyncLocal RecruitmentCall = new(); + private static readonly ConcurrentDictionary DeferredRoutes = new(); + private static long _globalUntilTicks; + + public static RequestOptions Options(CancellationToken ct) + { + ct.ThrowIfCancellationRequested(); + RecruitmentCall.Value = true; + var global = new DateTimeOffset(Interlocked.Read(ref _globalUntilTicks), TimeSpan.Zero); + if (global > DateTimeOffset.UtcNow) throw new RecruitmentDeferredException(global); + return new RequestOptions { CancelToken = ct, Timeout = 15000, RetryMode = RetryMode.AlwaysFail }; + } + + public static async Task SendAsync(string method, string endpoint, CancellationToken ct, Func> send) + { + // Discord buckets include a major channel/guild identity. Conservatively defer that identity + // across methods, message IDs and query strings so a different URL cannot evade a known limit. + var path = endpoint.Split('?')[0].Split('/'); + var key = path.Length >= 2 && path[0] is "channels" or "guilds" or "webhooks" + ? path[0] + "/" + path[1] : method + " " + endpoint.Split('?')[0]; + var now = DateTimeOffset.UtcNow; + var until = DeferredRoutes.GetValueOrDefault(key); + var global = new DateTimeOffset(Interlocked.Read(ref _globalUntilTicks), TimeSpan.Zero); + if (global > until) until = global; + if (until > now) + { + if (RecruitmentCall.Value) throw new RecruitmentDeferredException(until); + await Task.Delay(until - now, ct); // Other bot features retain their usual waiting behavior. + } + ct.ThrowIfCancellationRequested(); + var response = await send(); + if (response.StatusCode != HttpStatusCode.TooManyRequests) return response; + using var original = response.Stream; + using var reader = new StreamReader(original); + var body = await reader.ReadToEndAsync(ct); + var payload = JObject.Parse(body); + var seconds = (double?)payload["retry_after"]; + if (!seconds.HasValue && response.Headers.TryGetValue("Retry-After", out var header) && + double.TryParse(header, NumberStyles.Float, CultureInfo.InvariantCulture, out var value)) seconds = value; + // Include a small cushion; never shorten the server's retry window. + now = DateTimeOffset.UtcNow; + until = now.AddSeconds(Math.Max(1, seconds ?? 60) + 1); + DeferredRoutes.AddOrUpdate(key, until, (_, prior) => prior > until ? prior : until); + if ((bool?)payload["global"] == true) + { + long previous; + do { previous = Interlocked.Read(ref _globalUntilTicks); } + while (previous < until.UtcTicks && Interlocked.CompareExchange(ref _globalUntilTicks, until.UtcTicks, previous) != previous); + } + if (DeferredRoutes.Count > 1024) + foreach (var expired in DeferredRoutes.Where(x => x.Value <= now)) DeferredRoutes.TryRemove(expired.Key, out _); + if (RecruitmentCall.Value) throw new RecruitmentDeferredException(until); + return new RestResponse(response.StatusCode, response.Headers, new MemoryStream(System.Text.Encoding.UTF8.GetBytes(body))); + } +} diff --git a/DiscordBot/Services/Recruitment/RecruitmentService.cs b/DiscordBot/Services/Recruitment/RecruitmentService.cs index f41c8849..d4f13916 100644 --- a/DiscordBot/Services/Recruitment/RecruitmentService.cs +++ b/DiscordBot/Services/Recruitment/RecruitmentService.cs @@ -5,191 +5,351 @@ namespace DiscordBot.Services.Recruitment; -/// One bounded queue owns all recruitment state and external operations. +/// Serializes durable work; gateway callbacks only capture facts and wake the next-due scheduler. public sealed class RecruitmentService { - private readonly DiscordSocketClient _client; + private readonly DiscordSocketClient? _client; private readonly BotSettings _settings; - private readonly DiscordRecruitmentAdapter _discord; private readonly RecruitmentEngine _engine; + private readonly IRecruitmentDiscord _discord; + private bool _gatewayReady = true; + private readonly TimeProvider _time; private readonly ConcurrentDictionary _pendingThreadWork = new(); + private readonly Dictionary> _coalesced = new(); + private readonly object _enqueueGate = new(); + private IReadOnlySet _knownThreads = new HashSet(); + private IReadOnlyDictionary _messageOwners = new Dictionary(); + private string? _overviewMessage; private readonly Channel _queue = Channel.CreateBounded(new BoundedChannelOptions(256) { SingleReader = true, FullMode = BoundedChannelFullMode.Wait }); private readonly CancellationTokenSource _stop = new(); private Task? _worker; - private Timer? _timer; - private DateTimeOffset _connectedSince; + private ITimer? _timer; private int _gap; private long _evidenceGeneration; - private int _sweepQueued; - private sealed record Work(Func Run, Action Cancel); + private string _work = "Starting"; + private string? _job; + private RecruitmentHealth _health; + private enum WorkKind { Event, Deadline, Command, Recovery } + private sealed record Work(WorkKind Kind, Func Run, Action Cancel); + public RecruitmentHealth Health => Volatile.Read(ref _health); public RecruitmentService(DiscordSocketClient client, BotSettings settings, DatabaseService database) + : this(settings, new DiscordRecruitmentAdapter(client, settings, database), TimeProvider.System) => _client = client; + + internal RecruitmentService(BotSettings settings, IRecruitmentDiscord discord, TimeProvider time) { - _client = client; _settings = settings; - _discord = new DiscordRecruitmentAdapter(client, settings, database); - _engine = new RecruitmentEngine(settings, _discord, pendingThreadWork: id => _pendingThreadWork.GetValueOrDefault(id) > 0); + _settings = settings; _time = time; _discord = discord; + _health = RecruitmentHealth.Starting(time.GetUtcNow()); + _engine = new RecruitmentEngine(settings, discord, time, + id => _pendingThreadWork.GetValueOrDefault(id) > 0, () => Interlocked.Read(ref _evidenceGeneration), () => Volatile.Read(ref _gatewayReady), + () => _pendingThreadWork.Keys.Any(id => !Volatile.Read(ref _knownThreads).Contains(id))); + _engine.Changed += PublishHealth; } public async Task StartAsync() { if (_worker is not null) return; - _connectedSince = DateTimeOffset.UtcNow; - if (_settings.Recruitment.Enabled) - { - _client.ThreadCreated += ThreadCreated; - _client.ThreadUpdated += ThreadUpdated; - _client.ThreadDeleted += ThreadDeleted; - _client.MessageReceived += MessageReceived; - _client.Disconnected += Disconnected; - _client.Connected += Connected; - } + _gatewayReady = _client is null || _client.GetGuild(_settings.GuildId)?.IsConnected == true; + Subscribe(true); + _timer = _time.CreateTimer(_ => Enqueue("due", ct => _engine.RunDueAsync(ct), WorkKind.Deadline), null, Timeout.InfiniteTimeSpan, Timeout.InfiniteTimeSpan); var started = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); _worker = Task.Run(async () => { try { await _engine.StartAsync(_stop.Token); - LoggingService.LogToConsole("Recruitment: " + _engine.Status, ExtendedLogSeverity.Info); - started.TrySetResult(); + _work = "Idle"; + await _engine.RefreshOverviewAsync(_stop.Token); + PublishHealth(); ArmTimer(); started.TrySetResult(); await foreach (var work in _queue.Reader.ReadAllAsync(_stop.Token)) { + _work = work.Kind.ToString(); PublishHealth(); try { - if (Interlocked.Exchange(ref _gap, 0) != 0) _engine.MarkEvidenceGap(); + if (Interlocked.Exchange(ref _gap, 0) != 0) { _engine.MarkEvidenceGap(); _engine.RequestRecovery(); } await work.Run(_stop.Token); + await _engine.RefreshOverviewAsync(_stop.Token); } catch (OperationCanceledException) when (_stop.IsCancellationRequested) { work.Cancel(); break; } catch (Exception ex) { - work.Cancel(); - EvidenceGap(); - LoggingService.LogToConsole($"Recruitment operation held ({ex.GetType().Name}). Use /recruitment status or reconcile.", ExtendedLogSeverity.Warning); + work.Cancel(); EvidenceGap(); + LoggingService.LogToConsole($"Recruitment {work.Kind} held ({ex.GetType().Name}); inspect /recruitment status.", ExtendedLogSeverity.Warning); } + finally { _work = "Idle"; PublishHealth(); ArmTimer(); } } } catch (OperationCanceledException) when (_stop.IsCancellationRequested) { started.TrySetCanceled(); } catch (Exception ex) { started.TrySetException(ex); } finally { + _stop.Cancel(); _queue.Writer.TryComplete(); _timer?.Dispose(); Subscribe(false); while (_queue.Reader.TryRead(out var work)) work.Cancel(); _engine.Dispose(); + Volatile.Write(ref _health, Health with { Status = "Stopped", Work = "Stopped", ObservedAt = _time.GetUtcNow() }); } }); await started.Task; - if (_settings.Recruitment.Enabled && !_queue.Reader.Completion.IsCompleted && !_stop.IsCancellationRequested) - _timer = new Timer(_ => ScheduleSweep(), null, TimeSpan.FromSeconds(30), TimeSpan.FromSeconds(30)); } - private void EvidenceGap() + private void PublishHealth() + { + var next = _engine.Health(_work) with { Job = _job }; + var before = Health; + Volatile.Write(ref _health, next); + Volatile.Write(ref _knownThreads, _engine.State?.Listings.Keys.ToHashSet() ?? new HashSet()); + var owners = new Dictionary(); + foreach (var post in _engine.State?.Listings.Values ?? Enumerable.Empty()) + { + if (post.PublicMessageId is not null) owners[post.PublicMessageId] = post.Id; + if (post.StaffMessageId is not null) owners[post.StaffMessageId] = post.Id; + } + Volatile.Write(ref _messageOwners, owners); + Volatile.Write(ref _overviewMessage, _engine.State?.Overview.MessageId); + foreach (var forum in next.Forums) + { + var previous = before.Forums.FirstOrDefault(f => f.Id == forum.Id); + if (previous?.Fault == forum.Fault) continue; + LoggingService.LogToConsole($"Recruitment setup [{forum.Purpose}/{forum.Id}]: " + + (forum.Fault is null ? "recovered" : forum.Fault + (forum.RetryAt.HasValue ? $"; retry {forum.RetryAt:u}" : "; preview and repair required")), + forum.Fault is null ? ExtendedLogSeverity.Info : ExtendedLogSeverity.Warning); + } + if (before.Status != next.Status) + LoggingService.LogToConsole("Recruitment: " + next.Status, + next.InventoryComplete ? ExtendedLogSeverity.Info : ExtendedLogSeverity.Warning); + } + + private void ArmTimer() { - Interlocked.Increment(ref _evidenceGeneration); + if (!Volatile.Read(ref _gatewayReady)) { SetTimer(Timeout.InfiniteTimeSpan); return; } + var due = _engine.NextDueAt; + var delay = due.HasValue ? due.Value - _time.GetUtcNow() : Timeout.InfiniteTimeSpan; + // A bounded continuation yields to facts/commands already queued, including a timely acknowledgement. + if (due.HasValue && delay < TimeSpan.FromSeconds(1)) delay = TimeSpan.FromSeconds(1); + SetTimer(delay); + } + + private void SetTimer(TimeSpan delay) + { + if (_stop.IsCancellationRequested) return; + try { _timer?.Change(delay, Timeout.InfiniteTimeSpan); } + catch (ObjectDisposedException) when (_stop.IsCancellationRequested) { } + } + + internal void EvidenceGap() + { + Interlocked.Increment(ref _evidenceGeneration); // Immediately fences an action already awaiting its final read. Interlocked.Exchange(ref _gap, 1); + Volatile.Write(ref _health, Health with { InventoryComplete = false, Status = "Gateway evidence incomplete; recovery needed" }); } - private Action TrackThread(string? threadId) + + private Action TrackThread(string? thread) { - if (threadId is null) return () => { }; - _pendingThreadWork.AddOrUpdate(threadId, 1, (_, count) => count + 1); + if (thread is null) return () => { }; + _pendingThreadWork.AddOrUpdate(thread, 1, (_, count) => count + 1); var finished = 0; return () => { - if (Interlocked.Exchange(ref finished, 1) == 0) - { - var remaining = _pendingThreadWork.AddOrUpdate(threadId, 0, (_, count) => Math.Max(0, count - 1)); - if (remaining == 0) ((ICollection>)_pendingThreadWork).Remove(new(threadId, 0)); - } + if (Interlocked.Exchange(ref finished, 1) != 0) return; + var remaining = _pendingThreadWork.AddOrUpdate(thread, 0, (_, count) => Math.Max(0, count - 1)); + if (remaining == 0) ((ICollection>)_pendingThreadWork).Remove(new(thread, 0)); }; } - private void Event(Func run, string? threadId = null) - { - var finished = TrackThread(threadId); - if (!_queue.Writer.TryWrite(new Work(async ct => { try { await run(ct); } finally { finished(); } }, finished))) - { finished(); EvidenceGap(); } - } - private void ScheduleSweep() + + private bool Enqueue(string key, Func run, WorkKind kind = WorkKind.Event, string? thread = null) { - if (Interlocked.Exchange(ref _sweepQueued, 1) != 0) return; - if (!_queue.Writer.TryWrite(new Work(async ct => + lock (_enqueueGate) { - try { await _engine.SweepAsync(ct); } - finally { Interlocked.Exchange(ref _sweepQueued, 0); } - }, () => Interlocked.Exchange(ref _sweepQueued, 0)))) - { Interlocked.Exchange(ref _sweepQueued, 0); EvidenceGap(); } + if (_stop.IsCancellationRequested) return false; + if (_coalesced.ContainsKey(key)) { _coalesced[key] = run; return true; } + _coalesced[key] = run; + var finished = TrackThread(thread); + void Cancel() { lock (_enqueueGate) _coalesced.Remove(key); finished(); } + if (_queue.Writer.TryWrite(new Work(kind, async ct => + { + Func? latest; + lock (_enqueueGate) _coalesced.Remove(key, out latest); + try { if (latest is not null) await latest(ct); } + finally { finished(); } + }, Cancel))) return true; + Cancel(); EvidenceGap(); + SetTimer(TimeSpan.FromSeconds(1)); + return false; + } } + + internal void NotifyThread(string id) => Enqueue("thread:" + id, ct => _engine.ThreadChangedAsync(id, ct), thread: id); + internal void NotifyReply(ReplyObservation reply) => + Enqueue($"reply:{reply.ThreadId}:{reply.AuthorId}:{reply.IsStaff}", async ct => + { + if (_engine.State?.Listings.ContainsKey(reply.ThreadId) != true) await _engine.ThreadChangedAsync(reply.ThreadId, ct); + _engine.ObserveReply(reply); + }, thread: reply.ThreadId); + private Task ThreadCreated(SocketThreadChannel thread) { - if (thread.Guild.Id != _settings.GuildId || !_settings.Recruitment.Forums.Contains(thread.ParentChannel.Id)) return Task.CompletedTask; - var fromCreation = thread.CreatedAt >= _connectedSince; - var generation = Interlocked.Read(ref _evidenceGeneration); - Event(async ct => - { - var live = await _discord.GetThreadAsync(thread.Id.ToString(), ct); - if (live is not null) await _engine.ObserveAsync(live, fromCreation && generation == Interlocked.Read(ref _evidenceGeneration), ct); - }); - ScheduleSweep(); + if (IsRecruitment(thread)) NotifyThread(thread.Id.ToString()); return Task.CompletedTask; } private Task ThreadUpdated(Cacheable before, SocketThreadChannel after) { - if (after.Guild.Id == _settings.GuildId && _settings.Recruitment.Forums.Contains(after.ParentChannel.Id)) ScheduleSweep(); + if (IsRecruitment(after) && (!before.HasValue || before.Value.Flags != after.Flags || before.Value.IsArchived != after.IsArchived || + before.Value.IsLocked != after.IsLocked || !before.Value.AppliedTags.SequenceEqual(after.AppliedTags))) NotifyThread(after.Id.ToString()); return Task.CompletedTask; } private Task ThreadDeleted(Cacheable thread) { - Event(ct => _engine.ReconcileThreadAsync(thread.Id.ToString(), ct)); - ScheduleSweep(); return Task.CompletedTask; + // An uncached unrelated deletion costs no REST call. Known identities can be checked on the worker. + if (!Volatile.Read(ref _knownThreads).Contains(thread.Id.ToString())) return Task.CompletedTask; + Enqueue("deleted:" + thread.Id, ct => _engine.State?.Listings.ContainsKey(thread.Id.ToString()) == true + ? _engine.ThreadChangedAsync(thread.Id.ToString(), ct) : Task.CompletedTask); + return Task.CompletedTask; } + private bool IsRecruitment(SocketThreadChannel thread) => thread.Guild.Id == _settings.GuildId && _settings.Recruitment.Forums.Contains(thread.ParentChannel.Id); private Task MessageReceived(SocketMessage message) { - if (message.Channel is not SocketThreadChannel thread || thread.Guild.Id != _settings.GuildId || - !_settings.Recruitment.Forums.Contains(thread.ParentChannel.Id)) return Task.CompletedTask; + if (message.Channel.Id == _settings.Recruitment.FeedChannelId) + { + var overview = message.Author.Id == _client?.CurrentUser.Id && message.Content.Contains("recruitment:overview:"); + if (overview) return Task.CompletedTask; + Enqueue("feed-activity", _ => { _engine.FeedActivity(message.Id.ToString(), false); return Task.CompletedTask; }); + return Task.CompletedTask; + } + if (message.Channel is not SocketThreadChannel thread || !IsRecruitment(thread) || message.Author.IsBot || message.Source == MessageSource.Webhook || + message.Author.Id == ((IThreadChannel)thread).OwnerId) return Task.CompletedTask; var user = message.Author as SocketGuildUser; - var reply = new ReplyObservation(thread.Id.ToString(), message.Author.Id.ToString(), message.Author.IsBot, - message.Source == MessageSource.Webhook, user is null ? null : user.GuildPermissions.Administrator || user.Roles.Any(r => r.Id == _settings.Roles.Moderator)); - Event(async ct => + NotifyReply(new ReplyObservation(thread.Id.ToString(), message.Author.Id.ToString(), false, false, + user is null ? null : user.GuildPermissions.Administrator || user.Roles.Any(r => r.Id == _settings.Roles.Moderator))); + return Task.CompletedTask; + } + private Task MessageDeleted(Cacheable message, Cacheable channel) => + MessagesDeleted(new[] { message }, channel); + private Task MessagesDeleted(IReadOnlyCollection> messages, Cacheable channel) + { + NotifyMessageDeletion(channel.Id.ToString(), messages.Select(m => m.Id.ToString()).ToHashSet()); + return Task.CompletedTask; + } + internal void NotifyMessageDeletion(string channel, IReadOnlySet ids) + { + var owners = Volatile.Read(ref _messageOwners); + foreach (var group in ids.Where(owners.ContainsKey).GroupBy(id => owners[id])) + { + var affected = group.ToHashSet(); + Enqueue($"messages:{channel}:{group.Key}", ct => _engine.MessageDeletedAsync(channel, affected, ct), thread: group.Key); + } + if (Volatile.Read(ref _overviewMessage) is { } overview && ids.Contains(overview)) + Enqueue("overview-deleted", ct => _engine.MessageDeletedAsync(channel, new HashSet { overview }, ct)); + } + + private Task Disconnected(Exception _) { Volatile.Write(ref _gatewayReady, false); EvidenceGap(); return Task.CompletedTask; } + private Task Ready() + { + if (_client?.GetGuild(_settings.GuildId)?.IsConnected == true) { Volatile.Write(ref _gatewayReady, true); ScheduleRecovery(); } + return Task.CompletedTask; + } + private Task GuildAvailable(SocketGuild guild) { if (guild.Id == _settings.GuildId) { Volatile.Write(ref _gatewayReady, true); ScheduleRecovery(); } return Task.CompletedTask; } + private Task GuildUnavailable(SocketGuild guild) { if (guild.Id == _settings.GuildId) { Volatile.Write(ref _gatewayReady, false); EvidenceGap(); } return Task.CompletedTask; } + private void ScheduleRecovery() + { + EvidenceGap(); + Enqueue("recovery", async ct => { _engine.InvalidateAccess(); await _engine.SweepAsync(ct, retryHeld: false); }, WorkKind.Recovery); + } + private Task ChannelUpdated(SocketChannel before, SocketChannel after) + { + if (after is not SocketGuildChannel channel || channel.Guild.Id != _settings.GuildId) return Task.CompletedTask; + var permissionsChanged = before is not SocketGuildChannel prior || !prior.PermissionOverwrites.SequenceEqual(channel.PermissionOverwrites) || + (before as INestedChannel)?.CategoryId != (after as INestedChannel)?.CategoryId; + if (after is SocketForumChannel forum && _settings.Recruitment.Forums.Contains(forum.Id)) + { + var snapshot = new ForumSnapshot(forum.Id.ToString(), forum.Topic ?? "", (forum.Flags & ChannelFlags.RequireTag) != 0, + forum.Tags.Select(t => new LiveTag(t.Id.ToString(), t.Name, t.IsModerated, t.Emoji)).ToArray(), forum.Flags); + if (!permissionsChanged && _discord.IsExpectedForumUpdate(snapshot)) return Task.CompletedTask; + } + else if (!permissionsChanged || after.Id != _settings.Recruitment.FeedChannelId && after is not SocketCategoryChannel) return Task.CompletedTask; + ScheduleRecovery(); return Task.CompletedTask; + } + private Task ChannelDestroyed(SocketChannel channel) + { + if (channel is SocketGuildChannel guild && guild.Guild.Id == _settings.GuildId && + (channel.Id == _settings.Recruitment.FeedChannelId || _settings.Recruitment.Forums.Contains(channel.Id) || channel is SocketCategoryChannel)) ScheduleRecovery(); + return Task.CompletedTask; + } + private Task RoleUpdated(SocketRole before, SocketRole after) + { if (after.Guild.Id == _settings.GuildId && !before.Permissions.Equals(after.Permissions)) ScheduleRecovery(); return Task.CompletedTask; } + private Task RoleDeleted(SocketRole role) { if (role.Guild.Id == _settings.GuildId) ScheduleRecovery(); return Task.CompletedTask; } + private Task MemberUpdated(Cacheable before, SocketGuildUser after) + { + if (after.Guild.Id == _settings.GuildId && after.Id == _client?.CurrentUser.Id && + (!before.HasValue || !before.Value.Roles.Select(r => r.Id).SequenceEqual(after.Roles.Select(r => r.Id)))) ScheduleRecovery(); + return Task.CompletedTask; + } + + public bool CanReadStatus(string guild, string actor) => guild == _settings.GuildId.ToString() && + ulong.TryParse(actor, out var id) && _client?.GetGuild(_settings.GuildId)?.GetUser(id) is { } user && + (user.GuildPermissions.Administrator || user.Roles.Any(r => r.Id == _settings.Roles.Moderator)); + + public string QueueRecovery(string guild, string actor, string operation, string target, string reason) + { + if (!CanReadStatus(guild, actor)) return "Moderator access is required in the configured server."; + if (string.IsNullOrWhiteSpace(reason)) return "A reason is required for this change."; + var id = Guid.NewGuid().ToString("N")[..8]; + var queued = Enqueue("job:" + id, async ct => { - if (_engine.State is not null && !_engine.State.Listings.ContainsKey(reply.ThreadId)) + _job = id + " running"; PublishHealth(); + try { - var live = await _discord.GetThreadAsync(reply.ThreadId, ct); - if (live is not null) await _engine.ObserveAsync(live, false, ct); + var result = await _engine.StaffAsync(guild, actor, operation, target, reason, ct); + _job = id + ": " + RecruitmentPresentation.Safe(result, 300); } - _engine.ObserveReply(reply); - }, reply.ThreadId); - return Task.CompletedTask; + catch (Exception ex) + { + _job = id + ": held — " + RecruitmentPresentation.Safe(ex.Message, 250); + throw; + } + }, WorkKind.Recovery); + if (!queued) return "Recruitment is busy or stopped; the job was not queued."; + return $"Queued recruitment job `{id}`. Use /recruitment status to check progress."; } - private Task Disconnected(Exception _) - { EvidenceGap(); return Task.CompletedTask; } - private Task Connected() - { _connectedSince = DateTimeOffset.UtcNow; EvidenceGap(); ScheduleSweep(); return Task.CompletedTask; } public async Task RunAsync(Func> command, string? ownerThread = null) { - if (_worker is null) throw new InvalidOperationException("Recruitment is starting. Try again shortly."); + if (_worker is null || _stop.IsCancellationRequested) throw new InvalidOperationException("Recruitment is starting or stopped."); var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var finished = TrackThread(ownerThread); - var work = new Work(async ct => + var work = new Work(WorkKind.Command, async ct => { try { completion.TrySetResult(await command(_engine, ct)); } catch (Exception ex) { completion.TrySetException(ex); } finally { finished(); } }, () => { finished(); completion.TrySetCanceled(); }); - if (!_queue.Writer.TryWrite(work)) - { finished(); throw new InvalidOperationException("Recruitment is busy. Please try again shortly."); } + if (!_queue.Writer.TryWrite(work)) { finished(); throw new InvalidOperationException("Recruitment is busy. Please try again shortly."); } return await completion.Task; } + private void Subscribe(bool subscribe) + { + if (_client is null || !_settings.Recruitment.Enabled) return; + if (subscribe) + { + _client.ThreadCreated += ThreadCreated; _client.ThreadUpdated += ThreadUpdated; _client.ThreadDeleted += ThreadDeleted; + _client.MessageReceived += MessageReceived; _client.MessageDeleted += MessageDeleted; _client.MessagesBulkDeleted += MessagesDeleted; + _client.Disconnected += Disconnected; _client.Ready += Ready; _client.GuildAvailable += GuildAvailable; _client.GuildUnavailable += GuildUnavailable; + _client.ChannelUpdated += ChannelUpdated; _client.ChannelDestroyed += ChannelDestroyed; + _client.RoleUpdated += RoleUpdated; _client.RoleDeleted += RoleDeleted; _client.GuildMemberUpdated += MemberUpdated; + } + else + { + _client.ThreadCreated -= ThreadCreated; _client.ThreadUpdated -= ThreadUpdated; _client.ThreadDeleted -= ThreadDeleted; + _client.MessageReceived -= MessageReceived; _client.MessageDeleted -= MessageDeleted; _client.MessagesBulkDeleted -= MessagesDeleted; + _client.Disconnected -= Disconnected; _client.Ready -= Ready; _client.GuildAvailable -= GuildAvailable; _client.GuildUnavailable -= GuildUnavailable; + _client.ChannelUpdated -= ChannelUpdated; _client.ChannelDestroyed -= ChannelDestroyed; + _client.RoleUpdated -= RoleUpdated; _client.RoleDeleted -= RoleDeleted; _client.GuildMemberUpdated -= MemberUpdated; + } + } public async Task StopAsync(CancellationToken ct) { - _timer?.Dispose(); - _client.ThreadCreated -= ThreadCreated; - _client.ThreadUpdated -= ThreadUpdated; - _client.ThreadDeleted -= ThreadDeleted; - _client.MessageReceived -= MessageReceived; - _client.Disconnected -= Disconnected; - _client.Connected -= Connected; - _queue.Writer.TryComplete(); - if (_worker is null) return; - try { await _worker.WaitAsync(ct); } - catch (OperationCanceledException) { _stop.Cancel(); await _worker; } + Subscribe(false); _stop.Cancel(); _timer?.Dispose(); _queue.Writer.TryComplete(); + if (_worker is not null) await _worker.WaitAsync(ct); } } diff --git a/DiscordBot/Services/Recruitment/RecruitmentState.cs b/DiscordBot/Services/Recruitment/RecruitmentState.cs index ddf0d5ee..7cf8a85f 100644 --- a/DiscordBot/Services/Recruitment/RecruitmentState.cs +++ b/DiscordBot/Services/Recruitment/RecruitmentState.cs @@ -21,6 +21,8 @@ public sealed class RecruitmentState public DateTimeOffset InitializedAt { get; set; } [JsonProperty(Required = Required.AllowNull)] public DateTimeOffset DiscoveryFloor { get; set; } + public OverviewReceipt Overview { get; set; } = new(); + public RecruitmentFault? RecoveryFault { get; set; } public List SetupAudit { get; set; } = new(); public string? SweepAfterId { get; set; } public DateTimeOffset? LastSweepAt { get; set; } @@ -59,6 +61,7 @@ public sealed class ManagedTag public bool Moderated { get; set; } } +/// A retained attempt, including delivery/action receipts and the evidence needed to enforce policy safely. public sealed class Listing { [JsonProperty(Required = Required.AllowNull)] @@ -97,6 +100,7 @@ public sealed class Listing public bool ResponseCoverageComplete { get; set; } public bool AvailabilityUnknown { get; set; } public bool SuppressUnansweredClose { get; set; } + public RecruitmentFault? Fault { get; set; } public string? Hold { get; set; } public string LastOutcome { get; set; } = "Awaiting Guidelines acknowledgement"; public string LastActor { get; set; } = "system"; @@ -108,6 +112,7 @@ public sealed class Listing public sealed record RecruitmentAudit(DateTimeOffset At, string Actor, string Reason); +/// Informational facts captured once when a listing is first observed; missing data stays unknown. public sealed class InitialFacts { public DateTimeOffset? AccountCreatedAt { get; set; } @@ -118,6 +123,7 @@ public sealed class InitialFacts public bool PriorAcceptedListing { get; set; } } +/// Cross-forum cooldown and timeout history that survives removal of individual listings. public sealed class AuthorHistory { [JsonProperty(Required = Required.AllowNull)] @@ -128,6 +134,7 @@ public sealed class AuthorHistory public bool TimeoutAlerted { get; set; } } +/// Durable mutation intent. CallStarted may represent a successful write whose response was lost. public sealed class RecruitmentAction { [JsonProperty(Required = Required.AllowNull)] @@ -201,3 +208,20 @@ public static void Prune(RecruitmentState state, DateTimeOffset now, int postMon !state.Listings.Values.Any(p => p.OwnerId == id)) state.Authors.Remove(id); } } + +/// The replacement is confirmed before the previous overview is retired; listing audit messages are separate. +public sealed class OverviewReceipt +{ + public string? MessageId { get; set; } + public string? Marker { get; set; } + public long Sequence { get; set; } + public bool SendPending { get; set; } + public DateTimeOffset? SendStartedAt { get; set; } + public string? PendingMarker { get; set; } + public bool PendingReplacement { get; set; } + public string? RetiredMessageId { get; set; } + public string? RetiredMarker { get; set; } + public DateTimeOffset? LastPostedAt { get; set; } + public DateTimeOffset? LastEditedAt { get; set; } + public RecruitmentFault? Fault { get; set; } +} diff --git a/DiscordBot/Services/Recruitment/RecruitmentStateStore.cs b/DiscordBot/Services/Recruitment/RecruitmentStateStore.cs index 1724d819..8ef101f7 100644 --- a/DiscordBot/Services/Recruitment/RecruitmentStateStore.cs +++ b/DiscordBot/Services/Recruitment/RecruitmentStateStore.cs @@ -99,10 +99,16 @@ public static void Validate(RecruitmentState state, string guildId, IReadOnlyDic static bool Id(string? id) => ulong.TryParse(id, out var value) && value != 0; if (state.Schema != RecruitmentState.Format || !Guid.TryParseExact(state.InstanceId, "N", out _) || state.GuildId != guildId || !Id(guildId) || state.InitializedAt == default || state.DiscoveryFloor == default || - state.Forums is null || state.Listings is null || state.Authors is null || + state.Forums is null || state.Listings is null || state.Authors is null || state.Overview is null || state.Forums.Count != 4 || forums.Count != 4 || forums.Values.Distinct().Count() != 4 || forums.Any(f => !Id(f.Value) || !state.Forums.TryGetValue(f.Key, out var saved) || saved.ForumId != f.Value)) throw new InvalidDataException("Recruitment state schema, identity or forum mapping is invalid."); + var overview = state.Overview; + if (overview.Sequence < 0 || + overview.MessageId is not null && (!Id(overview.MessageId) || overview.Marker is null || !overview.Marker.StartsWith($"recruitment:overview:{state.InstanceId}:")) || + overview.RetiredMessageId is not null && (!Id(overview.RetiredMessageId) || overview.RetiredMessageId == overview.MessageId || overview.RetiredMarker is null || !overview.RetiredMarker.StartsWith($"recruitment:overview:{state.InstanceId}:")) || + overview.SendPending && (overview.SendStartedAt is null || overview.PendingMarker is null || !overview.PendingMarker.StartsWith($"recruitment:overview:{state.InstanceId}:"))) + throw new InvalidDataException("Recruitment overview receipt is invalid."); foreach (var f in state.Forums.Values) if (f is null || f.Tags is null || f.ConfirmedTopic is null || f.VisibleCode is null || f.Tags.Values.Any(t => t is null || !Id(t.Id) || string.IsNullOrWhiteSpace(t.Name))) From f2bbef2aa8ec6e66ab17141beae9be9cd14ecac9 Mon Sep 17 00:00:00 2001 From: James Kellie Date: Wed, 14 Oct 2026 21:47:58 +1000 Subject: [PATCH 08/31] Document recruitment recovery and feed status --- docs/recruitment.md | 32 ++++++++++++++++++++++---------- 1 file changed, 22 insertions(+), 10 deletions(-) diff --git a/docs/recruitment.md b/docs/recruitment.md index b2d8522d..2d41b1bc 100644 --- a/docs/recruitment.md +++ b/docs/recruitment.md @@ -10,7 +10,7 @@ Recruitment is disabled in the example, development and production settings. It - At the deadline, an unacknowledged attempt is removed. An acknowledged attempt still blocked by another listing/cooldown is locked and archived. Pins and explicit exemptions protect against automatic actions. - Only **accepted** listings start a cooldown, measured from the thread's original creation time. Closing/deleting on day 2 leaves 28 days; doing so on day 40 adds no wait. An open listing still occupies the slot after day 30, including when Discord naturally archives it. Removing an unaccepted attempt creates no cooldown. - The owner can close the listing or confirm removal through an expiring, single-use control. Accepted cooldowns survive removal. The `Closed` tag also requests lock-and-archive. Existing unrelated applied tags are preserved if there is no room for another tag. -- Accepted listings without a qualifying reply close after 30 days. Replies from other humans count; owner, bot, webhook, configured moderator and administrator replies do not. Recorded qualifying evidence survives message deletion. Incomplete response history holds unanswered closure for staff review. +- Accepted listings without a qualifying reply close after 30 days. Replies from other humans count; owner, bot, webhook, configured moderator and administrator replies do not. Recorded qualifying evidence survives message deletion. Incomplete response history holds unanswered closure for staff review. Discord can emit ThreadCreated when the bot gains access to an existing thread, so that event alone does not certify complete historical reply coverage. - Public replies and owner editing remain available while a listing is open. There is no short edit lock, reply deletion, or parent permission rewrite. Configured recruitment forums and their threads do not earn new XP, including while recruitment is disabled; existing totals are unchanged. ## Configure new forums @@ -37,13 +37,17 @@ Set these fields under `Recruitment` in the existing settings file: Replace zero IDs with the new channels. Do not reuse the old `Channels.Recruitment` or manually configure tag IDs. All four IDs must be distinct forum channels in `GuildId`. `Roles.Moderator` must resolve to the intended role. -The bot needs View Channel, Read Message History, Send Messages in Threads, Manage Threads and Manage Channels on each forum. Attach Files enables the PNG; a definitively rejected image upload falls back to text. In the feed it needs View Channel, Read Message History and Send Messages. Deny View Channel to `@everyone`; explicit access grants are limited to the configured moderator role, administrators and the bot. Existing Guild Members and Message Content intents must also be enabled in the Discord application. +The bot needs View Channel, Read Message History, Send Messages in Threads, Manage Threads and Manage Channels on each forum. Attach Files enables the PNG; a definitively rejected image upload falls back to text. In the feed it needs View Channel, Read Message History and Send Messages. Embed Links enables the status overview embed; without it, the overview uses concise text. Deny View Channel to `@everyone`; explicit access grants are limited to the configured moderator role, administrators and the bot. Existing Guild Members and Message Content intents must also be enabled in the Discord application. Review `DiscordBot/Assets/recruitment/guidelines/*.md` and `tags.json` before enabling. Templates own the **entire native Guidelines** and contain one `{{code}}` plus the duration placeholders. Rendered Guidelines must fit the 1,000-character budget, including the code and configured durations. They point members to the pinned moderator/admin post at the top of the forum for fuller guidance; moderators maintain that post separately. Each tag definition has a stable key, display name, advisory group and closing flag. Keep keys stable when renaming a tag. The one closing tag is unmoderated. The shipped copy/tag groups are editable defaults pending moderator approval. Local build/publish artifacts and Docker include the recruitment assets and bundled font. Use a persistent `ServerRootPath`; production remains one replica with `Recreate` deployment. Do not run two bot processes against the same state directory. -Keep member posting closed during initial setup. After configuring and restarting with `Enabled=true`, check `/recruitment status`, preview each forum, and verify native Guidelines, Require Tag and tag IDs. Unexpected first-use content, direct Guidelines edits, changed managed tag permissions or missing known tags hold processing. `/recruitment repair` explicitly republishes owned setup after review, preserves unrelated tags, and can recreate a deleted managed tag. Discord cannot restore the old ID or its lost applications after a tag is deleted; inspect affected listings when repairing that case. Removing managed definitions is intentionally held for manual review. +Keep member posting closed during initial setup. After configuring and restarting with `Enabled=true`, check `/recruitment status`, preview each forum, and verify native Guidelines, Require Tag and tag IDs. The bot does not create the separate pinned reference post. Unexpected first-use content, direct Guidelines edits, changed managed tag permissions or missing known tags hold processing. `/recruitment repair` explicitly republishes owned setup after review, preserves unrelated tags, and can recreate a deleted managed tag. Discord cannot restore the old ID or its lost applications after a tag is deleted; inspect affected listings when repairing that case. Removing managed definitions is intentionally held for manual review. + +Setup first publishes native Guidelines and selectable tags, verifies their assigned IDs and attributes, and only then enables Require Tag. An interrupted publication resumes from its saved intent. Other forums can finish setup while one is held; acceptance still waits for complete cross-forum inventory. Discord error `40066` means that no member-selectable tags were available for validation. Manage Channels alone does not resolve that tag-state rejection. The staged request path is covered by offline SDK tests; confirm the server's behavior in staging. + +Discord.Net 3.20.1 drops tag moderation during forum serialization and can send `flags:0` for partial updates. The recruitment transport corrects only its scoped tag payloads, and every forum modification explicitly preserves other flags. Unrelated tags, moderation and emojis remain intact. Weekly topic-only rotation omits unchanged tags. The persisted code rotates on Monday at 00:00 UTC. Publication is verified per forum; an interrupted rotation retries the same code. A challenge accepts its issued code and codes subsequently confirmed visible during its window. Codes are not included in advisories, staff entries, previews or diagnostic messages. @@ -55,37 +59,45 @@ All commands are ephemeral and check the configured guild and the caller's curre | Command | Purpose | | --- | --- | -| `/recruitment status` | Availability, setup/storage faults and inventory holds. | +| `/recruitment status` / `help` | Forum links, setup faults, counts, inventory coverage, next work and recovery-job status. Reads a detached snapshot, including during startup or a delayed Discord request. | | `/recruitment lookup id` | Retained thread/author history and creation cooldown. | | `/recruitment preview [forum]` | Proposed Guidelines with a masked code, and managed tag names. | -| `/recruitment repair reason` | Explicitly publish/verify managed setup after previewing it. | +| `/recruitment repair reason [forum]` | Queue publication/verification of all forums or one selected forum after previewing it. | | `/recruitment reconcile reason` | Retry inventory, message delivery and pending actions. | | `/recruitment manage operation thread-id reason` | Exempt/unexempt, reset a pending acknowledgement, reset timeout streak, designate reference, close/reopen, or review uncertain evidence. | | `/recruitment waive-cooldown author reason` | Clear the author's creation cooldown; never grants a second slot. | **Confirm qualifying reply** preserves positive evidence. **Confirm no qualifying reply** certifies missing coverage after staff review and allows the normal unanswered deadline to apply. **Confirm unavailable thread is absent** releases a held slot only when Discord availability is uncertain; it does not infer a deletion actor or change the cooldown. Reopening checks capacity and suppresses repeated automatic unanswered closure for that listing. A closed-tag or later explicit close still applies. +Repair and reconciliation return a short job ID promptly. The worker rechecks moderator authorization when the job runs; `/recruitment status` shows its latest result. Status/help use the gateway's current member/role cache and never wait behind mutation work. Missing authorization evidence denies access. + +The feed also contains one current **Recruitment status** overview, separate from listing audit entries. New feed messages schedule a replacement after 10 seconds of quiet, at most once every five minutes. Continuous activity cannot postpone an eligible replacement by more than 60 seconds. Content-only changes coalesce for five seconds, with at least 30 seconds between edits. The bot confirms and saves a replacement before deleting its own prior overview. Lost responses are recovered by a unique marker; failed cleanup retains the authoritative new message and retries separately. The overview's own traffic does not trigger another move, and a quiet feed generates no dashboard traffic. A cosmetic failure does not block listing acceptance. + The staff feed keeps one entry per attempt, updated with identity, eligibility, pending action, outcome and recent audit reasons. The third consecutive confirmed Guidelines timeout marks that attempt for moderator attention; later timeouts do not repeat the threshold alert until a real acknowledgement resets the streak. Wrong guesses are rate-limited and do not increment it. An independently missing thread or an ambiguous delete response is not counted as a confirmed bot timeout. ## Persistence and recovery State is stored in `{ServerRootPath}/recruitment/`: -- `recruitment-state.json`: schema `udc-recruitment-v2`, string IDs, UTC dates, per-forum receipts/cursors, listing records and author aggregates. +- `recruitment-state.json`: schema `udc-recruitment-v2`, string IDs, UTC dates, per-forum receipts/cursors, listing records, author aggregates, retry holds and overview delivery receipts. - `recruitment-state.json.bak`: last validated snapshot before the latest replacement. - `initialized`: matching store identity, used to detect a missing primary. - `writer.lock`: exclusive process lease. The file may remain after shutdown; its existence does not itself mean the lease is held. Writes are serialized, flushed and replaced atomically. A write failure fences subsequent mutations until storage is repaired and the process restarted. Corrupt, unsupported, incomplete, missing or mismatched history is never silently replaced with empty cooldowns. Back up the directory and its identity receipt together. A completely lost volume cannot be distinguished locally from a new volume; existing nonempty forum setup holds first-use initialization. Restore history before repairing setup after a suspected volume loss. -For storage recovery, stop the bot, preserve the damaged files, restore a validated primary (or review the `.bak`) with its matching `initialized` receipt, then restart and reconcile. A backup may precede the latest acceptance/action; inspect the staff feed and Discord before restoring it. Do not change forum/guild mappings, delete the identity receipt, or clear author history to bypass a fault. No legacy state converter is provided. +For storage recovery, stop the bot, preserve the damaged files, restore a validated primary (or review the `.bak`) with its matching `initialized` receipt, then restart and reconcile. A backup may precede the latest acceptance/action; inspect the staff feed and Discord before restoring it. Do not change forum/guild mappings, delete the identity receipt, or clear author history to bypass a fault. No converter for the old babysitter format is provided. Existing `udc-recruitment-v2` files load with defaults for the new optional operational fields and preserve listing/cooldown history. Once this version saves those fields, older binaries with strict JSON loading can reject the file. Before upgrading, stop the process and preserve a complete copy of the state directory. A rollback requires reviewing/restoring that compatible pre-upgrade snapshot and reconciling any actions since it was taken; the rolling `.bak` is not a permanent pre-upgrade backup. The worker saves intent and staff evidence before destructive calls, then rechecks current identity and protection. Interrupted message sends recover bot-owned messages by marker before retrying. Recovery scans are bounded; a scan that cannot establish completeness holds instead of duplicating a message. An interrupted delete that finds an absent thread records an unknown actor. Archived-message updates save a restoration intent, temporarily unarchive when necessary, and restore archiving after updating the bot's own advisory. [Discord thread rules](https://docs.discord.com/developers/topics/threads) -Startup, disconnects, lost queue events and delivery failures invalidate relevant negative reply evidence and restore full usable windows for pending attempts. Timely queued owner submissions and reply facts are processed before automatic actions; acknowledgement uses the Discord interaction timestamp. Archive discovery resumes bounded pages across sweeps, with at most 50 new identities and 50 retained records processed per pass; incomplete inventory holds acceptance/actions. Retained known records are reconciled directly; terminal details expire after 12 months and inactive author aggregates after 24 months, while open/reference/held/action dependencies remain. The discovery floor prevents expired detail from being recreated by old archive scans. +Recruitment uses a single state writer and a bounded, coalesced event queue. Relevant thread changes, human replies, deleted bot controls (including bulk deletion), permissions and readiness events drive targeted work. Deadlines and cooldown expiry have scheduled wake-ups. Ordinary acknowledgements do not run forum setup or enumerate all forums. A safety reconciliation runs every 15–18 minutes; incomplete inventory continues in bounded batches. Active-thread enumeration uses one guild request per discovery pass; newly found identities fetch their missing facts separately. The transport observes all identities before the SDK filters the response by forum. Initial facts and successful message renders are cached; an audit or invalidation refreshes them. State changes remain durable before external effects, and destructive actions retain fresh identity/protection checks. + +Invalid setup, permission and ownership failures hold until a relevant invalidation or explicit recovery. Transient failures back off with jitter. Recruitment hands actual HTTP 429 retry deadlines back to the scheduler, with conservative channel/guild scope and global handling, and retains the SDK's shared request queue. Known SDK bucket exhaustion also fails promptly rather than sleeping through minutes. Repeated identical fault logs are suppressed; status shows the scope and retry eligibility. + +Startup, disconnects, lost queue events and delivery failures invalidate relevant negative reply evidence and restore full usable windows for pending attempts. Timely queued owner submissions and reply facts are processed before automatic actions; acknowledgement uses the Discord interaction timestamp. Archive discovery resumes bounded pages across sweeps, with at most 50 new identities and 10 retained records processed per audit pass; scheduled listing work handles up to 20 due records at a time; incomplete inventory holds acceptance/actions. Retained known records are reconciled directly; terminal details expire after 12 months and inactive author aggregates after 24 months, while open/reference/held/action dependencies remain. The discovery floor prevents expired detail from being recreated by old archive scans. ## Validation before deployment -The automated tests cover policy, persistence faults, setup/rotation, lost responses, evidence gaps, owner/staff authorization, interaction registration and the real Linux PNG renderer. They do not call live Discord. +The automated tests cover policy, persistence faults, legacy v2 loading, staged setup/rotation, actual installed-SDK payloads, synthetic 235/537-second throttles, idle request budgets, targeted processing, queue loss, dashboard cooldown/recovery, owner/staff authorization, interaction registration and the real Linux PNG renderer. They do not call live Discord. -Before opening posting access, exercise the new forums in staging: effective permissions and feed privacy, native Guidelines/tag repair, acknowledgement and immediate acceptance, duplicate/cooldown behavior, removal confirmation, pinned/reference protection, unanswered/Closed closure, restart recovery, image descriptions and mobile readability. Verify the mounted persistent directory and graceful shutdown. No live bot login, guild command registration, staging mutation, deployment or remote push is part of local implementation validation. +Before opening posting access, exercise the new forums in staging: effective permissions and feed privacy, native Guidelines/tag repair, acknowledgement and immediate acceptance, duplicate/cooldown behavior, removal confirmation, pinned/reference protection, unanswered/Closed closure, restart recovery, image descriptions and mobile readability. Check initially empty forums, an unrelated moderated tag, restart between setup stages, the overview replacement cooldown and a quiet interval without repeated channel reads. Verify the mounted persistent directory and graceful shutdown. No live bot login, guild command registration, staging mutation, deployment or remote push is part of local implementation validation. From 63cde11117b4f9579ce1dcc193df624c57314dd3 Mon Sep 17 00:00:00 2001 From: James Kellie Date: Wed, 14 Oct 2026 22:29:50 +1000 Subject: [PATCH 09/31] Fix recruitment staff exclusion and deletion recovery --- .../Recruitment/RecruitmentFollowUpTests.cs | 83 +++++++++++++++++++ .../Recruitment/DiscordRecruitmentAdapter.cs | 6 +- .../Recruitment/IRecruitmentDiscord.cs | 2 +- .../Services/Recruitment/RecruitmentEngine.cs | 21 ++++- .../RecruitmentEngineScheduling.cs | 23 ++++- .../Recruitment/RecruitmentService.cs | 18 ++-- 6 files changed, 138 insertions(+), 15 deletions(-) create mode 100644 DiscordBot.Tests/Recruitment/RecruitmentFollowUpTests.cs diff --git a/DiscordBot.Tests/Recruitment/RecruitmentFollowUpTests.cs b/DiscordBot.Tests/Recruitment/RecruitmentFollowUpTests.cs new file mode 100644 index 00000000..b632bd9b --- /dev/null +++ b/DiscordBot.Tests/Recruitment/RecruitmentFollowUpTests.cs @@ -0,0 +1,83 @@ +using DiscordBot.Services.Recruitment; + +namespace DiscordBot.Tests.Recruitment; + +public class RecruitmentFollowUpTests +{ + [Theory] + [InlineData(true, false)] + [InlineData(false, true)] + public async Task StaffAndBotPostsNeverEnterMemberWorkflow(bool staff, bool bot) + { + using var rig = new RecruitmentRig(); await rig.Start(); + rig.Discord.Threads["100"] = Thread(rig) with { OwnerIsStaff = staff, OwnerIsBot = bot }; + await rig.Engine.ThreadChangedAsync("100", default); await rig.Sweep(); + Assert.Empty(rig.State.Listings); + Assert.DoesNotContain(rig.Discord.Messages.Keys, k => k.StartsWith("100:")); + Assert.Empty(rig.Discord.Actions); + } + + [Fact] + public async Task ExistingStaffRecordLosesControlsAndSlotWithoutChangingItsContent() + { + using var rig = new RecruitmentRig(); await rig.Start(); var post = await rig.Post(); + rig.Discord.Threads[post.Id] = rig.Discord.Threads[post.Id] with { OwnerIsStaff = true }; + await rig.Engine.ThreadChangedAsync(post.Id, default); + Assert.Equal(ListingStatus.Reference, post.Status); Assert.Null(post.PublicMessageId); + Assert.Null(post.Deadline); Assert.Equal(0, rig.Engine.Health().Open); + Assert.Equal("Budget AUD 500 for a short Unity project", rig.Discord.Threads[post.Id].Starter); + Assert.Empty(rig.Discord.Actions); + } + + [Fact] + public async Task ConfirmedDeletionClearsHeldCountsEvenWhileSetupIsUnavailable() + { + using var rig = new RecruitmentRig(); await rig.Start(); var post = await rig.Post(); await rig.Ack(); + post.Fault = new RecruitmentFault("Locked advisory", null); post.Hold = post.Fault.Message; + post.AvailabilityUnknown = true; + rig.Engine.InvalidateAccess(); rig.Discord.FailPermissions = true; await rig.Sweep(); + rig.Discord.Threads.Remove(post.Id); + await rig.Engine.ThreadDeletedAsync(post.Id, default); + Assert.Equal(ListingStatus.Deleted, post.Status); Assert.Null(post.Fault); Assert.Null(post.Hold); + Assert.False(post.AvailabilityUnknown); Assert.Equal(0, rig.Engine.Health().Open); Assert.Equal(0, rig.Engine.Health().Held); + Assert.NotNull(rig.State.Authors[post.OwnerId].LastAcceptedCreation); + } + + [Fact] + public async Task SafetyAuditFindsDeletedPermanentlyHeldListingWithoutRetryingItsWrite() + { + using var rig = new RecruitmentRig(); await rig.Start(); var post = await rig.Post(); + post.Fault = new RecruitmentFault("Locked advisory", null); post.Hold = post.Fault.Message; + rig.Discord.Threads.Remove(post.Id); + await rig.Engine.SweepAsync(default, retryHeld: false); + Assert.Equal(ListingStatus.Deleted, post.Status); Assert.Null(post.Fault); Assert.Null(post.Hold); + Assert.Empty(rig.Discord.Actions); + } + + [Fact] + public async Task ClosedTagEventClosesOnceInsteadOfCancellingItsOwnAction() + { + using var rig = new RecruitmentRig(); + var service = new RecruitmentService(rig.Settings, rig.Discord, rig.Clock); + try + { + await service.StartAsync(); + var tag = await service.RunAsync((engine, _) => Task.FromResult(engine.State!.Forums["paid-recruiting"].Tags["closed"].Id)); + rig.Discord.Threads["100"] = Thread(rig) with { Tags = new[] { tag } }; + service.NotifyThread("100"); + await service.RunAsync((_, _) => Task.FromResult(true)); + service.NotifyThread("100"); + var post = await service.RunAsync((engine, _) => Task.FromResult(engine.State!.Listings["100"])); + Assert.Equal(ListingStatus.Closed, post.Status); + Assert.Single(rig.Discord.Actions, a => a == ("100", "close")); + Assert.Single(post.Audit, a => a.Reason == "Closed tag applied"); + rig.Discord.Threads.Remove("100"); service.NotifyThreadDeletion("100"); + await service.RunAsync((_, _) => Task.FromResult(true)); + Assert.Equal(ListingStatus.Deleted, post.Status); Assert.Equal(0, service.Health.Open); + } + finally { await service.StopAsync(default); } + } + + private static ThreadSnapshot Thread(RecruitmentRig rig) => new("100", "11", "200", "Reference", rig.Clock.Now, + false, false, false, false, Array.Empty(), "Read the information below", rig.Clock.Now.AddYears(-2), rig.Clock.Now.AddMonths(-3)); +} diff --git a/DiscordBot/Services/Recruitment/DiscordRecruitmentAdapter.cs b/DiscordBot/Services/Recruitment/DiscordRecruitmentAdapter.cs index 3921c03d..57cae79e 100644 --- a/DiscordBot/Services/Recruitment/DiscordRecruitmentAdapter.cs +++ b/DiscordBot/Services/Recruitment/DiscordRecruitmentAdapter.cs @@ -20,7 +20,7 @@ public sealed class DiscordRecruitmentAdapter : IRecruitmentDiscord private readonly Dictionary _initialFacts = new(); private readonly Dictionary _messageChannels = new(); public void InvalidateThread(string threadId) { _threads.Remove(threadId); _messageChannels.Remove(threadId); } - public void InvalidateCache() { _threads.Clear(); _messageChannels.Clear(); _forums.Clear(); } + public void InvalidateCache() { _threads.Clear(); _initialFacts.Clear(); _messageChannels.Clear(); _forums.Clear(); } public DiscordRecruitmentAdapter(DiscordSocketClient client, BotSettings settings, DatabaseService database) => (_client, _settings, _database) = (client, settings, database); private static RequestOptions Options(CancellationToken ct) => RecruitmentRequests.Options(ct); @@ -207,7 +207,9 @@ private async Task SnapshotAsync(RestThreadChannel thread, Cance var starter = await thread.GetMessageAsync(thread.Id, Options(ct)); facts = new ThreadSnapshot(id, thread.ParentChannelId.ToString(), ownerId.ToString(), thread.Name, thread.CreatedAt.ToUniversalTime(), user?.IsBot ?? starter?.Author.IsBot ?? false, false, false, false, - Array.Empty(), starter?.Content, SnowflakeUtils.FromSnowflake(ownerId), user?.JoinedAt); + Array.Empty(), starter?.Content, SnowflakeUtils.FromSnowflake(ownerId), user?.JoinedAt, + user is not null && (user.GuildPermissions.Administrator || user.RoleIds.Contains(_settings.Roles.Moderator)), + user?.RoleIds.Count(r => r != _settings.GuildId)); // Missing starters remain retryable. Bound cached initial facts independently of retained history. if (starter is not null) { diff --git a/DiscordBot/Services/Recruitment/IRecruitmentDiscord.cs b/DiscordBot/Services/Recruitment/IRecruitmentDiscord.cs index b53e0c96..e72f549b 100644 --- a/DiscordBot/Services/Recruitment/IRecruitmentDiscord.cs +++ b/DiscordBot/Services/Recruitment/IRecruitmentDiscord.cs @@ -4,7 +4,7 @@ public sealed record LiveTag(string Id, string Name, bool Moderated, IEmote? Emo public sealed record ForumSnapshot(string Id, string Topic, bool RequireTag, IReadOnlyList Tags, ChannelFlags Flags = ChannelFlags.None); public sealed record ThreadSnapshot(string Id, string ForumId, string OwnerId, string Title, DateTimeOffset CreatedAt, bool OwnerIsBot, bool Pinned, bool Archived, bool Locked, IReadOnlyList Tags, string? Starter, - DateTimeOffset? AccountCreatedAt, DateTimeOffset? JoinedAt); + DateTimeOffset? AccountCreatedAt, DateTimeOffset? JoinedAt, bool OwnerIsStaff = false, int? MemberRoleCount = null); public sealed record ArchiveCursor(DateTimeOffset Since, DateTimeOffset? Before = null, DateTimeOffset? StartedAt = null); public sealed record ThreadInventory(IReadOnlyList Threads, bool Complete, IReadOnlyDictionary? Cursors = null); public sealed record ReplyObservation(string ThreadId, string AuthorId, bool IsBot, bool IsWebhook, bool? IsStaff); diff --git a/DiscordBot/Services/Recruitment/RecruitmentEngine.cs b/DiscordBot/Services/Recruitment/RecruitmentEngine.cs index 552b3221..2b6e30e9 100644 --- a/DiscordBot/Services/Recruitment/RecruitmentEngine.cs +++ b/DiscordBot/Services/Recruitment/RecruitmentEngine.cs @@ -111,7 +111,7 @@ public async Task SweepAsync(CancellationToken ct, bool retryHeld = true) { ct.ThrowIfCancellationRequested(); if (retryHeld) post.Fault = null; - await ProcessSafelyAsync(post, ct); + await ProcessSafelyAsync(post, ct, verifyHeld: true); } if (batch.Length > 0) State.SweepAfterId = batch[^1].Id; RecruitmentPolicy.Prune(State, Now, _settings.PostRetentionMonths, _settings.AuthorRetentionMonths, _settings.CooldownDays); @@ -162,7 +162,7 @@ public async Task ReconcileThreadAsync(string thread, CancellationToken ct) public async Task ObserveAsync(ThreadSnapshot live, bool observedFromCreation, CancellationToken ct) { - if (State is null || !_ready || live.OwnerIsBot || !State.Forums.Values.Any(f => f.ForumId == live.ForumId)) return; + if (State is null || !_ready || live.OwnerIsBot || live.OwnerIsStaff || !State.Forums.Values.Any(f => f.ForumId == live.ForumId)) return; if (State.Listings.ContainsKey(live.Id) || live.CreatedAt < State.DiscoveryFloor) return; var purpose = Purpose(live.ForumId); var post = new Listing @@ -194,6 +194,11 @@ public void ObserveReply(ReplyObservation reply) private async Task ProcessAsync(Listing post, CancellationToken ct) { + if (post.Status == ListingStatus.Deleted) + { + if (post.PublicationPending || post.StaffSendPending) await PublishAsync(post, ct); + return; + } if (post.Status == ListingStatus.Reference) { if (post.PendingAction is not null) await ExecuteActionAsync(post, ct); @@ -207,6 +212,15 @@ private async Task ProcessAsync(Listing post, CancellationToken ct) await PublishStaffAsync(post, ct); post.PublicationPending = false; Save(); return; } + if (live.OwnerIsBot || live.OwnerIsStaff) + { + // Retire records made by older versions without editing staff content or erasing accepted history. + post.Status = ListingStatus.Reference; post.PendingAction = null; post.Deadline = null; + post.Hold = null; post.Fault = null; post.ValidCodes.Clear(); _recoverWindows.Remove(post.Id); + post.LastOutcome = "Staff or bot post; excluded from member recruitment"; post.Version++; Save(); + await BeginActionAsync(post, RecruitmentActionKind.RemoveAdvisory, "system", post.LastOutcome, "", false, ct); + return; + } if (!post.PublicSendPending) post.PublicRestoreArchive = live.Archived && post.AcceptedAt.HasValue; post.Hold = null; if (live.Starter is null && !post.PromptDeliveredAt.HasValue && !post.AcceptedAt.HasValue) @@ -262,7 +276,8 @@ private async Task ProcessAsync(Listing post, CancellationToken ct) private void RecordAbsence(Listing post) { post.Status = ListingStatus.Deleted; post.PendingAction = null; post.UpdatedAt = Now; - post.PublicationPending = true; post.PublicSendPending = false; post.Hold = null; + post.PublicationPending = true; post.PublicSendPending = false; post.Hold = null; post.Fault = null; + post.AvailabilityUnknown = false; post.Deadline = null; _recoverWindows.Remove(post.Id); post.LastOutcome = "Thread absent; deletion actor unknown"; post.Version++; RecruitmentPolicy.Author(State!, post.OwnerId, Now); Save(); diff --git a/DiscordBot/Services/Recruitment/RecruitmentEngineScheduling.cs b/DiscordBot/Services/Recruitment/RecruitmentEngineScheduling.cs index 2d34c61f..e4d44141 100644 --- a/DiscordBot/Services/Recruitment/RecruitmentEngineScheduling.cs +++ b/DiscordBot/Services/Recruitment/RecruitmentEngineScheduling.cs @@ -111,6 +111,14 @@ public async Task ThreadChangedAsync(string id, CancellationToken ct) await ReconcileThreadAsync(id, ct); } + public async Task ThreadDeletedAsync(string id, CancellationToken ct) + { + _discord.InvalidateThread(id); + if (State?.Listings.GetValueOrDefault(id) is not { } post) return; + RecordAbsence(post); + if (_ready) await ReconcileThreadAsync(id, ct); + } + public async Task MessageDeletedAsync(string channel, IReadOnlySet ids, CancellationToken ct) { if (State is null) return; @@ -127,9 +135,20 @@ public async Task MessageDeletedAsync(string channel, IReadOnlySet ids, } } - private async Task ProcessSafelyAsync(Listing post, CancellationToken ct) + private async Task ProcessSafelyAsync(Listing post, CancellationToken ct, bool verifyHeld = false) { - if (post.Fault is { } fault && !fault.IsDue(Now)) return; + if (post.Fault is { } fault && !fault.IsDue(Now)) + { + if (!verifyHeld || fault.RetryAt is not null) return; + // A permanent write hold must not hide a deletion during downtime. Probe once per bounded audit. + try + { + var live = await ReadAsync(post, ct, refresh: true); + if (live is null) RecordAbsence(post); + else if (!live.OwnerIsBot && !live.OwnerIsStaff) return; + } + catch (Exception ex) when (ex is not System.IO.IOException && ex is not OperationCanceledException) { return; } + } var previousFault = post.Fault; post.Fault = null; try diff --git a/DiscordBot/Services/Recruitment/RecruitmentService.cs b/DiscordBot/Services/Recruitment/RecruitmentService.cs index d4f13916..cf9dab87 100644 --- a/DiscordBot/Services/Recruitment/RecruitmentService.cs +++ b/DiscordBot/Services/Recruitment/RecruitmentService.cs @@ -170,8 +170,9 @@ private bool Enqueue(string key, Func run, WorkKind kin { Func? latest; lock (_enqueueGate) _coalesced.Remove(key, out latest); - try { if (latest is not null) await latest(ct); } - finally { finished(); } + // Only work still waiting in the queue fences automatic actions. This event must not fence itself. + finished(); + if (latest is not null) await latest(ct); }, Cancel))) return true; Cancel(); EvidenceGap(); SetTimer(TimeSpan.FromSeconds(1)); @@ -200,12 +201,15 @@ private Task ThreadUpdated(Cacheable before, SocketT } private Task ThreadDeleted(Cacheable thread) { - // An uncached unrelated deletion costs no REST call. Known identities can be checked on the worker. - if (!Volatile.Read(ref _knownThreads).Contains(thread.Id.ToString())) return Task.CompletedTask; - Enqueue("deleted:" + thread.Id, ct => _engine.State?.Listings.ContainsKey(thread.Id.ToString()) == true - ? _engine.ThreadChangedAsync(thread.Id.ToString(), ct) : Task.CompletedTask); + NotifyThreadDeletion(thread.Id.ToString()); return Task.CompletedTask; } + internal void NotifyThreadDeletion(string id) + { + // Discord's delete event proves absence even when setup or a listing write is held. + if (Volatile.Read(ref _knownThreads).Contains(id)) + Enqueue("deleted:" + id, ct => _engine.ThreadDeletedAsync(id, ct), thread: id); + } private bool IsRecruitment(SocketThreadChannel thread) => thread.Guild.Id == _settings.GuildId && _settings.Recruitment.Forums.Contains(thread.ParentChannel.Id); private Task MessageReceived(SocketMessage message) { @@ -319,9 +323,9 @@ public async Task RunAsync(Func { + finished(); try { completion.TrySetResult(await command(_engine, ct)); } catch (Exception ex) { completion.TrySetException(ex); } - finally { finished(); } }, () => { finished(); completion.TrySetCanceled(); }); if (!_queue.Writer.TryWrite(work)) { finished(); throw new InvalidOperationException("Recruitment is busy. Please try again shortly."); } return await completion.Task; From 70732cf201bdf9a8f6aee5ce6022735c09a02feb Mon Sep 17 00:00:00 2001 From: James Kellie Date: Wed, 14 Oct 2026 22:37:35 +1000 Subject: [PATCH 10/31] Present recruitment checks in purpose-specific embeds --- .../RecruitmentPresentationTests.cs | 69 +++++++++++ .../Recruitment/RecruitmentRecoveryTests.cs | 2 +- .../RecruitmentStorageAndSetupTests.cs | 15 +-- .../Recruitment/RecruitmentTestRig.cs | 4 +- .../Recruitment/RecruitmentTransportTests.cs | 2 +- .../Recruitment/DiscordRecruitmentAdapter.cs | 47 ++------ .../Recruitment/IRecruitmentDiscord.cs | 8 +- .../Recruitment/RecruitmentContent.cs | 39 +++++++ .../Services/Recruitment/RecruitmentEngine.cs | 24 ++-- .../RecruitmentEngineScheduling.cs | 13 +++ .../Recruitment/RecruitmentPresentation.cs | 110 +++++++++++------- .../Recruitment/RecruitmentService.cs | 22 +++- .../Services/Recruitment/RecruitmentState.cs | 6 +- 13 files changed, 245 insertions(+), 116 deletions(-) create mode 100644 DiscordBot.Tests/Recruitment/RecruitmentPresentationTests.cs diff --git a/DiscordBot.Tests/Recruitment/RecruitmentPresentationTests.cs b/DiscordBot.Tests/Recruitment/RecruitmentPresentationTests.cs new file mode 100644 index 00000000..7c1351c3 --- /dev/null +++ b/DiscordBot.Tests/Recruitment/RecruitmentPresentationTests.cs @@ -0,0 +1,69 @@ +using DiscordBot.Services.Recruitment; + +namespace DiscordBot.Tests.Recruitment; + +public class RecruitmentPresentationTests +{ + [Theory] + [InlineData("DM me https://discord.gg/work", "no work-sample link found")] + [InlineData("https://github.com/person/project", "work-sample link detected")] + [InlineData("Portfolio: https://personal.example/my-work", "work-sample link detected")] + [InlineData("https://artstation.com/artist", "work-sample link detected")] + [InlineData("https://github.com", "external link found; purpose unclear")] + [InlineData("https://github.com.example/artist", "external link found; purpose unclear")] + [InlineData("Apply here: https://example.org/jobs", "external link found; purpose unclear")] + [InlineData(null, "unknown")] + public void WorkSampleDetectionDistinguishesInvitesAndUnlabelledLinks(string? content, string expected) => + Assert.Equal(expected, RecruitmentContent.Portfolio(content)); + + [Fact] + public async Task ForHireEmbedHighlightsMissingEvidenceWithoutImplyingEndorsement() + { + using var rig = new RecruitmentRig(); await rig.Start(); var post = await rig.Post(forum: "12"); + post.Facts.AccountCreatedAt = post.CreatedAt.AddDays(-2); post.Facts.JoinedAt = post.CreatedAt; + post.Facts.MemberRoleCount = 0; post.Facts.Payment = RecruitmentContent.Payment("DM for rates"); + var message = RecruitmentPresentation.Public(rig.State, post, rig.Settings.Recruitment, rig.Clock.Now); + var embed = Assert.IsType(message.Embed); + Assert.Equal(Discord.Color.Orange, embed.Color); + Assert.Equal(5, embed.Fields.Count(f => f.Name.StartsWith("⚠"))); + Assert.Contains("2 days at posting", embed.Fields[0].Value); + Assert.Contains("Clarify scope", embed.Fields.Last().Value); + Assert.DoesNotContain("Initial snapshot", message.TextFallback); + Assert.DoesNotContain("Community activity", message.TextFallback); + Assert.DoesNotContain("Protected from", message.TextFallback); + Assert.Contains("do not verify identity", embed.Footer!.Value.Text); + Assert.True(message.TextFallback!.Length < 2000); + } + + [Fact] + public async Task HobbyRecruitingDoesNotDemandPaymentOrAForHirePortfolio() + { + using var rig = new RecruitmentRig(); await rig.Start(); var post = await rig.Post(forum: "13"); + post.Facts.MemberRoleCount = 2; post.Facts.Payment = RecruitmentContent.Payment("Unpaid volunteer project"); + var embed = RecruitmentPresentation.Public(rig.State, post, rig.Settings.Recruitment, rig.Clock.Now).Embed!; + Assert.DoesNotContain(embed.Fields, f => f.Name.StartsWith("⚠") || f.Name.Contains("Portfolio")); + Assert.Equal(Discord.Color.Blue, embed.Color); + post.Facts.Payment = RecruitmentContent.Payment("Revenue share"); + Assert.Contains(RecruitmentPresentation.Public(rig.State, post, rig.Settings.Recruitment, rig.Clock.Now).Embed!.Fields, + f => f.Name == "⚠ Payment" && f.Value.Contains("no guaranteed payment")); + } + + [Fact] + public async Task StarterAndRoleCorrectionsRefreshEmbedFieldsWithoutScanningForumsOrDuplicatingControls() + { + using var rig = new RecruitmentRig(); await rig.Start(); var post = await rig.Post(forum: "12"); await rig.Ack(); + var messageId = post.PublicMessageId; var inventories = rig.Discord.Inventories; + rig.Discord.Threads[post.Id] = rig.Discord.Threads[post.Id] with { Starter = "Portfolio https://example.org/works. Rates USD 50/hour", MemberRoleCount = 3 }; + await rig.Engine.FactsChangedAsync(post.Id, default); + var message = rig.Discord.Messages["100:recruitment:100:public"].Message; + Assert.Contains(message.Embed!.Fields, f => f.Name == "Portfolio / work samples"); + Assert.Contains(message.Embed.Fields, f => f.Name == "Server roles" && f.Value == "3 assigned"); + Assert.Equal(messageId, post.PublicMessageId); Assert.Equal(inventories, rig.Discord.Inventories); + var writes = rig.Discord.Upserts; + await rig.Engine.FactsChangedAsync(post.Id, default); + Assert.Equal(writes, rig.Discord.Upserts); + rig.Discord.Threads[post.Id] = rig.Discord.Threads[post.Id] with { MemberRoleCount = 0 }; + await rig.Engine.MemberChangedAsync(post.OwnerId, default); + Assert.Contains(rig.Discord.Messages["100:recruitment:100:public"].Message.Embed!.Fields, f => f.Name == "⚠ Server roles"); + } +} diff --git a/DiscordBot.Tests/Recruitment/RecruitmentRecoveryTests.cs b/DiscordBot.Tests/Recruitment/RecruitmentRecoveryTests.cs index 52a41263..a6142e0c 100644 --- a/DiscordBot.Tests/Recruitment/RecruitmentRecoveryTests.cs +++ b/DiscordBot.Tests/Recruitment/RecruitmentRecoveryTests.cs @@ -14,7 +14,7 @@ public async Task InteractionModuleRegistersWithoutConnectingOrStartingTheFeatur { using var client = new DiscordSocketClient(); using var interactions = new InteractionService(client); - var service = new RecruitmentService(client, new BotSettings(), null!); + var service = new RecruitmentService(client, new BotSettings()); using var provider = new ServiceCollection().AddSingleton(service).BuildServiceProvider(); var module = await interactions.AddModuleAsync(provider); Assert.Equal(8, module.SlashCommands.Count); diff --git a/DiscordBot.Tests/Recruitment/RecruitmentStorageAndSetupTests.cs b/DiscordBot.Tests/Recruitment/RecruitmentStorageAndSetupTests.cs index 3ec3a3db..517eb05e 100644 --- a/DiscordBot.Tests/Recruitment/RecruitmentStorageAndSetupTests.cs +++ b/DiscordBot.Tests/Recruitment/RecruitmentStorageAndSetupTests.cs @@ -1,5 +1,4 @@ using DiscordBot.Services.Recruitment; -using ImageMagick; using Newtonsoft.Json.Linq; namespace DiscordBot.Tests.Recruitment; @@ -248,22 +247,10 @@ public async Task DisabledServiceDoesNotCreateStateOrPublish_ButForumsStillExclu [InlineData("USD 2000", "concrete rate or budget stated")] [InlineData("$35/hour", "concrete rate or budget stated")] [InlineData("150 EUR per day", "concrete rate or budget stated")] - [InlineData("Revenue-share", "ambiguous / discuss payment terms")] + [InlineData("Revenue-share", "revenue-share stated")] [InlineData("Competitive rates", "ambiguous / discuss payment terms")] [InlineData("A Unity hobby project", "no concrete rate or budget found")] [InlineData(null, "unknown")] public void PaymentIsNeutralAndAdvisory(string? text, string expected) => Assert.Equal(expected, RecruitmentContent.Payment(text)); - [Fact] - public async Task BannerUsesPackagedFontAndBoundedImage_WithEssentialTextFallback() - { - using var rig = new RecruitmentRig(); await rig.Start(); var post = await rig.Post(); - var png = RecruitmentPresentation.RenderBanner(post, rig.Settings.AssetsRootPath); - Assert.NotNull(png); Assert.InRange(png.Length, 100, 512 * 1024); - using var image = new MagickImage(png); Assert.Equal(900u, image.Width); Assert.Equal(270u, image.Height); - Assert.Null(RecruitmentPresentation.RenderBanner(post, "/nonexistent/udc-assets")); - var fallback = RecruitmentPresentation.Public(rig.State, post, rig.Settings.Recruitment, rig.Clock.Now); - Assert.Contains("Community activity: unknown", fallback.Content); Assert.Contains("Initial snapshot", fallback.Content); - File.WriteAllBytes(Path.Combine(Path.GetTempPath(), "udc-recruitment-preview.png"), png); - } } diff --git a/DiscordBot.Tests/Recruitment/RecruitmentTestRig.cs b/DiscordBot.Tests/Recruitment/RecruitmentTestRig.cs index 8864e57f..c4fcd488 100644 --- a/DiscordBot.Tests/Recruitment/RecruitmentTestRig.cs +++ b/DiscordBot.Tests/Recruitment/RecruitmentTestRig.cs @@ -151,8 +151,8 @@ public Task PublishForumAsync(ForumSnapshot expected, string? top { ThreadReads++; return UnknownThreads.Contains(id) ? Task.FromException(new InvalidOperationException("Forbidden")) : Task.FromResult(Threads.GetValueOrDefault(id)); } public void InvalidateThread(string id) { } public void InvalidateCache() { } + public void InvalidateFacts(string id) { } public Task HasReplyAsync(ThreadSnapshot thread, CancellationToken ct) { ReplyReads++; return Task.FromResult(Replies.Contains(thread.Id)); } - public Task GetActivityAsync(string userId, CancellationToken ct) => Task.FromResult("unknown"); public Task UpsertAsync(string channelId, string? messageId, bool recovering, DateTimeOffset since, RecruitmentMessage message, Action beforeWrite, CancellationToken ct) { Upserts++; @@ -161,7 +161,7 @@ public Task UpsertAsync(string channelId, string? messageId, bool reco var key = channelId + ":" + message.Marker; if (Messages.TryGetValue(key, out var existing)) { - if (existing.Message.Content != message.Content || existing.Message.Embed?.Description != message.Embed?.Description) beforeWrite(); + if (existing.Message.RenderKey != message.RenderKey) beforeWrite(); Messages[key] = (existing.Id, message); return Task.FromResult(new Delivery(existing.Id, false)); } beforeWrite(); diff --git a/DiscordBot.Tests/Recruitment/RecruitmentTransportTests.cs b/DiscordBot.Tests/Recruitment/RecruitmentTransportTests.cs index b97e163f..e347ef24 100644 --- a/DiscordBot.Tests/Recruitment/RecruitmentTransportTests.cs +++ b/DiscordBot.Tests/Recruitment/RecruitmentTransportTests.cs @@ -58,7 +58,7 @@ public async Task InventoryReusesForumsAndFetchesGuildActiveThreadsOnce() await client.LoginAsync(TokenType.Bot, "offline-placeholder-token", validateToken: false); var settings = new BotSettings { GuildId = 1, Recruitment = new RecruitmentSettings { Forums = new RecruitmentForums { PaidRecruitingId = 11, PaidForHireId = 12, HobbyRecruitingId = 13, HobbyForHireId = 14 } } }; - var adapter = new DiscordRecruitmentAdapter(client, settings, null!); + var adapter = new DiscordRecruitmentAdapter(client, settings); ForumSnapshot? observed = null; foreach (var id in settings.Recruitment.Forums.Mapping().Values) { diff --git a/DiscordBot/Services/Recruitment/DiscordRecruitmentAdapter.cs b/DiscordBot/Services/Recruitment/DiscordRecruitmentAdapter.cs index 57cae79e..92e0949f 100644 --- a/DiscordBot/Services/Recruitment/DiscordRecruitmentAdapter.cs +++ b/DiscordBot/Services/Recruitment/DiscordRecruitmentAdapter.cs @@ -1,6 +1,5 @@ using System.IO; using System.Collections.Concurrent; -using System.Net; using Discord.Net; using Discord.Rest; using Discord.WebSocket; @@ -13,16 +12,16 @@ public sealed class DiscordRecruitmentAdapter : IRecruitmentDiscord { private readonly DiscordSocketClient _client; private readonly BotSettings _settings; - private readonly DatabaseService _database; private readonly Dictionary _forums = new(); private readonly ConcurrentDictionary _expectedForums = new(); private readonly Dictionary _threads = new(); private readonly Dictionary _initialFacts = new(); private readonly Dictionary _messageChannels = new(); public void InvalidateThread(string threadId) { _threads.Remove(threadId); _messageChannels.Remove(threadId); } + public void InvalidateFacts(string threadId) { _initialFacts.Remove(threadId); InvalidateThread(threadId); } public void InvalidateCache() { _threads.Clear(); _initialFacts.Clear(); _messageChannels.Clear(); _forums.Clear(); } - public DiscordRecruitmentAdapter(DiscordSocketClient client, BotSettings settings, DatabaseService database) - => (_client, _settings, _database) = (client, settings, database); + public DiscordRecruitmentAdapter(DiscordSocketClient client, BotSettings settings) + => (_client, _settings) = (client, settings); private static RequestOptions Options(CancellationToken ct) => RecruitmentRequests.Options(ct); private SocketGuild Guild => _client.GetGuild(_settings.GuildId) ?? throw new InvalidOperationException("Configured guild is unavailable."); @@ -240,20 +239,6 @@ private async Task SnapshotAsync(RestThreadChannel thread, Cance return unknown ? null : false; } - public async Task GetActivityAsync(string userId, CancellationToken ct) - { - try - { - var query = _database.Query; - if (query is null) return "unknown"; - var user = await query.GetUser(userId).WaitAsync(TimeSpan.FromSeconds(5), ct); - if (user is null) return "unknown"; - return user.Exp > 0 || user.Karma > 0 || user.KarmaGiven > 0 ? "recorded" : "none recorded"; - } - catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; } - catch { return "unknown"; } - } - private async Task MessageChannelAsync(string id, CancellationToken ct) { if (_messageChannels.TryGetValue(id, out var cached)) return cached; @@ -267,6 +252,9 @@ private async Task MessageChannelAsync(string id, CancellationT public async Task UpsertAsync(string channelId, string? messageId, bool recovering, DateTimeOffset since, RecruitmentMessage message, Action beforeWrite, CancellationToken ct) { var channel = await MessageChannelAsync(channelId, ct); + if (message.Embed is not null && message.TextFallback is not null && channel is RestThreadChannel listing && + !Guild.CurrentUser.GetPermissions(Guild.GetChannel(listing.ParentChannelId)).EmbedLinks) + message = message with { Content = message.TextFallback, Embed = null }; IUserMessage? existing = null; if (messageId is not null) existing = await channel.GetMessageAsync(ulong.Parse(messageId), CacheMode.AllowDownload, Options(ct)) as IUserMessage; if (existing is not null && (existing.Author.Id != _client.CurrentUser.Id || !existing.Content.Contains(message.Marker))) @@ -293,8 +281,8 @@ public async Task UpsertAsync(string channelId, string? messageId, boo if (existing is not null) { var unarchivedForEdit = false; - var edit = existing.Content != message.Content || existing.Embeds.FirstOrDefault()?.Description != message.Embed?.Description || - existing.Embeds.FirstOrDefault()?.Title != message.Embed?.Title || existing.Components.Count == 0 && message.OwnerControls || existing.Components.Count > 0 && !message.OwnerControls; + var edit = existing.Content != message.Content || RecruitmentMessage.EmbedKey(existing.Embeds.FirstOrDefault()) != RecruitmentMessage.EmbedKey(message.Embed) || + existing.Embeds.Count > (message.Embed is null ? 0 : 1) || existing.Attachments.Count > 0 || existing.Components.Count == 0 && message.OwnerControls || existing.Components.Count > 0 && !message.OwnerControls; if (edit) { beforeWrite(); @@ -304,7 +292,7 @@ public async Task UpsertAsync(string channelId, string? messageId, boo await archived.ModifyAsync(p => p.Archived = false, Options(ct)); unarchivedForEdit = true; } - await existing.ModifyAsync(p => { p.Content = message.Content; p.Components = components; p.Embeds = message.Embed is null ? Array.Empty() : new[] { message.Embed }; p.AllowedMentions = AllowedMentions.None; }, Options(ct)); + await existing.ModifyAsync(p => { p.Content = message.Content; p.Components = components; p.Attachments = Array.Empty(); p.Embeds = message.Embed is null ? Array.Empty() : new[] { message.Embed }; p.AllowedMentions = AllowedMentions.None; }, Options(ct)); } // The caller persists this restoration intent before publication, so a restart can finish it. if (message.RestoreArchive && channel is IThreadChannel thread && (unarchivedForEdit || !thread.IsArchived)) @@ -320,22 +308,7 @@ public async Task UpsertAsync(string channelId, string? messageId, boo if (archivedThread.IsLocked && !message.RestoreArchive) throw new InvalidOperationException("A locked thread needs moderator review before receiving a challenge."); await archivedThread.ModifyAsync(p => p.Archived = false, Options(ct)); } - IUserMessage sent; - if (message.Image is not null) - { - using var stream = new MemoryStream(message.Image, false); - try - { - sent = await channel.SendFileAsync(new FileAttachment(stream, "recruitment.png", message.ImageDescription), message.Content, - options: Options(ct), allowedMentions: AllowedMentions.None, components: components, embed: message.Embed); - } - catch (HttpException ex) when (ex.HttpCode == HttpStatusCode.BadRequest || ex.DiscordCode == DiscordErrorCode.MissingPermissions) - { - // These are definitive rejections, so a text fallback cannot duplicate an ambiguous successful send. - sent = await channel.SendMessageAsync(message.Content, options: Options(ct), allowedMentions: AllowedMentions.None, components: components, embed: message.Embed); - } - } - else sent = await channel.SendMessageAsync(message.Content, options: Options(ct), allowedMentions: AllowedMentions.None, components: components, embed: message.Embed); + var sent = await channel.SendMessageAsync(message.Content, options: Options(ct), allowedMentions: AllowedMentions.None, components: components, embed: message.Embed); if (message.RestoreArchive && channel is IThreadChannel restoreThread) await restoreThread.ModifyAsync(p => p.Archived = true, Options(ct)); return new Delivery(sent.Id.ToString(), true); diff --git a/DiscordBot/Services/Recruitment/IRecruitmentDiscord.cs b/DiscordBot/Services/Recruitment/IRecruitmentDiscord.cs index e72f549b..04d674e7 100644 --- a/DiscordBot/Services/Recruitment/IRecruitmentDiscord.cs +++ b/DiscordBot/Services/Recruitment/IRecruitmentDiscord.cs @@ -8,7 +8,11 @@ public sealed record ThreadSnapshot(string Id, string ForumId, string OwnerId, s public sealed record ArchiveCursor(DateTimeOffset Since, DateTimeOffset? Before = null, DateTimeOffset? StartedAt = null); public sealed record ThreadInventory(IReadOnlyList Threads, bool Complete, IReadOnlyDictionary? Cursors = null); public sealed record ReplyObservation(string ThreadId, string AuthorId, bool IsBot, bool IsWebhook, bool? IsStaff); -public sealed record RecruitmentMessage(string Content, string Marker, bool OwnerControls, byte[]? Image = null, string? ImageDescription = null, bool RestoreArchive = false, Embed? Embed = null); +public sealed record RecruitmentMessage(string Content, string Marker, bool OwnerControls, bool RestoreArchive = false, Embed? Embed = null, string? TextFallback = null) +{ + public string RenderKey => Content + "\n" + OwnerControls + "\n" + EmbedKey(Embed); + public static string EmbedKey(IEmbed? embed) => embed is null ? "" : Newtonsoft.Json.JsonConvert.SerializeObject(embed.ToEmbedBuilder().Build()); +} public sealed record Delivery(string Id, bool NewlySent); /// Null thread means confirmed Discord Unknown Channel, never a cache miss or permission failure. @@ -24,9 +28,9 @@ public interface IRecruitmentDiscord Task InventoryAsync(DateTimeOffset floor, IReadOnlySet knownIds, IReadOnlyDictionary cursors, CancellationToken ct); Task GetThreadAsync(string threadId, CancellationToken ct, bool refresh = false); void InvalidateThread(string threadId); + void InvalidateFacts(string threadId); void InvalidateCache(); Task HasReplyAsync(ThreadSnapshot thread, CancellationToken ct); - Task GetActivityAsync(string userId, CancellationToken ct); // Ambiguous sends must search the entire bounded recovery interval or hold; never blindly resend. Task UpsertAsync(string channelId, string? messageId, bool recovering, DateTimeOffset since, RecruitmentMessage message, Action beforeWrite, CancellationToken ct); diff --git a/DiscordBot/Services/Recruitment/RecruitmentContent.cs b/DiscordBot/Services/Recruitment/RecruitmentContent.cs index 047ad495..412e6644 100644 --- a/DiscordBot/Services/Recruitment/RecruitmentContent.cs +++ b/DiscordBot/Services/Recruitment/RecruitmentContent.cs @@ -12,14 +12,53 @@ public static string Payment(string? content) try { if (Concrete().IsMatch(text)) return "concrete rate or budget stated"; + if (RevenueShare().IsMatch(text)) return "revenue-share stated"; + if (Unpaid().IsMatch(text)) return "unpaid collaboration stated"; if (Ambiguous().IsMatch(text)) return "ambiguous / discuss payment terms"; return "no concrete rate or budget found"; } catch (RegexMatchTimeoutException) { return "unknown"; } } + // A link is evidence of a reference to work, never evidence that the author made it. No URLs are fetched. + public static string Portfolio(string? content) + { + if (content is null) return "unknown"; + var text = content[..Math.Min(content.Length, 8000)]; + var external = false; + try + { + foreach (Match match in Links().Matches(text)) + { + if (!Uri.TryCreate(match.Value.TrimEnd('.', ',', ';', '!', '?'), UriKind.Absolute, out var uri) || + uri.Scheme is not ("https" or "http") || uri.HostNameType != UriHostNameType.Dns) continue; + var host = uri.IdnHost; + static bool Domain(string host, string domain) => host.Equals(domain, StringComparison.OrdinalIgnoreCase) || host.EndsWith("." + domain, StringComparison.OrdinalIgnoreCase); + if (Domain(host, "discord.com") || Domain(host, "discord.gg") || Domain(host, "discordapp.com")) continue; + external = true; + var workHost = new[] { "github.com", "gitlab.com", "artstation.com", "behance.net", "dribbble.com", "itch.io", "vimeo.com", "youtube.com", "youtu.be", "deviantart.com" } + .Any(domain => Domain(host, domain)); + var lineStart = text.LastIndexOf('\n', Math.Max(0, match.Index - 1)) + 1; + var lineEnd = text.IndexOf('\n', match.Index); + if (lineEnd < 0) lineEnd = text.Length; + if (workHost && uri.AbsolutePath.Length > 1 || WorkLabel().IsMatch(text[lineStart..lineEnd])) + return "work-sample link detected"; + } + return external ? "external link found; purpose unclear" : "no work-sample link found"; + } + catch (RegexMatchTimeoutException) { return "unknown"; } + } + [GeneratedRegex(@"(?:[$€£¥]\s*\d+(?:[.,]\d+)*(?:\s*[kK])?|\b\d+(?:[.,]\d+)*\s*(?:USD|AUD|CAD|EUR|GBP|JPY)\b|\b(?:USD|AUD|CAD|EUR|GBP|JPY)\s*\d+)", RegexOptions.IgnoreCase, 100)] private static partial Regex Concrete(); [GeneratedRegex(@"\b(?:rev(?:enue)?[ -]?share|royalt(?:y|ies)|negotiable|competitive|paid|budget|payment|rates?|profit[ -]?share)\b", RegexOptions.IgnoreCase, 100)] private static partial Regex Ambiguous(); + [GeneratedRegex(@"\b(?:rev(?:enue)?[ -]?share|royalt(?:y|ies)|profit[ -]?share)\b", RegexOptions.IgnoreCase, 100)] + private static partial Regex RevenueShare(); + [GeneratedRegex(@"\b(?:unpaid|volunteer|no (?:pay|payment|compensation))\b", RegexOptions.IgnoreCase, 100)] + private static partial Regex Unpaid(); + [GeneratedRegex(@"https?://[^\s<>\[\]()]+", RegexOptions.IgnoreCase, 100)] + private static partial Regex Links(); + [GeneratedRegex(@"\b(?:portfolio|showreel|demo|work[ -]?samples?|examples? of (?:my |our )?work|previous work|proof of work)\b", RegexOptions.IgnoreCase, 100)] + private static partial Regex WorkLabel(); } diff --git a/DiscordBot/Services/Recruitment/RecruitmentEngine.cs b/DiscordBot/Services/Recruitment/RecruitmentEngine.cs index 2b6e30e9..8ebf0c1c 100644 --- a/DiscordBot/Services/Recruitment/RecruitmentEngine.cs +++ b/DiscordBot/Services/Recruitment/RecruitmentEngine.cs @@ -111,6 +111,7 @@ public async Task SweepAsync(CancellationToken ct, bool retryHeld = true) { ct.ThrowIfCancellationRequested(); if (retryHeld) post.Fault = null; + _discord.InvalidateFacts(post.Id); await ProcessSafelyAsync(post, ct, verifyHeld: true); } if (batch.Length > 0) State.SweepAfterId = batch[^1].Id; @@ -160,10 +161,10 @@ public async Task ReconcileThreadAsync(string thread, CancellationToken ct) await ProcessSafelyAsync(waiting, ct); } - public async Task ObserveAsync(ThreadSnapshot live, bool observedFromCreation, CancellationToken ct) + public Task ObserveAsync(ThreadSnapshot live, bool observedFromCreation, CancellationToken ct) { - if (State is null || !_ready || live.OwnerIsBot || live.OwnerIsStaff || !State.Forums.Values.Any(f => f.ForumId == live.ForumId)) return; - if (State.Listings.ContainsKey(live.Id) || live.CreatedAt < State.DiscoveryFloor) return; + if (State is null || !_ready || live.OwnerIsBot || live.OwnerIsStaff || !State.Forums.Values.Any(f => f.ForumId == live.ForumId)) return Task.CompletedTask; + if (State.Listings.ContainsKey(live.Id) || live.CreatedAt < State.DiscoveryFloor) return Task.CompletedTask; var purpose = Purpose(live.ForumId); var post = new Listing { @@ -173,7 +174,8 @@ public async Task ObserveAsync(ThreadSnapshot live, bool observedFromCreation, C Facts = new InitialFacts { AccountCreatedAt = live.AccountCreatedAt, JoinedAt = live.JoinedAt, - Activity = await _discord.GetActivityAsync(live.OwnerId, ct), Payment = RecruitmentContent.Payment(live.Starter), + Payment = RecruitmentContent.Payment(live.Starter), MemberRoleCount = live.MemberRoleCount, + Portfolio = RecruitmentContent.Portfolio(live.Starter), TagAdvice = _assets!.TagAdvice(purpose, live.Tags, State.Forums[purpose]), PriorAcceptedListing = State.Authors.GetValueOrDefault(live.OwnerId)?.LastAcceptedCreation is not null } @@ -181,6 +183,7 @@ public async Task ObserveAsync(ThreadSnapshot live, bool observedFromCreation, C State.Listings[post.Id] = post; RecruitmentPolicy.Author(State, post.OwnerId, Now); Save(); + return Task.CompletedTask; } public void ObserveReply(ReplyObservation reply) @@ -221,6 +224,12 @@ private async Task ProcessAsync(Listing post, CancellationToken ct) await BeginActionAsync(post, RecruitmentActionKind.RemoveAdvisory, "system", post.LastOutcome, "", false, ct); return; } + post.Title = RecruitmentPresentation.Safe(live.Title); + post.Facts.AccountCreatedAt = live.AccountCreatedAt; post.Facts.JoinedAt = live.JoinedAt; + post.Facts.MemberRoleCount = live.MemberRoleCount; + post.Facts.Payment = RecruitmentContent.Payment(live.Starter); + post.Facts.Portfolio = RecruitmentContent.Portfolio(live.Starter); + post.Facts.TagAdvice = _assets!.TagAdvice(Purpose(post.ForumId), live.Tags, State!.Forums[Purpose(post.ForumId)]); if (!post.PublicSendPending) post.PublicRestoreArchive = live.Archived && post.AcceptedAt.HasValue; post.Hold = null; if (live.Starter is null && !post.PromptDeliveredAt.HasValue && !post.AcceptedAt.HasValue) @@ -340,11 +349,10 @@ private async Task PublishAsync(Listing post, CancellationToken ct) if (post.Status is ListingStatus.Deleted or ListingStatus.Reference) { post.PublicationPending = false; post.PublicSendPending = false; Save(); return; } var message = RecruitmentPresentation.Public(State!, post, _settings, Now); - if (!post.PublicSendPending && !post.PublicationPending && post.PublicMessageId is not null && _publicRenders.GetValueOrDefault(post.Id) == message.Content) return; + if (!post.PublicSendPending && !post.PublicationPending && post.PublicMessageId is not null && _publicRenders.GetValueOrDefault(post.Id) == message.RenderKey) return; var recovering = post.PublicSendPending; - var image = post.PublicMessageId is null ? RecruitmentPresentation.RenderBanner(post, _bot.AssetsRootPath) : null; var delivery = await _discord.UpsertAsync(post.Id, post.PublicMessageId, recovering, post.PublicSendStartedAt ?? post.CreatedAt, - RecruitmentPresentation.Public(State!, post, _settings, Now, image) with + message with { RestoreArchive = post.PublicRestoreArchive || post.Status == ListingStatus.Closed }, () => { if (!recovering) post.PublicSendStartedAt = Now; @@ -352,7 +360,7 @@ private async Task PublishAsync(Listing post, CancellationToken ct) }, ct); var changed = post.PublicMessageId != delivery.Id || post.PublicSendPending || post.PublicationPending; post.PublicMessageId = delivery.Id; post.PublicSendPending = false; post.PublicationPending = false; - _publicRenders[post.Id] = message.Content; + _publicRenders[post.Id] = message.RenderKey; if (delivery.NewlySent && post.Deadline.HasValue && !post.AcceptedAt.HasValue) { post.PromptDeliveredAt = null; post.Deadline = null; post.Version++; changed = true; } if (changed) Save(); diff --git a/DiscordBot/Services/Recruitment/RecruitmentEngineScheduling.cs b/DiscordBot/Services/Recruitment/RecruitmentEngineScheduling.cs index e4d44141..cc2638b2 100644 --- a/DiscordBot/Services/Recruitment/RecruitmentEngineScheduling.cs +++ b/DiscordBot/Services/Recruitment/RecruitmentEngineScheduling.cs @@ -119,6 +119,19 @@ public async Task ThreadDeletedAsync(string id, CancellationToken ct) if (_ready) await ReconcileThreadAsync(id, ct); } + public Task FactsChangedAsync(string id, CancellationToken ct) + { + _discord.InvalidateFacts(id); + return ThreadChangedAsync(id, ct); + } + + public async Task MemberChangedAsync(string owner, CancellationToken ct) + { + if (State is null) return; + foreach (var post in State.Listings.Values.Where(p => p.OwnerId == owner && p.Status == ListingStatus.Open).ToArray()) + await FactsChangedAsync(post.Id, ct); + } + public async Task MessageDeletedAsync(string channel, IReadOnlySet ids, CancellationToken ct) { if (State is null) return; diff --git a/DiscordBot/Services/Recruitment/RecruitmentPresentation.cs b/DiscordBot/Services/Recruitment/RecruitmentPresentation.cs index 775ec9c7..e3d23796 100644 --- a/DiscordBot/Services/Recruitment/RecruitmentPresentation.cs +++ b/DiscordBot/Services/Recruitment/RecruitmentPresentation.cs @@ -1,46 +1,88 @@ -using System.IO; using DiscordBot.Settings; -using ImageMagick; -using ImageMagick.Drawing; namespace DiscordBot.Services.Recruitment; public static class RecruitmentPresentation { - public static string Facts(Listing post) => - $"Initial snapshot · account created {Date(post.Facts.AccountCreatedAt)} · joined {Date(post.Facts.JoinedAt)}\n" + - $"Community activity: {post.Facts.Activity}. Payment: {post.Facts.Payment}.\n" + - $"Earlier accepted listing: {(post.Facts.PriorAcceptedListing ? "recorded" : "none recorded")}. {post.Facts.TagAdvice}."; - - private static string Date(DateTimeOffset? date) => date?.ToString("yyyy-MM-dd") ?? "unknown"; public static string Timestamp(DateTimeOffset date) => $""; public static string Safe(string value, int max = 180) => new string(value.Take(max) .Select(c => char.IsControl(c) || "`*_~|<>@".Contains(c) ? ' ' : c).ToArray()); - public static RecruitmentMessage Public(RecruitmentState state, Listing post, RecruitmentSettings settings, DateTimeOffset now, byte[]? image = null) + public static RecruitmentMessage Public(RecruitmentState state, Listing post, RecruitmentSettings settings, DateTimeOffset now) { var eligibility = RecruitmentPolicy.Evaluate(state, post, now, settings.CooldownDays); - var status = post.Status != ListingStatus.Open ? post.Status.ToString() : post.AcceptedAt.HasValue ? "Accepted" : - post.AcknowledgedAt.HasValue ? "Guidelines acknowledged; waiting for eligibility" : "Read this forum's Guidelines and acknowledge the code"; - var text = $"**{status}**\n"; + var status = post.Status != ListingStatus.Open ? post.Status.ToString() : post.AcceptedAt.HasValue ? "Listing active" : + post.AcknowledgedAt.HasValue ? "Waiting for eligibility" : "Acknowledgement needed"; + var instructions = new List(); if (post.Status == ListingStatus.Open && !post.AcceptedAt.HasValue) { - text += post.Deadline.HasValue ? $"Acknowledgement window ends {Timestamp(post.Deadline.Value)}.\n" : "Your full acknowledgement window starts when these controls are confirmed available.\n"; - if (eligibility.BlockingListingId is not null) - text += $"Your other listing <#{eligibility.BlockingListingId}> occupies your one listing slot.\n"; - if (eligibility.NextAllowedAt.HasValue) text += $"Creation cooldown ends {Timestamp(eligibility.NextAllowedAt.Value)}.\n"; + if (!post.AcknowledgedAt.HasValue) instructions.Add("Read this forum's Guidelines and enter the code using **Acknowledge**."); + instructions.Add(post.Deadline.HasValue ? $"Deadline: {Timestamp(post.Deadline.Value)}." : "Your acknowledgement window starts when these controls are available."); + if (eligibility.BlockingListingId is not null) instructions.Add($"Your other listing <#{eligibility.BlockingListingId}> occupies your listing slot."); + if (eligibility.NextAllowedAt.HasValue) instructions.Add($"Creation cooldown ends {Timestamp(eligibility.NextAllowedAt.Value)}."); } if (post.AcceptedAt.HasValue) { var next = state.Authors.GetValueOrDefault(post.OwnerId)?.LastAcceptedCreation?.AddDays(settings.CooldownDays); - text += next.HasValue ? $"Replacement cooldown: {Timestamp(next.Value)}. Close this listing before replacing it.\n" - : "Creation cooldown waived. Close this listing before replacing it.\n"; + if (next > now) instructions.Add($"Next listing: {Timestamp(next.Value)}." + (post.Status == ListingStatus.Open ? " Close this listing before replacing it." : "")); + else if (post.Status == ListingStatus.Open) instructions.Add("Close this listing before posting a replacement."); + } + if (post.Status == ListingStatus.Open && (post.Exempt || post.Pinned)) instructions.Add("This listing stays open until its owner or a moderator closes it."); + if (post.Hold is not null || post.AvailabilityUnknown) instructions.Add("Processing paused; moderator review or Discord recovery is needed."); + + var purpose = state.Forums.First(f => f.Value.ForumId == post.ForumId).Key; + var paid = purpose.StartsWith("paid-"); + var forHire = purpose.EndsWith("for-hire"); + var embed = new EmbedBuilder().WithTitle("Recruitment · " + status).WithDescription(string.Join("\n", instructions)); + var flagged = false; + void Field(string name, string value, bool flag, bool inline = true) + { + flagged |= flag; + embed.AddField((flag ? "⚠ " : "") + name, value, inline); } - if (post.Exempt || post.Pinned) text += "Protected from automatic closure/removal.\n"; - if (post.Hold is not null || post.AvailabilityUnknown) text += "Processing is waiting for moderator review or Discord recovery.\n"; - text += "\n" + Facts(post) + "\nAgree on scope and payment in writing; verify claims independently. These facts are not an endorsement."; + var accountAge = post.Facts.AccountCreatedAt.HasValue ? post.CreatedAt - post.Facts.AccountCreatedAt.Value : (TimeSpan?)null; + Field("Account age", accountAge.HasValue + ? $"{Age(accountAge.Value)} at posting\nCreated " : "Unavailable", + !accountAge.HasValue || accountAge.Value.TotalDays < 30); + var joined = post.Facts.JoinedAt; + Field("Joined server", joined.HasValue ? $"" + + (post.CreatedAt - joined.Value < TimeSpan.FromDays(7) ? "\nJoined less than 7 days before posting" : "") : "Unavailable", + !joined.HasValue || post.CreatedAt - joined.Value < TimeSpan.FromDays(7)); + Field("Server roles", post.Facts.MemberRoleCount switch + { + null => "Unavailable", + 0 => "None assigned. Add relevant skill roles.", + var count => $"{count} assigned" + }, post.Facts.MemberRoleCount is null or 0); + var concrete = post.Facts.Payment == "concrete rate or budget stated"; + var unpaid = post.Facts.Payment == "unpaid collaboration stated"; + var revenueShare = post.Facts.Payment == "revenue-share stated"; + Field("Payment", concrete ? "Rate or budget detected; terms unverified." : revenueShare ? "Revenue share stated; no guaranteed payment detected." : + unpaid ? paid ? "Unpaid terms detected in a paid forum." : "Unpaid collaboration stated." : + post.Facts.Payment == "unknown" ? "Starter text unavailable." : paid ? "No concrete rate or budget detected." : "No clear unpaid, revenue-share or payment terms detected.", + !concrete && (paid || !unpaid), false); + if (forHire) + Field("Portfolio / work samples", post.Facts.Portfolio switch + { + "work-sample link detected" => "Link detected; authorship and contents unverified.", + "external link found; purpose unclear" => "External link found. Label your portfolio or work samples.", + "unknown" => "Starter text unavailable.", + _ => "No link detected. Add a portfolio, demo or work samples." + }, post.Facts.Portfolio != "work-sample link detected", false); + embed.AddField("Before moving to DMs", "Clarify scope, deliverables, timing and payment in this thread first. Verify work samples and agree terms in writing.") + .WithColor(flagged ? Color.Orange : Color.Blue) + .WithFooter("Checks use the starter post and server membership; they do not verify identity or endorse the listing."); + var built = embed.Build(); var marker = $"recruitment:{post.Id}:public"; - return new RecruitmentMessage(text + $"\n||{marker}||", marker, post.Status == ListingStatus.Open, image, Facts(post)); + var fallback = $"**{built.Title}**\n{built.Description}\n" + string.Join("\n", built.Fields.Select(f => $"**{f.Name}**: {f.Value}")) + $"\n{built.Footer?.Text}\n||{marker}||"; + return new RecruitmentMessage($"||{marker}||", marker, post.Status == ListingStatus.Open, Embed: built, TextFallback: fallback); + } + + private static string Age(TimeSpan age) + { + var days = Math.Max(0, (int)age.TotalDays); + if (days >= 365) return $"{days / 365} {(days / 365 == 1 ? "year" : "years")}"; + return $"{days} {(days == 1 ? "day" : "days")}"; } public static RecruitmentMessage Staff(RecruitmentState state, Listing post, RecruitmentSettings settings) @@ -64,26 +106,4 @@ public static RecruitmentMessage Staff(RecruitmentState state, Listing post, Rec return new RecruitmentMessage(text + $"||{marker}||", marker, false); } - public static byte[]? RenderBanner(Listing post, string assetsRoot) - { - try - { - var font = Path.Combine(assetsRoot, "fonts", "OpenSans-Regular.ttf"); - if (!File.Exists(font)) return null; - // Fixed size, local font and bounded, bot-generated text only. No remote images or member markup. - using var image = new MagickImage(new MagickColor("#17212D"), 900, 270); - var draw = new Drawables().Font(font).FillColor(new MagickColor("#F0F4F8")).FontPointSize(25) - .Text(28, 42, "RECRUITMENT · INITIAL SNAPSHOT").FontPointSize(19) - .Text(28, 84, $"Account: {Date(post.Facts.AccountCreatedAt)} Joined: {Date(post.Facts.JoinedAt)}") - .Text(28, 121, $"Community activity: {post.Facts.Activity}") - .Text(28, 158, $"Payment: {post.Facts.Payment}") - .FontPointSize(17).FillColor(new MagickColor("#B8C8D9")) - .Text(28, 208, "Agree on scope and terms in writing. Verify claims independently.") - .Text(28, 239, "Context only; these facts are not an endorsement."); - draw.Draw(image); - var bytes = image.ToByteArray(MagickFormat.Png); - return bytes.Length <= 512 * 1024 ? bytes : null; - } - catch { return null; } // Rendering is cosmetic; essential information is also text. - } } diff --git a/DiscordBot/Services/Recruitment/RecruitmentService.cs b/DiscordBot/Services/Recruitment/RecruitmentService.cs index cf9dab87..5fdd5415 100644 --- a/DiscordBot/Services/Recruitment/RecruitmentService.cs +++ b/DiscordBot/Services/Recruitment/RecruitmentService.cs @@ -18,6 +18,7 @@ public sealed class RecruitmentService private readonly Dictionary> _coalesced = new(); private readonly object _enqueueGate = new(); private IReadOnlySet _knownThreads = new HashSet(); + private IReadOnlySet _listingOwners = new HashSet(); private IReadOnlyDictionary _messageOwners = new Dictionary(); private string? _overviewMessage; private readonly Channel _queue = Channel.CreateBounded(new BoundedChannelOptions(256) @@ -34,8 +35,8 @@ private enum WorkKind { Event, Deadline, Command, Recovery } private sealed record Work(WorkKind Kind, Func Run, Action Cancel); public RecruitmentHealth Health => Volatile.Read(ref _health); - public RecruitmentService(DiscordSocketClient client, BotSettings settings, DatabaseService database) - : this(settings, new DiscordRecruitmentAdapter(client, settings, database), TimeProvider.System) => _client = client; + public RecruitmentService(DiscordSocketClient client, BotSettings settings) + : this(settings, new DiscordRecruitmentAdapter(client, settings), TimeProvider.System) => _client = client; internal RecruitmentService(BotSettings settings, IRecruitmentDiscord discord, TimeProvider time) { @@ -99,6 +100,7 @@ private void PublishHealth() var before = Health; Volatile.Write(ref _health, next); Volatile.Write(ref _knownThreads, _engine.State?.Listings.Keys.ToHashSet() ?? new HashSet()); + Volatile.Write(ref _listingOwners, _engine.State?.Listings.Values.Where(p => p.Status == ListingStatus.Open).Select(p => p.OwnerId).ToHashSet() ?? new HashSet()); var owners = new Dictionary(); foreach (var post in _engine.State?.Listings.Values ?? Enumerable.Empty()) { @@ -187,6 +189,14 @@ internal void NotifyReply(ReplyObservation reply) => if (_engine.State?.Listings.ContainsKey(reply.ThreadId) != true) await _engine.ThreadChangedAsync(reply.ThreadId, ct); _engine.ObserveReply(reply); }, thread: reply.ThreadId); + internal void NotifyFactsChanged(string id) => Enqueue("facts:" + id, ct => _engine.FactsChangedAsync(id, ct), thread: id); + + private Task MessageUpdated(Cacheable before, SocketMessage after, ISocketMessageChannel channel) + { + if (channel is SocketThreadChannel thread && IsRecruitment(thread) && after.Id == thread.Id && + (!before.HasValue || before.Value.Content != after.Content)) NotifyFactsChanged(thread.Id.ToString()); + return Task.CompletedTask; + } private Task ThreadCreated(SocketThreadChannel thread) { @@ -284,8 +294,10 @@ private Task RoleUpdated(SocketRole before, SocketRole after) private Task RoleDeleted(SocketRole role) { if (role.Guild.Id == _settings.GuildId) ScheduleRecovery(); return Task.CompletedTask; } private Task MemberUpdated(Cacheable before, SocketGuildUser after) { - if (after.Guild.Id == _settings.GuildId && after.Id == _client?.CurrentUser.Id && - (!before.HasValue || !before.Value.Roles.Select(r => r.Id).SequenceEqual(after.Roles.Select(r => r.Id)))) ScheduleRecovery(); + if (after.Guild.Id != _settings.GuildId || before.HasValue && before.Value.Roles.Select(r => r.Id).SequenceEqual(after.Roles.Select(r => r.Id))) return Task.CompletedTask; + if (after.Id == _client?.CurrentUser.Id) ScheduleRecovery(); + else if (Volatile.Read(ref _listingOwners).Contains(after.Id.ToString())) + Enqueue("member:" + after.Id, ct => _engine.MemberChangedAsync(after.Id.ToString(), ct)); return Task.CompletedTask; } @@ -338,6 +350,7 @@ private void Subscribe(bool subscribe) { _client.ThreadCreated += ThreadCreated; _client.ThreadUpdated += ThreadUpdated; _client.ThreadDeleted += ThreadDeleted; _client.MessageReceived += MessageReceived; _client.MessageDeleted += MessageDeleted; _client.MessagesBulkDeleted += MessagesDeleted; + _client.MessageUpdated += MessageUpdated; _client.Disconnected += Disconnected; _client.Ready += Ready; _client.GuildAvailable += GuildAvailable; _client.GuildUnavailable += GuildUnavailable; _client.ChannelUpdated += ChannelUpdated; _client.ChannelDestroyed += ChannelDestroyed; _client.RoleUpdated += RoleUpdated; _client.RoleDeleted += RoleDeleted; _client.GuildMemberUpdated += MemberUpdated; @@ -346,6 +359,7 @@ private void Subscribe(bool subscribe) { _client.ThreadCreated -= ThreadCreated; _client.ThreadUpdated -= ThreadUpdated; _client.ThreadDeleted -= ThreadDeleted; _client.MessageReceived -= MessageReceived; _client.MessageDeleted -= MessageDeleted; _client.MessagesBulkDeleted -= MessagesDeleted; + _client.MessageUpdated -= MessageUpdated; _client.Disconnected -= Disconnected; _client.Ready -= Ready; _client.GuildAvailable -= GuildAvailable; _client.GuildUnavailable -= GuildUnavailable; _client.ChannelUpdated -= ChannelUpdated; _client.ChannelDestroyed -= ChannelDestroyed; _client.RoleUpdated -= RoleUpdated; _client.RoleDeleted -= RoleDeleted; _client.GuildMemberUpdated -= MemberUpdated; diff --git a/DiscordBot/Services/Recruitment/RecruitmentState.cs b/DiscordBot/Services/Recruitment/RecruitmentState.cs index 7cf8a85f..d474492a 100644 --- a/DiscordBot/Services/Recruitment/RecruitmentState.cs +++ b/DiscordBot/Services/Recruitment/RecruitmentState.cs @@ -112,15 +112,17 @@ public sealed class Listing public sealed record RecruitmentAudit(DateTimeOffset At, string Actor, string Reason); -/// Informational facts captured once when a listing is first observed; missing data stays unknown. +/// Advisory member and starter-message facts, refreshed on edits and bounded recovery checks. public sealed class InitialFacts { public DateTimeOffset? AccountCreatedAt { get; set; } public DateTimeOffset? JoinedAt { get; set; } - public string Activity { get; set; } = "unknown"; + public string Activity { get; set; } = "unknown"; // Retained for v2 history compatibility; no longer collected or displayed. public string Payment { get; set; } = "unknown"; public string TagAdvice { get; set; } = ""; public bool PriorAcceptedListing { get; set; } + public int? MemberRoleCount { get; set; } + public string Portfolio { get; set; } = "unknown"; } /// Cross-forum cooldown and timeout history that survives removal of individual listings. From 02856a6e3bfb3a5f2a3057836409402d28cd6c87 Mon Sep 17 00:00:00 2001 From: James Kellie Date: Wed, 14 Oct 2026 23:03:58 +1000 Subject: [PATCH 11/31] Manage pinned recruitment references and recovery --- .../Recruitment/RecruitmentFollowUpTests.cs | 12 ++ .../RecruitmentPresentationTests.cs | 2 +- .../RecruitmentPublicationTests.cs | 140 ++++++++++++++++++ .../Recruitment/RecruitmentReferenceTests.cs | 129 ++++++++++++++++ .../RecruitmentStorageAndSetupTests.cs | 11 +- .../Recruitment/RecruitmentTestRig.cs | 26 +++- .../Recruitment/RecruitmentTransportTests.cs | 1 + .../recruitment/guidelines/hobby-for-hire.md | 2 +- .../guidelines/hobby-recruiting.md | 2 +- .../recruitment/guidelines/paid-for-hire.md | 2 +- .../recruitment/guidelines/paid-recruiting.md | 2 +- .../Recruitment/DiscordRecruitmentAdapter.cs | 13 +- .../DiscordRecruitmentReferences.cs | 53 +++++++ .../Recruitment/IRecruitmentDiscord.cs | 2 + .../Services/Recruitment/RecruitmentEngine.cs | 26 +++- .../RecruitmentEngineScheduling.cs | 28 ++-- .../Services/Recruitment/RecruitmentHealth.cs | 6 +- .../Recruitment/RecruitmentReferences.cs | 89 +++++++++++ .../Recruitment/RecruitmentService.cs | 11 +- .../Services/Recruitment/RecruitmentState.cs | 11 ++ .../Recruitment/RecruitmentStateStore.cs | 4 +- docs/recruitment.md | 27 ++-- 22 files changed, 553 insertions(+), 46 deletions(-) create mode 100644 DiscordBot.Tests/Recruitment/RecruitmentPublicationTests.cs create mode 100644 DiscordBot.Tests/Recruitment/RecruitmentReferenceTests.cs create mode 100644 DiscordBot/Services/Recruitment/DiscordRecruitmentReferences.cs create mode 100644 DiscordBot/Services/Recruitment/RecruitmentReferences.cs diff --git a/DiscordBot.Tests/Recruitment/RecruitmentFollowUpTests.cs b/DiscordBot.Tests/Recruitment/RecruitmentFollowUpTests.cs index b632bd9b..0f54ada5 100644 --- a/DiscordBot.Tests/Recruitment/RecruitmentFollowUpTests.cs +++ b/DiscordBot.Tests/Recruitment/RecruitmentFollowUpTests.cs @@ -80,4 +80,16 @@ public async Task ClosedTagEventClosesOnceInsteadOfCancellingItsOwnAction() private static ThreadSnapshot Thread(RecruitmentRig rig) => new("100", "11", "200", "Reference", rig.Clock.Now, false, false, false, false, Array.Empty(), "Read the information below", rig.Clock.Now.AddYears(-2), rig.Clock.Now.AddMonths(-3)); + + [Fact] + public async Task QueuedEvidenceCancelsClosureBeforeAppendingAnActionAudit() + { + using var rig = new RecruitmentRig(); await rig.Start(); var post = await rig.Post(); + rig.Discord.Threads[post.Id] = rig.Discord.Threads[post.Id] with { Tags = new[] { rig.State.Forums["paid-recruiting"].Tags["closed"].Id } }; + rig.PendingThreadWork.Add(post.Id); + await rig.Engine.ThreadChangedAsync(post.Id, default); + Assert.DoesNotContain(post.Audit, a => a.Reason == "Closed tag applied"); Assert.Empty(rig.Discord.Actions); + rig.PendingThreadWork.Clear(); await rig.Engine.ThreadChangedAsync(post.Id, default); + Assert.Single(post.Audit, a => a.Reason == "Closed tag applied"); Assert.Equal(ListingStatus.Closed, post.Status); + } } diff --git a/DiscordBot.Tests/Recruitment/RecruitmentPresentationTests.cs b/DiscordBot.Tests/Recruitment/RecruitmentPresentationTests.cs index 7c1351c3..e1cde2e2 100644 --- a/DiscordBot.Tests/Recruitment/RecruitmentPresentationTests.cs +++ b/DiscordBot.Tests/Recruitment/RecruitmentPresentationTests.cs @@ -63,7 +63,7 @@ public async Task StarterAndRoleCorrectionsRefreshEmbedFieldsWithoutScanningForu await rig.Engine.FactsChangedAsync(post.Id, default); Assert.Equal(writes, rig.Discord.Upserts); rig.Discord.Threads[post.Id] = rig.Discord.Threads[post.Id] with { MemberRoleCount = 0 }; - await rig.Engine.MemberChangedAsync(post.OwnerId, default); + await rig.Engine.FactsChangedAsync(post.Id, default); Assert.Contains(rig.Discord.Messages["100:recruitment:100:public"].Message.Embed!.Fields, f => f.Name == "⚠ Server roles"); } } diff --git a/DiscordBot.Tests/Recruitment/RecruitmentPublicationTests.cs b/DiscordBot.Tests/Recruitment/RecruitmentPublicationTests.cs new file mode 100644 index 00000000..453e32f0 --- /dev/null +++ b/DiscordBot.Tests/Recruitment/RecruitmentPublicationTests.cs @@ -0,0 +1,140 @@ +using System.Net; +using System.Text; +using Discord; +using Discord.Net.Rest; +using Discord.WebSocket; +using DiscordBot.Services.Recruitment; +using DiscordBot.Settings; +using Newtonsoft.Json.Linq; + +namespace DiscordBot.Tests.Recruitment; + +// The transport's synthetic global rate limit is process-wide, including across SDK clients. +[Collection("Recruitment transport")] +public class RecruitmentPublicationTests +{ + [Theory] + [InlineData("50", false, true, 1)] + [InlineData("51", false, true, 1)] + [InlineData("52", false, false, 1)] + [InlineData("1", true, false, 0)] + public async Task InstalledSdkSnapshotsStaffBotsAndRoles(string role, bool bot, bool staff, int roleCount) + { + var transport = new PublicationTransport { Role = role, OwnerBot = bot }; + using var client = await Client(transport); + var thread = await new DiscordRecruitmentAdapter(client, Settings()).GetThreadAsync("100", default); + Assert.Equal(staff, thread!.OwnerIsStaff); Assert.Equal(bot, thread.OwnerIsBot); + Assert.Equal(roleCount, thread.MemberRoleCount); + } + + [Fact] + public async Task InstalledSdkCreatesAndPinsReferenceWithoutChangingOtherFlagsOrMessages() + { + var transport = new PublicationTransport(); using var client = await Client(transport); + var adapter = new DiscordRecruitmentAdapter(client, Settings()); + const string marker = "recruitment:reference:instance:11"; + var id = await adapter.CreateReferenceAsync("11", "71", marker, default); + Assert.Equal("100", id); + Assert.Equal("71", (string?)transport.CreatePayload!["applied_tags"]![0]); + var content = transport.StarterContent; + Assert.True(await adapter.SecureReferenceAsync("11", id, true, marker, default)); + Assert.Equal(32770, (int)transport.Thread["flags"]!); + Assert.True((bool)transport.Thread["thread_metadata"]!["archived"]!); + Assert.True((bool)transport.Thread["thread_metadata"]!["locked"]!); + Assert.Equal(content, transport.StarterContent); + Assert.DoesNotContain(transport.ChannelWrites, w => w["permission_overwrites"] is not null); + var writes = transport.ChannelWrites.Count; + Assert.True(await adapter.SecureReferenceAsync("11", id, true, marker, default)); + Assert.Equal(writes, transport.ChannelWrites.Count); + } + + [Fact] + public async Task InstalledSdkUpdatesFieldOnlyChangesAndRemovesLegacyBannerOnce() + { + var transport = new PublicationTransport(); using var client = await Client(transport); + var adapter = new DiscordRecruitmentAdapter(client, Settings()); + const string marker = "recruitment:100:public"; + var embed = new EmbedBuilder().WithTitle("Recruitment").WithDescription("Listing active").AddField("Payment", "Rate detected").Build(); + var message = new RecruitmentMessage($"||{marker}||", marker, false, Embed: embed); + var writes = 0; + await adapter.UpsertAsync("100", "101", false, DateTimeOffset.UtcNow, message, () => writes++, default); + Assert.Equal(1, writes); + var patch = Assert.Single(transport.MessageWrites); + Assert.Empty((JArray)patch["attachments"]!); + Assert.Equal("Rate detected", (string?)patch["embeds"]![0]!["fields"]![0]!["value"]); + await adapter.UpsertAsync("100", "101", false, DateTimeOffset.UtcNow, message, () => writes++, default); + Assert.Equal(1, writes); + var corrected = embed.ToEmbedBuilder().WithFields(new EmbedFieldBuilder().WithName("Payment").WithValue("No rate detected")).Build(); + await adapter.UpsertAsync("100", "101", false, DateTimeOffset.UtcNow, message with { Embed = corrected }, () => writes++, default); + Assert.Equal(2, writes); + } + + private static BotSettings Settings() => new() { GuildId = 1, + Recruitment = new RecruitmentSettings { Forums = new RecruitmentForums { PaidRecruitingId = 11, PaidForHireId = 12, HobbyRecruitingId = 13, HobbyForHireId = 14 } }, + Roles = new() { Moderator = 50 } }; + + private static async Task Client(PublicationTransport transport) + { + var client = new DiscordSocketClient(new DiscordSocketConfig { RestClientProvider = RecruitmentForumTransport.Wrap(_ => transport) }); + await client.LoginAsync(TokenType.Bot, "offline-placeholder-token", validateToken: false); + // READY normally seeds the socket REST wrapper's identity. Seed only that SDK cache from an + // offline REST login so these payload tests never open a gateway or replace production I/O. + using var rest = new Discord.Rest.DiscordRestClient(new Discord.Rest.DiscordRestConfig { RestClientProvider = _ => transport }); + await rest.LoginAsync(TokenType.Bot, "offline-placeholder-token", validateToken: false); + typeof(Discord.Rest.DiscordRestClient).GetProperty("CurrentUser", System.Reflection.BindingFlags.Public | + System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.DeclaredOnly)!.SetValue(client.Rest, rest.CurrentUser); + return client; + } + + private sealed class PublicationTransport : IRestClient + { + public string Role { get; init; } = "52"; + public bool OwnerBot { get; init; } + public string StarterContent { get; private set; } = "Example listing"; + public JObject? CreatePayload { get; private set; } + public List ChannelWrites { get; } = new(); + public List MessageWrites { get; } = new(); + public JObject Thread { get; } = JObject.Parse("""{"id":"100","type":11,"guild_id":"1","parent_id":"11","owner_id":"200","name":"Example","flags":32768,"applied_tags":[],"thread_metadata":{"archived":false,"locked":false,"auto_archive_duration":10080,"archive_timestamp":"2026-10-14T12:00:00Z"}}"""); + private readonly JObject _message = JObject.Parse("""{"id":"101","channel_id":"100","type":0,"content":"||recruitment:100:public||","author":{"id":"999","username":"offline","discriminator":"0","bot":true},"timestamp":"2026-10-14T12:00:00Z","embeds":[{"type":"rich","title":"Recruitment","description":"Listing active","fields":[{"name":"Payment","value":"Unknown","inline":false}]}],"attachments":[{"id":"900","filename":"recruitment.png","size":10,"url":"https://cdn.discordapp.com/example.png","proxy_url":"https://cdn.discordapp.com/example.png"}],"components":[]}"""); + public void Dispose() { } + public void SetHeader(string key, string value) { } + public void SetCancelToken(CancellationToken token) { } + public Task SendAsync(string method, string endpoint, CancellationToken cancelToken, bool headerOnly, string reason, IEnumerable>> headers) => Respond(method, endpoint, null); + public Task SendAsync(string method, string endpoint, string json, CancellationToken cancelToken, bool headerOnly, string reason, IEnumerable>> headers) => Respond(method, endpoint, JObject.Parse(json)); + public Task SendAsync(string method, string endpoint, IReadOnlyDictionary multipart, CancellationToken cancelToken, bool headerOnly, string reason, IEnumerable>> headers) => Respond(method, endpoint, JObject.Parse((string)multipart["payload_json"])); + private Task Respond(string method, string endpoint, JObject? patch) + { + string body; + if (endpoint == "users/@me") body = """{"id":"999","username":"offline","discriminator":"0","bot":true}"""; + else if (endpoint == "oauth2/applications/@me") body = """{"id":"999","name":"offline","description":"","bot_public":true,"bot_require_code_grant":false,"verify_key":"00"}"""; + else if (endpoint.StartsWith("guilds/1/members/200")) body = $$"""{"user":{"id":"200","username":"member","discriminator":"0","bot":{{OwnerBot.ToString().ToLowerInvariant()}}},"roles":["{{Role}}"],"joined_at":"2020-01-01T00:00:00Z"}"""; + else if (endpoint.StartsWith("guilds/1")) body = """{"id":"1","name":"Offline","owner_id":"900","roles":[{"id":"1","name":"@everyone","permissions":"0"},{"id":"50","name":"Moderator","permissions":"0"},{"id":"51","name":"Admin","permissions":"8"},{"id":"52","name":"Artist","permissions":"0"}],"emojis":[],"features":[],"stickers":[],"afk_timeout":300,"preferred_locale":"en-US"}"""; + else if (endpoint == "channels/11") body = """{"id":"11","type":15,"guild_id":"1","name":"forum","flags":16,"available_tags":[{"id":"71","name":"Closed","moderated":false}]}"""; + else if (endpoint == "channels/11/threads" && method == "POST") + { + CreatePayload = patch!; StarterContent = (string)patch!["message"]!["content"]!; + Thread["owner_id"] = "999"; Thread["applied_tags"] = patch["applied_tags"]!.DeepClone(); body = Thread.ToString(); + } + else if (endpoint == "channels/100") + { + if (method == "PATCH") + { + ChannelWrites.Add(patch!); + foreach (var property in patch!.Properties()) + if (property.Name is "locked" or "archived") Thread["thread_metadata"]![property.Name] = property.Value.DeepClone(); + else Thread[property.Name] = property.Value.DeepClone(); + } + body = Thread.ToString(); + } + else if (endpoint == "channels/100/messages/100") body = new JObject { ["id"] = "100", ["channel_id"] = "100", ["type"] = 0, + ["content"] = StarterContent, ["timestamp"] = "2026-10-14T12:00:00Z", ["author"] = new JObject { ["id"] = Thread["owner_id"]!.ToString(), ["username"] = "owner", ["discriminator"] = "0", ["bot"] = (string?)Thread["owner_id"] == "999" || OwnerBot }, ["embeds"] = new JArray(), ["attachments"] = new JArray() }.ToString(); + else if (endpoint == "channels/100/messages/101") + { + if (method == "PATCH") { MessageWrites.Add(patch!); foreach (var property in patch!.Properties()) _message[property.Name] = property.Value.DeepClone(); } + body = _message.ToString(); + } + else throw new InvalidOperationException($"Unexpected offline route: {method} {endpoint}"); + return Task.FromResult(new RestResponse(HttpStatusCode.OK, new Dictionary(), new MemoryStream(Encoding.UTF8.GetBytes(body)))); + } + } +} diff --git a/DiscordBot.Tests/Recruitment/RecruitmentReferenceTests.cs b/DiscordBot.Tests/Recruitment/RecruitmentReferenceTests.cs new file mode 100644 index 00000000..be0ee3f3 --- /dev/null +++ b/DiscordBot.Tests/Recruitment/RecruitmentReferenceTests.cs @@ -0,0 +1,129 @@ +using DiscordBot.Services.Recruitment; + +namespace DiscordBot.Tests.Recruitment; + +public class RecruitmentReferenceTests +{ + [Fact] + public async Task StartupCreatesOneLockedPinnedReferencePerForumAndRestartReusesThem() + { + using var rig = new RecruitmentRig(); await rig.Start(); + Assert.Equal(4, rig.Discord.ReferenceCreates); Assert.Empty(rig.State.Listings); + var ids = rig.State.Forums.Values.Select(f => f.Reference.ThreadId).ToArray(); + foreach (var id in ids) + { + var reference = rig.Discord.Threads[id!]; + Assert.True(reference.Pinned && reference.Locked && reference.Archived); + Assert.Contains("information below", reference.Starter); + Assert.DoesNotContain(rig.State.RotationCode, reference.Starter); + } + var checks = rig.Discord.ReferenceChecks; + await rig.Sweep(); Assert.Equal(checks, rig.Discord.ReferenceChecks); + await rig.Restart(); Assert.Equal(4, rig.Discord.ReferenceCreates); + Assert.Equal(ids, rig.State.Forums.Values.Select(f => f.Reference.ThreadId)); + Assert.All(rig.Engine.Health().Forums, f => Assert.NotNull(f.ReferenceId)); + } + + [Fact] + public async Task ExistingPinnedStaffReferenceIsReusedWithoutReplacingItsContent() + { + using var rig = new RecruitmentRig(); + rig.Discord.Threads["100"] = new ThreadSnapshot("100", "11", "900", "Information", rig.Clock.Now, + false, true, false, false, Array.Empty(), "Moderator-maintained rules", rig.Clock.Now.AddYears(-1), rig.Clock.Now, OwnerIsStaff: true); + await rig.Start(); + Assert.Equal("100", rig.State.Forums["paid-recruiting"].Reference.ThreadId); + Assert.False(rig.State.Forums["paid-recruiting"].Reference.BotOwned); + Assert.Equal("Moderator-maintained rules", rig.Discord.Threads["100"].Starter); + Assert.Equal(3, rig.Discord.ReferenceCreates); Assert.Empty(rig.State.Listings); + } + + [Fact] + public async Task LostCreationResponseRecoversExistingThreadAcrossRestart() + { + using var rig = new RecruitmentRig(); rig.Discord.LoseNextReferenceResponse = true; + await rig.Start(); + var receipt = rig.State.Forums["paid-recruiting"].Reference; + Assert.True(receipt.CreatePending); Assert.Null(receipt.ThreadId); Assert.NotNull(receipt.Fault); + Assert.Equal(4, rig.Discord.ReferenceCreates); + await rig.Restart(); + Assert.Equal(4, rig.Discord.ReferenceCreates); + Assert.False(rig.State.Forums["paid-recruiting"].Reference.CreatePending); + Assert.NotNull(rig.State.Forums["paid-recruiting"].Reference.ThreadId); + Assert.Null(rig.State.Forums["paid-recruiting"].Reference.Fault); + } + + [Fact] + public async Task PinFailureRetainsIdentityAndDoesNotBlockMemberAcceptance() + { + using var rig = new RecruitmentRig(); rig.Discord.FailReferencePin = true; await rig.Start(); + Assert.All(rig.State.Forums.Values, f => { Assert.NotNull(f.Reference.ThreadId); Assert.NotNull(f.Reference.Fault); }); + Assert.Contains("reference held", rig.Engine.Health().Text()); + await rig.Post(); Assert.Contains("accepted", await rig.Ack()); + Assert.Equal(4, rig.Discord.ReferenceCreates); + rig.Discord.FailReferencePin = false; await rig.Restart(); + Assert.Equal(4, rig.Discord.ReferenceCreates); Assert.All(rig.State.Forums.Values, f => Assert.Null(f.Reference.Fault)); + } + + [Fact] + public async Task DeletedReferenceIsReplacedButAccessFailureNeverCreatesADuplicate() + { + using var rig = new RecruitmentRig(); await rig.Start(); + var id = rig.State.Forums["paid-recruiting"].Reference.ThreadId!; + rig.Discord.UnknownThreads.Add(id); await rig.Restart(); + Assert.Equal(id, rig.State.Forums["paid-recruiting"].Reference.ThreadId); Assert.Equal(4, rig.Discord.ReferenceCreates); + rig.Discord.UnknownThreads.Remove(id); rig.Discord.Threads.Remove(id); + await rig.Engine.ThreadDeletedAsync(id, default); await rig.Engine.RunDueAsync(default); + Assert.NotEqual(id, rig.State.Forums["paid-recruiting"].Reference.ThreadId); + Assert.Equal(5, rig.Discord.ReferenceCreates); Assert.Equal(0, rig.Engine.Health().Open); + } + + [Fact] + public async Task IncompleteDiscoveryDefersReferenceCreation() + { + using var rig = new RecruitmentRig(); rig.Discord.InventoryComplete = false; await rig.Start(); + Assert.Equal(0, rig.Discord.ReferenceCreates); + rig.Discord.InventoryComplete = true; await rig.Sweep(); + Assert.Equal(4, rig.Discord.ReferenceCreates); + } + + [Fact] + public async Task OverdueReferenceRetryDoesNotPollWhileForumSetupIsHeld() + { + using var rig = new RecruitmentRig(); rig.Discord.FailReferencePin = true; await rig.Start(); + rig.Clock.Advance(TimeSpan.FromMinutes(1)); + rig.Discord.Forums["11"] = rig.Discord.Forums["11"] with { Topic = "External Guidelines edit" }; + await rig.Engine.SweepAsync(default, retryHeld: false); await rig.Engine.RefreshOverviewAsync(default); + Assert.NotNull(rig.State.Forums["paid-recruiting"].Fault); + Assert.True(rig.Engine.NextDueAt > rig.Clock.Now); + var reads = rig.Discord.ForumReads; + await rig.Engine.RunDueAsync(default); + Assert.Equal(reads, rig.Discord.ForumReads); + } + + [Fact] + public async Task RetainedStaffReferenceIsReusedEvenWhenNormalInventorySkipsItsKnownId() + { + using var rig = new RecruitmentRig(); await rig.Start(); var post = await rig.Post(); await rig.Staff("reference"); + rig.Discord.Threads[post.Id] = rig.Discord.Threads[post.Id] with { Pinned = true, OwnerIsStaff = true }; + var old = rig.State.Forums["paid-recruiting"].Reference.ThreadId!; + rig.Discord.Threads.Remove(old); await rig.Engine.ThreadDeletedAsync(old, default); + await rig.Restart(); + Assert.Equal(post.Id, rig.State.Forums["paid-recruiting"].Reference.ThreadId); + Assert.Equal(4, rig.Discord.ReferenceCreates); + } + + [Fact] + public async Task MissingBotStarterDoesNotAuthorizeDuplicateReferenceAfterLostResponse() + { + using var rig = new RecruitmentRig(); rig.Discord.LoseNextReferenceResponse = true; await rig.Start(); + var reference = rig.Discord.Threads.Values.Single(t => t.ForumId == "11"); + rig.Discord.Threads[reference.Id] = reference with { Starter = null }; + await rig.Restart(); + Assert.Equal(4, rig.Discord.ReferenceCreates); + Assert.True(rig.State.Forums["paid-recruiting"].Reference.CreatePending); + rig.Discord.Threads[reference.Id] = reference; + await rig.Sweep(); + Assert.Equal(reference.Id, rig.State.Forums["paid-recruiting"].Reference.ThreadId); + Assert.Equal(4, rig.Discord.ReferenceCreates); + } +} diff --git a/DiscordBot.Tests/Recruitment/RecruitmentStorageAndSetupTests.cs b/DiscordBot.Tests/Recruitment/RecruitmentStorageAndSetupTests.cs index 517eb05e..7e746e1e 100644 --- a/DiscordBot.Tests/Recruitment/RecruitmentStorageAndSetupTests.cs +++ b/DiscordBot.Tests/Recruitment/RecruitmentStorageAndSetupTests.cs @@ -17,7 +17,7 @@ public void ShippedGuidelinesFitBudgetWithMaximumDurations() { var text = assets.Render(purpose, "ABCDE", settings); Assert.InRange(text.Length, 1, 1000); - Assert.Contains("pinned moderator/admin post", text); + Assert.Contains("pinned information post", text); Assert.Contains("ABCDE", text); } } @@ -78,14 +78,19 @@ public async Task ExistingV2StateLoadsWithoutNewOptionalOperationalReceipts() using var reader = new Newtonsoft.Json.JsonTextReader(new StringReader(File.ReadAllText(path))) { DateParseHandling = Newtonsoft.Json.DateParseHandling.None }; var json = JObject.Load(reader); json.Remove("Overview"); json.Remove("RecoveryFault"); - foreach (var forum in json["Forums"]!.Children()) ((JObject)forum.Value).Remove("Fault"); - foreach (var listing in json["Listings"]!.Children()) ((JObject)listing.Value).Remove("Fault"); + foreach (var forum in json["Forums"]!.Children()) { ((JObject)forum.Value).Remove("Fault"); ((JObject)forum.Value).Remove("Reference"); } + foreach (var listing in json["Listings"]!.Children()) + { + ((JObject)listing.Value).Remove("Fault"); + ((JObject)listing.Value["Facts"]!).Remove("MemberRoleCount"); ((JObject)listing.Value["Facts"]!).Remove("Portfolio"); + } File.WriteAllText(path, json.ToString()); await rig.Restart(); Assert.True(rig.Engine.State is not null, rig.Engine.Status); Assert.Equal(accepted, rig.State.Authors["200"].LastAcceptedCreation); Assert.NotNull(rig.State.Overview); Assert.NotNull(rig.State.Listings["100"].AcceptedAt); + Assert.Equal(4, rig.Discord.ReferenceCreates); // Existing bot references recovered from their marker. } [Fact] diff --git a/DiscordBot.Tests/Recruitment/RecruitmentTestRig.cs b/DiscordBot.Tests/Recruitment/RecruitmentTestRig.cs index c4fcd488..563ac30c 100644 --- a/DiscordBot.Tests/Recruitment/RecruitmentTestRig.cs +++ b/DiscordBot.Tests/Recruitment/RecruitmentTestRig.cs @@ -118,6 +118,10 @@ internal sealed class FakeRecruitmentDiscord : IRecruitmentDiscord public string? FailForum { get; set; } public bool? FailForumRequireTag { get; set; } public bool LoseNextForumResponse { get; set; } + public bool LoseNextReferenceResponse { get; set; } + public bool FailReferencePin { get; set; } + public int ReferenceCreates { get; private set; } + public int ReferenceChecks { get; private set; } public List<(string Id, bool Tags, bool RequireTag)> ForumPublications { get; } = new(); public Action? BeforeDelete { get; set; } public int Sends { get; private set; } @@ -146,12 +150,32 @@ public Task PublishForumAsync(ForumSnapshot expected, string? top if (LoseNextForumResponse) { LoseNextForumResponse = false; throw new TimeoutException("Forum response lost"); } return Task.FromResult(Forums[id]); } - public Task InventoryAsync(DateTimeOffset floor, IReadOnlySet knownIds, IReadOnlyDictionary cursors, CancellationToken ct) { Inventories++; return Task.FromResult(new ThreadInventory(Threads.Values.Where(t => t.CreatedAt >= floor).ToArray(), InventoryComplete)); } + public Task InventoryAsync(DateTimeOffset floor, IReadOnlySet knownIds, IReadOnlyDictionary cursors, CancellationToken ct) { Inventories++; return Task.FromResult(new ThreadInventory(Threads.Values.Where(t => t.CreatedAt >= floor && !knownIds.Contains(t.Id)).ToArray(), InventoryComplete)); } public Task GetThreadAsync(string id, CancellationToken ct, bool refresh = false) { ThreadReads++; return UnknownThreads.Contains(id) ? Task.FromException(new InvalidOperationException("Forbidden")) : Task.FromResult(Threads.GetValueOrDefault(id)); } public void InvalidateThread(string id) { } public void InvalidateCache() { } public void InvalidateFacts(string id) { } + public Task CreateReferenceAsync(string forum, string closedTagId, string marker, CancellationToken ct) + { + ReferenceCreates++; + var id = (++_nextId).ToString(); + Threads[id] = new ThreadSnapshot(id, forum, "999", "Read first · Recruitment information", DateTimeOffset.UtcNow, + true, false, false, false, new[] { closedTagId }, DiscordRecruitmentAdapter.ReferenceIntroduction(marker), null, null); + if (LoseNextReferenceResponse) { LoseNextReferenceResponse = false; throw new TimeoutException("Reference response lost"); } + return Task.FromResult(id); + } + public Task SecureReferenceAsync(string forum, string id, bool botOwned, string marker, CancellationToken ct) + { + ReferenceChecks++; + if (FailReferencePin) throw new TimeoutException("Reference pin failed"); + if (UnknownThreads.Contains(id)) throw new InvalidOperationException("Forbidden"); + if (!Threads.TryGetValue(id, out var thread)) return Task.FromResult(false); + if (thread.ForumId != forum || (botOwned ? thread.OwnerId != "999" || thread.Starter?.Contains(marker) != true : !thread.OwnerIsStaff)) + throw new InvalidOperationException("Reference identity mismatch"); + Threads[id] = thread with { Pinned = true, Locked = true, Archived = true }; + return Task.FromResult(true); + } public Task HasReplyAsync(ThreadSnapshot thread, CancellationToken ct) { ReplyReads++; return Task.FromResult(Replies.Contains(thread.Id)); } public Task UpsertAsync(string channelId, string? messageId, bool recovering, DateTimeOffset since, RecruitmentMessage message, Action beforeWrite, CancellationToken ct) { diff --git a/DiscordBot.Tests/Recruitment/RecruitmentTransportTests.cs b/DiscordBot.Tests/Recruitment/RecruitmentTransportTests.cs index e347ef24..486d67a8 100644 --- a/DiscordBot.Tests/Recruitment/RecruitmentTransportTests.cs +++ b/DiscordBot.Tests/Recruitment/RecruitmentTransportTests.cs @@ -10,6 +10,7 @@ namespace DiscordBot.Tests.Recruitment; +[Collection("Recruitment transport")] public class RecruitmentTransportTests { [Fact] diff --git a/DiscordBot/Assets/recruitment/guidelines/hobby-for-hire.md b/DiscordBot/Assets/recruitment/guidelines/hobby-for-hire.md index 68a04ba2..3839f53d 100644 --- a/DiscordBot/Assets/recruitment/guidelines/hobby-for-hire.md +++ b/DiscordBot/Assets/recruitment/guidelines/hobby-for-hire.md @@ -2,7 +2,7 @@ Describe your skills, examples, availability and expectations for unpaid collaboration. -Read the pinned moderator/admin post at the top of this forum for fuller guidance. Use relevant tags; verify claims and agree terms in writing. +Read the pinned information post at the top of this forum for fuller guidance. Use relevant tags; verify claims and agree terms in writing. You may have **one open listing total across all four recruitment forums**. Combine hiring and offering services in a for-hire listing. diff --git a/DiscordBot/Assets/recruitment/guidelines/hobby-recruiting.md b/DiscordBot/Assets/recruitment/guidelines/hobby-recruiting.md index 4b5da20f..c4d50e98 100644 --- a/DiscordBot/Assets/recruitment/guidelines/hobby-recruiting.md +++ b/DiscordBot/Assets/recruitment/guidelines/hobby-recruiting.md @@ -2,7 +2,7 @@ Describe the project, progress, skills and time needed. Clearly state unpaid or revenue-share terms. -Read the pinned moderator/admin post at the top of this forum for fuller guidance. Use relevant tags; verify claims and agree terms in writing. +Read the pinned information post at the top of this forum for fuller guidance. Use relevant tags; verify claims and agree terms in writing. You may have **one open listing total across all four recruitment forums**. Combine hiring and offering services in a for-hire listing. diff --git a/DiscordBot/Assets/recruitment/guidelines/paid-for-hire.md b/DiscordBot/Assets/recruitment/guidelines/paid-for-hire.md index 8e6758c2..88dadd08 100644 --- a/DiscordBot/Assets/recruitment/guidelines/paid-for-hire.md +++ b/DiscordBot/Assets/recruitment/guidelines/paid-for-hire.md @@ -2,7 +2,7 @@ Describe your services, portfolio, availability, rates, currency and contact details. -Read the pinned moderator/admin post at the top of this forum for fuller guidance. Use relevant tags; verify claims and agree terms in writing. +Read the pinned information post at the top of this forum for fuller guidance. Use relevant tags; verify claims and agree terms in writing. You may have **one open listing total across all four recruitment forums**. Combine hiring and offering services in a for-hire listing. diff --git a/DiscordBot/Assets/recruitment/guidelines/paid-recruiting.md b/DiscordBot/Assets/recruitment/guidelines/paid-recruiting.md index ddff64e5..be3f7fb7 100644 --- a/DiscordBot/Assets/recruitment/guidelines/paid-recruiting.md +++ b/DiscordBot/Assets/recruitment/guidelines/paid-recruiting.md @@ -2,7 +2,7 @@ Describe the work, skills needed, budget, currency, payment schedule and contact details. -Read the pinned moderator/admin post at the top of this forum for fuller guidance. Use relevant tags; verify claims and agree terms in writing. +Read the pinned information post at the top of this forum for fuller guidance. Use relevant tags; verify claims and agree terms in writing. You may have **one open listing total across all four recruitment forums**. Combine hiring and offering services in a for-hire listing. diff --git a/DiscordBot/Services/Recruitment/DiscordRecruitmentAdapter.cs b/DiscordBot/Services/Recruitment/DiscordRecruitmentAdapter.cs index 92e0949f..223f1077 100644 --- a/DiscordBot/Services/Recruitment/DiscordRecruitmentAdapter.cs +++ b/DiscordBot/Services/Recruitment/DiscordRecruitmentAdapter.cs @@ -8,7 +8,7 @@ namespace DiscordBot.Services.Recruitment; /// Discord I/O and authoritative pre-action checks for the serialized recruitment engine. -public sealed class DiscordRecruitmentAdapter : IRecruitmentDiscord +public sealed partial class DiscordRecruitmentAdapter : IRecruitmentDiscord { private readonly DiscordSocketClient _client; private readonly BotSettings _settings; @@ -23,6 +23,8 @@ public sealed class DiscordRecruitmentAdapter : IRecruitmentDiscord public DiscordRecruitmentAdapter(DiscordSocketClient client, BotSettings settings) => (_client, _settings) = (client, settings); private static RequestOptions Options(CancellationToken ct) => RecruitmentRequests.Options(ct); + private ulong BotUserId => _client.CurrentUser?.Id ?? _client.Rest.CurrentUser?.Id + ?? throw new InvalidOperationException("Authenticated bot identity is unavailable."); private SocketGuild Guild => _client.GetGuild(_settings.GuildId) ?? throw new InvalidOperationException("Configured guild is unavailable."); public Task ValidateAccessAsync(CancellationToken ct) @@ -59,7 +61,7 @@ public async Task ValidateFeedAsync(CancellationToken ct) if (role?.Permissions.Administrator == true || overwrite.TargetId == _settings.Roles.Moderator || (role?.IsManaged == true && Guild.CurrentUser.Roles.Any(r => r.Id == role.Id))) continue; } - else if (overwrite.TargetId == _client.CurrentUser.Id || await IsModeratorAsync(_settings.GuildId.ToString(), overwrite.TargetId.ToString(), ct)) continue; + else if (overwrite.TargetId == BotUserId || await IsModeratorAsync(_settings.GuildId.ToString(), overwrite.TargetId.ToString(), ct)) continue; throw new InvalidOperationException("The recruitment feed grants access outside the configured moderator/admin roles."); } _messageChannels[feed.Id.ToString()] = feed; @@ -252,12 +254,13 @@ private async Task MessageChannelAsync(string id, CancellationT public async Task UpsertAsync(string channelId, string? messageId, bool recovering, DateTimeOffset since, RecruitmentMessage message, Action beforeWrite, CancellationToken ct) { var channel = await MessageChannelAsync(channelId, ct); + var botUserId = BotUserId; if (message.Embed is not null && message.TextFallback is not null && channel is RestThreadChannel listing && !Guild.CurrentUser.GetPermissions(Guild.GetChannel(listing.ParentChannelId)).EmbedLinks) message = message with { Content = message.TextFallback, Embed = null }; IUserMessage? existing = null; if (messageId is not null) existing = await channel.GetMessageAsync(ulong.Parse(messageId), CacheMode.AllowDownload, Options(ct)) as IUserMessage; - if (existing is not null && (existing.Author.Id != _client.CurrentUser.Id || !existing.Content.Contains(message.Marker))) + if (existing is not null && (existing.Author.Id != botUserId || !existing.Content.Contains(message.Marker))) throw new InvalidOperationException("Stored recruitment message identity does not match."); if (existing is null && recovering) { @@ -267,7 +270,7 @@ public async Task UpsertAsync(string channelId, string? messageId, boo { var messages = (before.HasValue ? await channel.GetMessagesAsync(before.Value, Direction.Before, 100, CacheMode.AllowDownload, Options(ct)).FlattenAsync() : await channel.GetMessagesAsync(100, CacheMode.AllowDownload, Options(ct)).FlattenAsync()).ToArray(); - existing = messages.OfType().FirstOrDefault(m => m.Author.Id == _client.CurrentUser.Id && m.Content.Contains(message.Marker)); + existing = messages.OfType().FirstOrDefault(m => m.Author.Id == botUserId && m.Content.Contains(message.Marker)); if (existing is not null) break; if (messages.Length < 100 || messages.Min(m => m.Timestamp) <= since.AddMinutes(-5)) { covered = true; break; } before = messages.Min(m => m.Id); @@ -356,7 +359,7 @@ private void GuardWrite(RestThreadChannel live, ThreadSnapshot expected, bool pr public async Task DeleteAdvisoryAsync(string threadId, string messageId, CancellationToken ct, string? marker = null) { var channel = await MessageChannelAsync(threadId, ct); - if (await channel.GetMessageAsync(ulong.Parse(messageId), CacheMode.AllowDownload, Options(ct)) is IUserMessage message && message.Author.Id == _client.CurrentUser.Id && (marker is null || message.Content.Contains(marker))) + if (await channel.GetMessageAsync(ulong.Parse(messageId), CacheMode.AllowDownload, Options(ct)) is IUserMessage message && message.Author.Id == BotUserId && (marker is null || message.Content.Contains(marker))) await message.DeleteAsync(Options(ct)); } } diff --git a/DiscordBot/Services/Recruitment/DiscordRecruitmentReferences.cs b/DiscordBot/Services/Recruitment/DiscordRecruitmentReferences.cs new file mode 100644 index 00000000..5141a20d --- /dev/null +++ b/DiscordBot/Services/Recruitment/DiscordRecruitmentReferences.cs @@ -0,0 +1,53 @@ +using Discord.Net; +using Discord.Rest; + +namespace DiscordBot.Services.Recruitment; + +public sealed partial class DiscordRecruitmentAdapter +{ + public async Task CreateReferenceAsync(string forumId, string closedTagId, string marker, CancellationToken ct) + { + var forum = _forums.TryGetValue(forumId, out var cached) ? cached.Channel : await ForumAsync(forumId, ct); + var tags = forum.Tags.Where(t => t.Id.ToString() == closedTagId).ToArray(); + if (tags.Length != 1) throw new InvalidOperationException("Reference post needs a verified Closed tag."); + var thread = await forum.CreatePostAsync("Read first · Recruitment information", ThreadArchiveDuration.OneWeek, + text: ReferenceIntroduction(marker), tags: tags, allowedMentions: AllowedMentions.None, options: Options(ct)); + return thread.Id.ToString(); + } + + internal static string ReferenceIntroduction(string marker) => + "Read the guidance below and this forum's Guidelines before posting. Clarify scope, timing and payment in the listing before moving to DMs.\n\n" + + "Moderators and administrators maintain the information below.\n" + $"||{marker}||"; + + public async Task SecureReferenceAsync(string forumId, string threadId, bool botOwned, string marker, CancellationToken ct) + { + RestThreadChannel thread; + try + { + thread = await _client.Rest.GetChannelAsync(ulong.Parse(threadId), Options(ct)) as RestThreadChannel + ?? throw new InvalidOperationException("Reference is not a forum thread."); + } + catch (HttpException ex) when (ex.DiscordCode == DiscordErrorCode.UnknownChannel) { return false; } + if (thread.GuildId != _settings.GuildId || thread.ParentChannelId.ToString() != forumId || !_settings.Recruitment.Forums.Contains(thread.ParentChannelId)) + throw new InvalidOperationException("Reference forum identity does not match."); + if (botOwned) + { + var starter = await thread.GetMessageAsync(thread.Id, Options(ct)); + var botUserId = BotUserId; + if (((IThreadChannel)thread).OwnerId != botUserId || starter?.Author.Id != botUserId || !starter.Content.Contains(marker, StringComparison.Ordinal)) + throw new InvalidOperationException("Reference bot ownership or marker does not match."); + } + else if (!await IsModeratorAsync(_settings.GuildId.ToString(), ((IThreadChannel)thread).OwnerId.ToString(), ct)) + throw new InvalidOperationException("Existing reference is no longer owned by a moderator or administrator."); + if (thread.IsLocked && thread.IsArchived && thread.Flags.HasFlag(ChannelFlags.Pinned)) return true; + // Forum threads inherit permissions. Locking is the supported member-posting restriction; + // staff with Manage Threads can reopen it to maintain their own messages below the introduction. + if (thread.IsArchived) await thread.ModifyAsync(p => { p.Archived = false; p.Flags = thread.Flags; }, Options(ct)); + await thread.ModifyAsync(p => { p.Flags = thread.Flags | ChannelFlags.Pinned; p.Locked = true; p.Archived = true; }, Options(ct)); + var verified = await _client.Rest.GetChannelAsync(thread.Id, Options(ct)) as RestThreadChannel; + if (verified is null || !verified.IsArchived || !verified.IsLocked || !verified.Flags.HasFlag(ChannelFlags.Pinned)) + throw new InvalidOperationException("Reference pin/lock was not confirmed."); + InvalidateFacts(threadId); + return true; + } +} diff --git a/DiscordBot/Services/Recruitment/IRecruitmentDiscord.cs b/DiscordBot/Services/Recruitment/IRecruitmentDiscord.cs index 04d674e7..c07ce037 100644 --- a/DiscordBot/Services/Recruitment/IRecruitmentDiscord.cs +++ b/DiscordBot/Services/Recruitment/IRecruitmentDiscord.cs @@ -27,6 +27,8 @@ public interface IRecruitmentDiscord Task PublishForumAsync(ForumSnapshot expected, string? topic, IReadOnlyList? tags, bool requireTag, CancellationToken ct); Task InventoryAsync(DateTimeOffset floor, IReadOnlySet knownIds, IReadOnlyDictionary cursors, CancellationToken ct); Task GetThreadAsync(string threadId, CancellationToken ct, bool refresh = false); + Task CreateReferenceAsync(string forumId, string closedTagId, string marker, CancellationToken ct); + Task SecureReferenceAsync(string forumId, string threadId, bool botOwned, string marker, CancellationToken ct); void InvalidateThread(string threadId); void InvalidateFacts(string threadId); void InvalidateCache(); diff --git a/DiscordBot/Services/Recruitment/RecruitmentEngine.cs b/DiscordBot/Services/Recruitment/RecruitmentEngine.cs index 8ebf0c1c..8d999af0 100644 --- a/DiscordBot/Services/Recruitment/RecruitmentEngine.cs +++ b/DiscordBot/Services/Recruitment/RecruitmentEngine.cs @@ -60,6 +60,7 @@ public void MarkEvidenceGap(bool invalidateAccess = true) _accessInvalid = true; if (invalidateAccess) _feedReady = false; _discord.InvalidateCache(); + _verifiedReferences.Clear(); _referenceCandidates.Clear(); _ignoredThreads.Clear(); foreach (var post in State.Listings.Values.Where(p => p.Status == ListingStatus.Open)) { post.ResponseCoverageComplete = false; @@ -115,6 +116,7 @@ public async Task SweepAsync(CancellationToken ct, bool retryHeld = true) await ProcessSafelyAsync(post, ct, verifyHeld: true); } if (batch.Length > 0) State.SweepAfterId = batch[^1].Id; + await ReconcileReferencesAsync(ct, retryHeld); RecruitmentPolicy.Prune(State, Now, _settings.PostRetentionMonths, _settings.AuthorRetentionMonths, _settings.CooldownDays); if (EvidenceCurrent) State.LastSweepAt = Now; else _nextAudit = Now.AddSeconds(10); @@ -139,12 +141,23 @@ private async Task DiscoverAsync(CancellationToken ct) { var state = State!; var generation = _evidenceGeneration(); - var cursors = state.Forums.Values.ToDictionary(f => f.ForumId, f => f.ArchiveCursor ?? new ArchiveCursor(state.DiscoveryFloor)); - var inventory = await _discord.InventoryAsync(state.DiscoveryFloor, state.Listings.Keys.ToHashSet(), cursors, ct); + var cursors = state.Forums.Values.ToDictionary(f => f.ForumId, f => + { + var cursor = f.ArchiveCursor ?? new ArchiveCursor(state.DiscoveryFloor); + return f.Reference.CreatePending && f.Reference.CreateStartedAt is { } started && cursor.Since > started.AddMinutes(-5) + ? new ArchiveCursor(started.AddMinutes(-5)) : cursor; + }); + foreach (var id in _ignoredThreads.Where(p => p.Value < state.DiscoveryFloor).Select(p => p.Key).ToArray()) _ignoredThreads.Remove(id); + var known = state.Listings.Keys.Concat(_ignoredThreads.Keys).Concat(state.Forums.Values.Select(f => f.Reference.ThreadId).OfType()).ToHashSet(); + var inventory = await _discord.InventoryAsync(state.DiscoveryFloor, known, cursors, ct); _inventoryComplete = inventory.Complete; _inventoryGeneration = generation; + _unreadableReferenceForums.Clear(); foreach (var live in inventory.Threads.OrderBy(t => t.CreatedAt).ThenBy(t => ulong.Parse(t.Id))) + { + ObserveReferenceCandidate(live); await ObserveAsync(live, false, ct); + } if (inventory.Cursors is not null) foreach (var forum in state.Forums.Values) forum.ArchiveCursor = inventory.Cursors[forum.ForumId]; if (!_inventoryComplete) @@ -217,6 +230,7 @@ private async Task ProcessAsync(Listing post, CancellationToken ct) } if (live.OwnerIsBot || live.OwnerIsStaff) { + ObserveReferenceCandidate(live); // Retire records made by older versions without editing staff content or erasing accepted history. post.Status = ListingStatus.Reference; post.PendingAction = null; post.Deadline = null; post.Hold = null; post.Fault = null; post.ValidCodes.Clear(); _recoverWindows.Remove(post.Id); @@ -435,7 +449,8 @@ private async Task BeginActionAsync(Listing post, RecruitmentActionKind kind, st if (post.PendingAction is not null) throw new InvalidOperationException("An earlier action still needs reconciliation."); post.PendingAction = new RecruitmentAction { Kind = kind, ActorId = actor, Reason = reason, Condition = condition, Automatic = automatic, RequiresModerator = requiresModerator, GuidelineTimeout = condition == "timeout", CreatedAt = Now }; - Audit(post, actor, reason); Save(); + if (!automatic) Audit(post, actor, reason); + Save(); await ExecuteActionAsync(post, ct); } @@ -493,6 +508,9 @@ private async Task ExecuteActionAsync(Listing post, CancellationToken ct) // Gateway facts and timely owner interactions already queued must be applied before an automatic action. if (action.Automatic && (!EvidenceCurrent || _pendingThreadWork(post.Id))) { post.PendingAction = null; Save(); return; } + // The pending action is already visible to staff. Do not append an automatic-action audit + // for a request cancelled by newer evidence before it reaches the write boundary. + if (action.Automatic && !action.CallStarted) Audit(post, action.ActorId, action.Reason); action.CallStarted = true; Save(); switch (action.Kind) { @@ -547,6 +565,7 @@ public async Task StaffAsync(string guild, string actor, string operatio { if (target.Length > 0 && !State.Forums.ContainsKey(target)) return "Unknown recruitment forum purpose."; State.Overview.Fault = null; + _verifiedReferences.Clear(); State.SetupAudit.Add(new RecruitmentAudit(Now, actor, "Repair forum setup: " + reason)); if (State.SetupAudit.Count > 30) State.SetupAudit.RemoveAt(0); Save(); @@ -558,6 +577,7 @@ public async Task StaffAsync(string guild, string actor, string operatio if (operation == "reconcile") { State.Overview.Fault = null; + _verifiedReferences.Clear(); State.SetupAudit.Add(new RecruitmentAudit(Now, actor, "Reconcile: " + reason)); if (State.SetupAudit.Count > 30) State.SetupAudit.RemoveAt(0); Save(); await SweepAsync(ct); return Status; diff --git a/DiscordBot/Services/Recruitment/RecruitmentEngineScheduling.cs b/DiscordBot/Services/Recruitment/RecruitmentEngineScheduling.cs index cc2638b2..913dc13c 100644 --- a/DiscordBot/Services/Recruitment/RecruitmentEngineScheduling.cs +++ b/DiscordBot/Services/Recruitment/RecruitmentEngineScheduling.cs @@ -22,7 +22,7 @@ public sealed partial class RecruitmentEngine State?.Forums.Select(f => new ForumHealth(f.Key, f.Value.ForumId, f.Value.Fault is not null ? "held" : f.Value.PendingTopic is not null ? "publication pending" : f.Value.ConfirmedTopic.Length == 0 ? "not verified" : "Guidelines and tags verified", - f.Value.Fault?.Message, f.Value.Fault?.RetryAt)).ToArray() ?? Array.Empty(), State?.Overview.Fault?.Message); + f.Value.Fault?.Message, f.Value.Fault?.RetryAt, f.Value.Reference.ThreadId, f.Value.Reference.Fault?.Message)).ToArray() ?? Array.Empty(), State?.Overview.Fault?.Message); private DateTimeOffset? NextWorkAt { @@ -32,7 +32,11 @@ private DateTimeOffset? NextWorkAt var times = new List(); if (_nextAudit != DateTimeOffset.MaxValue) times.Add(_nextAudit); if (State.RecoveryFault?.RetryAt is { } retry) times.Add(retry); - times.AddRange(State.Forums.Values.Where(f => f.Fault?.RetryAt is not null).Select(f => f.Fault!.RetryAt!.Value)); + // Expired child retries cannot run through a parent hold; scheduling them would wake every second. + if (State.RecoveryFault is null) + times.AddRange(State.Forums.Values.Where(f => f.Fault?.RetryAt is not null).Select(f => f.Fault!.RetryAt!.Value)); + if (_ready) + times.AddRange(State.Forums.Values.Where(f => f.Reference.Fault?.RetryAt is not null).Select(f => f.Reference.Fault!.RetryAt!.Value)); if (_ready && EvidenceCurrent) { times.Add(State.RotateAt); @@ -68,7 +72,8 @@ public async Task RunDueAsync(CancellationToken ct) { if (State is null) return; if (_nextAudit <= Now || State.RecoveryFault?.IsDue(Now) == true || - State.Forums.Values.Any(f => f.Fault?.IsDue(Now) == true) || _ready && State.RotateAt <= Now) + State.RecoveryFault is null && State.Forums.Values.Any(f => f.Fault?.IsDue(Now) == true) || + _ready && (State.Forums.Values.Any(f => f.Reference.Fault?.IsDue(Now) == true) || State.RotateAt <= Now)) { await SweepAsync(ct, retryHeld: false); return; @@ -91,6 +96,7 @@ public void InvalidateAccess(string? forum = null) State.RecoveryFault = null; State.Overview.Fault = null; foreach (var f in State.Forums.Values.Where(f => forum is null || f.ForumId == forum)) f.Fault = null; + foreach (var f in State.Forums.Values.Where(f => forum is null || f.ForumId == forum)) f.Reference.Fault = null; foreach (var post in State.Listings.Values.Where(p => forum is null || p.ForumId == forum)) post.Fault = null; _nextAudit = Now; MarkEvidenceGap(); @@ -99,12 +105,13 @@ public void InvalidateAccess(string? forum = null) public async Task ThreadChangedAsync(string id, CancellationToken ct) { _discord.InvalidateThread(id); + if (State?.Forums.Values.Any(f => f.Reference.ThreadId == id) == true) return; if (State is null || !_ready) return; if (!State.Listings.TryGetValue(id, out var post)) { var live = await _discord.GetThreadAsync(id, ct); // ThreadCreated also signals access to an existing thread; it cannot certify reply coverage. - if (live is not null) await ObserveAsync(live, false, ct); + if (live is not null) { ObserveReferenceCandidate(live); await ObserveAsync(live, false, ct); } if (!State.Listings.TryGetValue(id, out post)) return; } post.Fault = null; @@ -114,6 +121,12 @@ public async Task ThreadChangedAsync(string id, CancellationToken ct) public async Task ThreadDeletedAsync(string id, CancellationToken ct) { _discord.InvalidateThread(id); + if (State?.Forums.Values.FirstOrDefault(f => f.Reference.ThreadId == id) is { } forum) + { + forum.Reference = new ReferenceReceipt(); _verifiedReferences.Remove(forum.ForumId); + _referenceCandidates.Remove(forum.ForumId); _ignoredThreads.Remove(id); + RequestRecovery(); Save(); + } if (State?.Listings.GetValueOrDefault(id) is not { } post) return; RecordAbsence(post); if (_ready) await ReconcileThreadAsync(id, ct); @@ -125,13 +138,6 @@ public Task FactsChangedAsync(string id, CancellationToken ct) return ThreadChangedAsync(id, ct); } - public async Task MemberChangedAsync(string owner, CancellationToken ct) - { - if (State is null) return; - foreach (var post in State.Listings.Values.Where(p => p.OwnerId == owner && p.Status == ListingStatus.Open).ToArray()) - await FactsChangedAsync(post.Id, ct); - } - public async Task MessageDeletedAsync(string channel, IReadOnlySet ids, CancellationToken ct) { if (State is null) return; diff --git a/DiscordBot/Services/Recruitment/RecruitmentHealth.cs b/DiscordBot/Services/Recruitment/RecruitmentHealth.cs index 80ea8f5f..5ee22c0e 100644 --- a/DiscordBot/Services/Recruitment/RecruitmentHealth.cs +++ b/DiscordBot/Services/Recruitment/RecruitmentHealth.cs @@ -1,6 +1,6 @@ namespace DiscordBot.Services.Recruitment; -public sealed record ForumHealth(string Purpose, string Id, string Setup, string? Fault, DateTimeOffset? RetryAt); +public sealed record ForumHealth(string Purpose, string Id, string Setup, string? Fault, DateTimeOffset? RetryAt, string? ReferenceId = null, string? ReferenceFault = null); /// Detached from mutable state so moderators can inspect progress while the worker awaits Discord. public sealed record RecruitmentHealth(string Status, string Work, DateTimeOffset ObservedAt, @@ -21,7 +21,9 @@ public Embed Embed(bool includeFreshness = true) public string Text() { var forums = string.Join("\n", Forums.Select(f => $"• <#{f.Id}> · {f.Purpose}: {f.Setup}" + - (f.Fault is null ? "" : $" — {f.Fault}" + (f.RetryAt.HasValue ? $"; retry {RecruitmentPresentation.Timestamp(f.RetryAt.Value)}" : "; preview and repair required")))); + (f.Fault is null ? "" : $" — {RecruitmentPresentation.Safe(f.Fault, 250)}" + (f.RetryAt.HasValue ? $"; retry {RecruitmentPresentation.Timestamp(f.RetryAt.Value)}" : "; preview and repair required")) + + (f.ReferenceId is null ? " · reference pending" : $" · reference <#{f.ReferenceId}>") + + (f.ReferenceFault is null ? "" : " · reference held: " + RecruitmentPresentation.Safe(f.ReferenceFault, 180)))); return RecruitmentPresentation.Safe(Status, 400) + $"\nOpen: {Open} · awaiting acknowledgement: {Pending} · held: {Held}\n" + $"Inventory: {(InventoryComplete ? "complete" : "recovery needed")} · work: {Work}\n" + (LastRecovery.HasValue ? $"Last recovery: {RecruitmentPresentation.Timestamp(LastRecovery.Value)}\n" : "No completed recovery yet.\n") + diff --git a/DiscordBot/Services/Recruitment/RecruitmentReferences.cs b/DiscordBot/Services/Recruitment/RecruitmentReferences.cs new file mode 100644 index 00000000..b83d6f52 --- /dev/null +++ b/DiscordBot/Services/Recruitment/RecruitmentReferences.cs @@ -0,0 +1,89 @@ +namespace DiscordBot.Services.Recruitment; + +public sealed partial class RecruitmentEngine +{ + private readonly HashSet _verifiedReferences = new(); + private readonly Dictionary _referenceCandidates = new(); + private readonly Dictionary _ignoredThreads = new(); + private readonly HashSet _unreadableReferenceForums = new(); + private string ReferenceMarker(string forum) => $"recruitment:reference:{State!.InstanceId}:{forum}"; + + private void ObserveReferenceCandidate(ThreadSnapshot live) + { + if (!live.OwnerIsBot && !live.OwnerIsStaff) return; + if (live.OwnerIsBot && live.Starter is null) + { + _unreadableReferenceForums.Add(live.ForumId); + return; // The missing starter could contain an interrupted create's marker; keep it discoverable. + } + // Retain ignored identities for this recovery pass so 50 staff/bot posts cannot starve member discovery. + _ignoredThreads[live.Id] = live.CreatedAt; + var owned = live.OwnerIsBot && live.Starter?.Contains(ReferenceMarker(live.ForumId), StringComparison.Ordinal) == true; + if (owned || live.OwnerIsStaff && live.Pinned && !_referenceCandidates.ContainsKey(live.ForumId)) + _referenceCandidates[live.ForumId] = (live.Id, owned); + } + + private async Task ReconcileReferencesAsync(CancellationToken ct, bool retryHeld) + { + foreach (var (purpose, forum) in State!.Forums) + { + var receipt = forum.Reference; + if (_verifiedReferences.Contains(forum.ForumId) || !retryHeld && receipt.Fault is { } fault && !fault.IsDue(Now)) continue; + // Reuse the normal bounded inventory; never create while discovery could still hide a prior send. + if (!EvidenceCurrent) { _nextAudit = Now.AddSeconds(10); return; } + try + { + var marker = ReferenceMarker(forum.ForumId); + if (receipt.ThreadId is not null && !await _discord.SecureReferenceAsync(forum.ForumId, receipt.ThreadId, receipt.BotOwned, marker, ct)) + { + _referenceCandidates.Remove(forum.ForumId); _ignoredThreads.Remove(receipt.ThreadId); + receipt.ThreadId = null; receipt.CreatePending = false; Save(); + } + if (receipt.ThreadId is null) + { + // Older versions retained designated references as listings. Normal inventory skips + // those known IDs; check them before creating another pinned post. + if (!_referenceCandidates.ContainsKey(forum.ForumId) && !receipt.CreatePending) + { + var retained = State.Listings.Values.Where(p => p.ForumId == forum.ForumId && + p.Status != ListingStatus.Deleted && (p.Status == ListingStatus.Reference || p.Pinned)).ToArray(); + foreach (var post in retained.Take(10)) + { + if (await _discord.GetThreadAsync(post.Id, ct, refresh: true) is { } live) ObserveReferenceCandidate(live); + if (_referenceCandidates.ContainsKey(forum.ForumId)) break; + } + if (retained.Length > 10 && !_referenceCandidates.ContainsKey(forum.ForumId)) + throw new InvalidOperationException("Reference discovery exceeded 10 retained candidates; review earlier reference designations."); + } + if (_unreadableReferenceForums.Contains(forum.ForumId)) + throw new TimeoutException("Reference discovery is waiting for a bot-owned starter message."); + if (_referenceCandidates.TryGetValue(forum.ForumId, out var candidate) && (!receipt.CreatePending || candidate.BotOwned)) + { + receipt.ThreadId = candidate.Id; receipt.BotOwned = candidate.BotOwned; receipt.CreatePending = false; Save(); + } + else + { + if (receipt.CreatePending && receipt.CreateStartedAt < State.DiscoveryFloor) + throw new InvalidOperationException("Reference creation predates retained discovery; inspect the forum before retrying."); + receipt.CreateStartedAt = Now; receipt.CreatePending = true; receipt.BotOwned = true; Save(); + var closed = forum.Tags[_assets!.Tags[purpose].Single(t => t.Closing).Key].Id; + receipt.ThreadId = await _discord.CreateReferenceAsync(forum.ForumId, closed, marker, ct); + receipt.CreatePending = false; Save(); // Identity is durable before pin/lock writes. + } + if (!await _discord.SecureReferenceAsync(forum.ForumId, receipt.ThreadId, receipt.BotOwned, marker, ct)) + { + _ignoredThreads.Remove(receipt.ThreadId); _referenceCandidates.Remove(forum.ForumId); + receipt.ThreadId = null; Save(); + throw new TimeoutException("Reference disappeared before pinning; discovery will retry."); + } + } + receipt.Fault = null; _verifiedReferences.Add(forum.ForumId); Save(); + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not System.IO.IOException) + { + receipt.Fault = RecruitmentFault.From(ex, Now, receipt.Fault); + Save(); // Reference availability does not block otherwise valid member listings. + } + } + } +} diff --git a/DiscordBot/Services/Recruitment/RecruitmentService.cs b/DiscordBot/Services/Recruitment/RecruitmentService.cs index 5fdd5415..d6378959 100644 --- a/DiscordBot/Services/Recruitment/RecruitmentService.cs +++ b/DiscordBot/Services/Recruitment/RecruitmentService.cs @@ -18,7 +18,7 @@ public sealed class RecruitmentService private readonly Dictionary> _coalesced = new(); private readonly object _enqueueGate = new(); private IReadOnlySet _knownThreads = new HashSet(); - private IReadOnlySet _listingOwners = new HashSet(); + private IReadOnlyDictionary _listingOwners = new Dictionary(); private IReadOnlyDictionary _messageOwners = new Dictionary(); private string? _overviewMessage; private readonly Channel _queue = Channel.CreateBounded(new BoundedChannelOptions(256) @@ -99,8 +99,9 @@ private void PublishHealth() var next = _engine.Health(_work) with { Job = _job }; var before = Health; Volatile.Write(ref _health, next); - Volatile.Write(ref _knownThreads, _engine.State?.Listings.Keys.ToHashSet() ?? new HashSet()); - Volatile.Write(ref _listingOwners, _engine.State?.Listings.Values.Where(p => p.Status == ListingStatus.Open).Select(p => p.OwnerId).ToHashSet() ?? new HashSet()); + Volatile.Write(ref _knownThreads, _engine.State is { } state ? state.Listings.Keys.Concat(state.Forums.Values.Select(f => f.Reference.ThreadId).OfType()).ToHashSet() : new HashSet()); + Volatile.Write(ref _listingOwners, _engine.State?.Listings.Values.Where(p => p.Status == ListingStatus.Open) + .GroupBy(p => p.OwnerId).ToDictionary(g => g.Key, g => g.Select(p => p.Id).ToArray()) ?? new Dictionary()); var owners = new Dictionary(); foreach (var post in _engine.State?.Listings.Values ?? Enumerable.Empty()) { @@ -296,8 +297,8 @@ private Task MemberUpdated(Cacheable before, SocketGuild { if (after.Guild.Id != _settings.GuildId || before.HasValue && before.Value.Roles.Select(r => r.Id).SequenceEqual(after.Roles.Select(r => r.Id))) return Task.CompletedTask; if (after.Id == _client?.CurrentUser.Id) ScheduleRecovery(); - else if (Volatile.Read(ref _listingOwners).Contains(after.Id.ToString())) - Enqueue("member:" + after.Id, ct => _engine.MemberChangedAsync(after.Id.ToString(), ct)); + else if (Volatile.Read(ref _listingOwners).TryGetValue(after.Id.ToString(), out var threads)) + foreach (var id in threads) NotifyFactsChanged(id); return Task.CompletedTask; } diff --git a/DiscordBot/Services/Recruitment/RecruitmentState.cs b/DiscordBot/Services/Recruitment/RecruitmentState.cs index d474492a..1a041539 100644 --- a/DiscordBot/Services/Recruitment/RecruitmentState.cs +++ b/DiscordBot/Services/Recruitment/RecruitmentState.cs @@ -43,6 +43,7 @@ public sealed class ForumReceipt public string ForumId { get; set; } = ""; public RecruitmentFault? Fault { get; set; } public ArchiveCursor? ArchiveCursor { get; set; } + public ReferenceReceipt Reference { get; set; } = new(); public string ConfirmedTopic { get; set; } = ""; public string VisibleCode { get; set; } = ""; public Dictionary Tags { get; set; } = new(); @@ -53,6 +54,16 @@ public sealed class ForumReceipt public List? PendingTags { get; set; } } +/// A forum's reference thread, separate from member listings and the private feed overview. +public sealed class ReferenceReceipt +{ + public string? ThreadId { get; set; } + public bool BotOwned { get; set; } + public bool CreatePending { get; set; } + public DateTimeOffset? CreateStartedAt { get; set; } + public RecruitmentFault? Fault { get; set; } +} + public sealed class ManagedTag { [JsonProperty(Required = Required.AllowNull)] diff --git a/DiscordBot/Services/Recruitment/RecruitmentStateStore.cs b/DiscordBot/Services/Recruitment/RecruitmentStateStore.cs index 8ef101f7..06e33992 100644 --- a/DiscordBot/Services/Recruitment/RecruitmentStateStore.cs +++ b/DiscordBot/Services/Recruitment/RecruitmentStateStore.cs @@ -110,7 +110,9 @@ state.Forums is null || state.Listings is null || state.Authors is null || state overview.SendPending && (overview.SendStartedAt is null || overview.PendingMarker is null || !overview.PendingMarker.StartsWith($"recruitment:overview:{state.InstanceId}:"))) throw new InvalidDataException("Recruitment overview receipt is invalid."); foreach (var f in state.Forums.Values) - if (f is null || f.Tags is null || f.ConfirmedTopic is null || f.VisibleCode is null || + if (f is null || f.Tags is null || f.ConfirmedTopic is null || f.VisibleCode is null || f.Reference is null || + f.Reference.ThreadId is not null && !Id(f.Reference.ThreadId) || + f.Reference.CreatePending && (f.Reference.CreateStartedAt is null || f.Reference.ThreadId is not null) || f.Tags.Values.Any(t => t is null || !Id(t.Id) || string.IsNullOrWhiteSpace(t.Name))) throw new InvalidDataException("Recruitment forum receipt is invalid."); foreach (var (id, p) in state.Listings) diff --git a/docs/recruitment.md b/docs/recruitment.md index 2d41b1bc..b38e5555 100644 --- a/docs/recruitment.md +++ b/docs/recruitment.md @@ -5,7 +5,10 @@ Recruitment is disabled in the example, development and production settings. It ## Member behavior - Each member has **one listing total** across paid recruiting, paid for hire, hobby recruiting and hobby for hire. An older pending attempt reserves the slot; changing forums does not bypass it. -- The bot posts one advisory with an accessible initial PNG snapshot, essential text and owner controls. Database/member facts can be **unknown**. Payment and tag suggestions are advisory; there is no trust score or automated scam accusation. +- Posts by bots, configured moderators and administrators are excluded from member recruitment. Previously managed staff posts become references and lose their bot advisory; accepted history is preserved. +- The bot posts one advisory embed with owner controls. It shows account age at posting, join date, server-role count (excluding `@everyone`), payment detection and, in for-hire forums, portfolio/work-sample links. Accounts under 30 days old, joins less than 7 days before posting, missing roles and missing/uncertain details are marked for attention. Blue means no listed check needs attention; it is not a trust rating. +- Paid posts need a concrete rate or budget to clear the payment flag. Hobby posts can state unpaid collaboration; revenue share is identified as non-guaranteed payment. Portfolio detection recognizes work-host links or a labelled external link; it never fetches links or verifies authorship. Starter edits and member-role changes refresh the checks. Members should clarify scope, deliverables, timing and payment publicly before moving to DMs. +- Unavailable facts remain explicit. Community activity is no longer queried or displayed. Discord's documented [member](https://docs.discord.com/developers/resources/guild#guild-member-object) and [user](https://docs.discord.com/developers/resources/user#user-object) structures do not expose server-profile privacy, so no privacy flag is inferred. Embeds have a text fallback without Embed Links; updating an old advisory removes its obsolete banner attachment. - The owner enters the five-character code from the forum's native Guidelines. The 30-minute window starts after usable controls are delivered. A correct code accepts an eligible listing immediately. An acknowledged blocked attempt can become accepted if its blocker clears during the window. - At the deadline, an unacknowledged attempt is removed. An acknowledged attempt still blocked by another listing/cooldown is locked and archived. Pins and explicit exemptions protect against automatic actions. - Only **accepted** listings start a cooldown, measured from the thread's original creation time. Closing/deleting on day 2 leaves 28 days; doing so on day 40 adds no wait. An open listing still occupies the slot after day 30, including when Discord naturally archives it. Removing an unaccepted attempt creates no cooldown. @@ -37,13 +40,13 @@ Set these fields under `Recruitment` in the existing settings file: Replace zero IDs with the new channels. Do not reuse the old `Channels.Recruitment` or manually configure tag IDs. All four IDs must be distinct forum channels in `GuildId`. `Roles.Moderator` must resolve to the intended role. -The bot needs View Channel, Read Message History, Send Messages in Threads, Manage Threads and Manage Channels on each forum. Attach Files enables the PNG; a definitively rejected image upload falls back to text. In the feed it needs View Channel, Read Message History and Send Messages. Embed Links enables the status overview embed; without it, the overview uses concise text. Deny View Channel to `@everyone`; explicit access grants are limited to the configured moderator role, administrators and the bot. Existing Guild Members and Message Content intents must also be enabled in the Discord application. +The bot needs View Channel, Read Message History, Send Messages in Threads, Manage Threads and Manage Channels on each forum. Creating reference posts also requires Send Messages. Embed Links enables public advisory embeds; no Attach Files permission is needed. In the feed it needs View Channel, Read Message History and Send Messages. Embed Links enables the status overview embed; without it, the overview uses concise text. Deny View Channel to `@everyone`; explicit access grants are limited to the configured moderator role, administrators and the bot. Existing Guild Members and Message Content intents must also be enabled in the Discord application. Review `DiscordBot/Assets/recruitment/guidelines/*.md` and `tags.json` before enabling. Templates own the **entire native Guidelines** and contain one `{{code}}` plus the duration placeholders. Rendered Guidelines must fit the 1,000-character budget, including the code and configured durations. They point members to the pinned moderator/admin post at the top of the forum for fuller guidance; moderators maintain that post separately. Each tag definition has a stable key, display name, advisory group and closing flag. Keep keys stable when renaming a tag. The one closing tag is unmoderated. The shipped copy/tag groups are editable defaults pending moderator approval. Local build/publish artifacts and Docker include the recruitment assets and bundled font. Use a persistent `ServerRootPath`; production remains one replica with `Recreate` deployment. Do not run two bot processes against the same state directory. -Keep member posting closed during initial setup. After configuring and restarting with `Enabled=true`, check `/recruitment status`, preview each forum, and verify native Guidelines, Require Tag and tag IDs. The bot does not create the separate pinned reference post. Unexpected first-use content, direct Guidelines edits, changed managed tag permissions or missing known tags hold processing. `/recruitment repair` explicitly republishes owned setup after review, preserves unrelated tags, and can recreate a deleted managed tag. Discord cannot restore the old ID or its lost applications after a tag is deleted; inspect affected listings when repairing that case. Removing managed definitions is intentionally held for manual review. +Keep member posting closed during initial setup. After configuring and restarting with `Enabled=true`, check `/recruitment status`, preview each forum, and verify native Guidelines, Require Tag and tag IDs. The bot provisions the pinned reference after complete discovery. Unexpected first-use content, direct Guidelines edits, changed managed tag permissions or missing known tags hold processing. `/recruitment repair` explicitly republishes owned setup after review, preserves unrelated tags, and can recreate a deleted managed tag. Discord cannot restore the old ID or its lost applications after a tag is deleted; inspect affected listings when repairing that case. Removing managed definitions is intentionally held for manual review. Setup first publishes native Guidelines and selectable tags, verifies their assigned IDs and attributes, and only then enables Require Tag. An interrupted publication resumes from its saved intent. Other forums can finish setup while one is held; acceptance still waits for complete cross-forum inventory. Discord error `40066` means that no member-selectable tags were available for validation. Manage Channels alone does not resolve that tag-state rejection. The staged request path is covered by offline SDK tests; confirm the server's behavior in staging. @@ -51,7 +54,11 @@ Discord.Net 3.20.1 drops tag moderation during forum serialization and can send The persisted code rotates on Monday at 00:00 UTC. Publication is verified per forum; an interrupted rotation retries the same code. A challenge accepts its issued code and codes subsequently confirmed visible during its window. Codes are not included in advisories, staff entries, previews or diagnostic messages. -Create, edit and pin the moderator reference post yourself. Use `/recruitment manage` → **Designate reference post** with its thread ID. This excludes it from challenges and capacity and removes the bot's provisional advisory. An already accepted cooldown remains until explicitly waived. Pinning alone does not free an accepted member slot, and ordinary moderator personal listings follow the normal listing policy. +After setup and complete discovery, the bot reuses a discovered pinned staff reference or creates **Read first · Recruitment information** in each forum. The short introduction directs readers to staff guidance below and the native Guidelines; it does not repeat the rotating code. The reference uses the existing Closed tag, is pinned, locked and archived, and is excluded from member challenges and capacity. Creation intent and the returned thread ID are saved before later writes. Interrupted creates recover by a bot-owned marker; incomplete inventory prevents duplicate creation. A deleted reference is replaced after discovery, and status links to each reference or shows its recovery fault. + +Forum posts inherit parent permissions, so a single reference cannot have admin-only permission overwrites. Administrators and moderators with **Manage Threads** can reopen it, add or edit their own messages, then close it again. The bot preserves those messages and rechecks the pin/lock on startup, recovery or an explicit repair/reconcile. It does not immediately close a thread while staff are editing. [Discord thread permissions](https://docs.discord.com/developers/topics/threads) + +`/recruitment manage` → **Designate reference post** still excludes an existing member post from challenges and capacity and removes its advisory. An accepted cooldown remains until explicitly waived. Pinning an ordinary member listing alone does not free its slot. ## Moderator controls @@ -59,7 +66,7 @@ All commands are ephemeral and check the configured guild and the caller's curre | Command | Purpose | | --- | --- | -| `/recruitment status` / `help` | Forum links, setup faults, counts, inventory coverage, next work and recovery-job status. Reads a detached snapshot, including during startup or a delayed Discord request. | +| `/recruitment status` / `help` | Forum/reference links, setup or reference faults, counts, inventory coverage, next work and recovery-job status. Reads a detached snapshot, including during startup or a delayed Discord request. | | `/recruitment lookup id` | Retained thread/author history and creation cooldown. | | `/recruitment preview [forum]` | Proposed Guidelines with a masked code, and managed tag names. | | `/recruitment repair reason [forum]` | Queue publication/verification of all forums or one selected forum after previewing it. | @@ -79,7 +86,7 @@ The staff feed keeps one entry per attempt, updated with identity, eligibility, State is stored in `{ServerRootPath}/recruitment/`: -- `recruitment-state.json`: schema `udc-recruitment-v2`, string IDs, UTC dates, per-forum receipts/cursors, listing records, author aggregates, retry holds and overview delivery receipts. +- `recruitment-state.json`: schema `udc-recruitment-v2`, string IDs, UTC dates, per-forum setup/reference receipts and cursors, listing records, author aggregates, retry holds and overview delivery receipts. - `recruitment-state.json.bak`: last validated snapshot before the latest replacement. - `initialized`: matching store identity, used to detect a missing primary. - `writer.lock`: exclusive process lease. The file may remain after shutdown; its existence does not itself mean the lease is held. @@ -90,14 +97,14 @@ For storage recovery, stop the bot, preserve the damaged files, restore a valida The worker saves intent and staff evidence before destructive calls, then rechecks current identity and protection. Interrupted message sends recover bot-owned messages by marker before retrying. Recovery scans are bounded; a scan that cannot establish completeness holds instead of duplicating a message. An interrupted delete that finds an absent thread records an unknown actor. Archived-message updates save a restoration intent, temporarily unarchive when necessary, and restore archiving after updating the bot's own advisory. [Discord thread rules](https://docs.discord.com/developers/topics/threads) -Recruitment uses a single state writer and a bounded, coalesced event queue. Relevant thread changes, human replies, deleted bot controls (including bulk deletion), permissions and readiness events drive targeted work. Deadlines and cooldown expiry have scheduled wake-ups. Ordinary acknowledgements do not run forum setup or enumerate all forums. A safety reconciliation runs every 15–18 minutes; incomplete inventory continues in bounded batches. Active-thread enumeration uses one guild request per discovery pass; newly found identities fetch their missing facts separately. The transport observes all identities before the SDK filters the response by forum. Initial facts and successful message renders are cached; an audit or invalidation refreshes them. State changes remain durable before external effects, and destructive actions retain fresh identity/protection checks. +Recruitment uses a single state writer and a bounded, coalesced event queue. Relevant thread/starter edits, member-role changes, human replies, deleted bot controls (including bulk deletion), permissions and readiness events drive targeted work. Deadlines and cooldown expiry have scheduled wake-ups. Ordinary acknowledgements do not run forum setup or enumerate all forums. A safety reconciliation runs every 15–18 minutes; incomplete inventory continues in bounded batches. Active-thread enumeration uses one guild request per discovery pass; newly found identities fetch their missing facts separately. The transport observes all identities before the SDK filters the response by forum. Member/starter facts and successful message renders are cached; targeted events or a bounded audit refresh them. Reference provisioning shares discovery instead of enumerating forums separately. Only work still waiting in the queue fences automatic actions; the event being processed does not cancel itself. State changes remain durable before external effects, and destructive actions retain fresh identity/protection checks. -Invalid setup, permission and ownership failures hold until a relevant invalidation or explicit recovery. Transient failures back off with jitter. Recruitment hands actual HTTP 429 retry deadlines back to the scheduler, with conservative channel/guild scope and global handling, and retains the SDK's shared request queue. Known SDK bucket exhaustion also fails promptly rather than sleeping through minutes. Repeated identical fault logs are suppressed; status shows the scope and retry eligibility. +Confirmed thread-deletion events release listing capacity and clear stale holds even while setup is unavailable. Bounded safety audits also probe permanently held records for deletions missed during downtime, without blindly retrying their writes. Invalid setup, permission and ownership failures otherwise hold until a relevant invalidation or explicit recovery. Transient failures back off with jitter. Recruitment hands actual HTTP 429 retry deadlines back to the scheduler, with conservative channel/guild scope and global handling, and retains the SDK's shared request queue. Known SDK bucket exhaustion also fails promptly rather than sleeping through minutes. Repeated identical fault logs are suppressed; status shows the scope and retry eligibility. Startup, disconnects, lost queue events and delivery failures invalidate relevant negative reply evidence and restore full usable windows for pending attempts. Timely queued owner submissions and reply facts are processed before automatic actions; acknowledgement uses the Discord interaction timestamp. Archive discovery resumes bounded pages across sweeps, with at most 50 new identities and 10 retained records processed per audit pass; scheduled listing work handles up to 20 due records at a time; incomplete inventory holds acceptance/actions. Retained known records are reconciled directly; terminal details expire after 12 months and inactive author aggregates after 24 months, while open/reference/held/action dependencies remain. The discovery floor prevents expired detail from being recreated by old archive scans. ## Validation before deployment -The automated tests cover policy, persistence faults, legacy v2 loading, staged setup/rotation, actual installed-SDK payloads, synthetic 235/537-second throttles, idle request budgets, targeted processing, queue loss, dashboard cooldown/recovery, owner/staff authorization, interaction registration and the real Linux PNG renderer. They do not call live Discord. +The automated tests cover policy, persistence faults, legacy v2 loading, staged setup/rotation, actual installed-SDK payloads, synthetic 235/537-second throttles, idle request budgets, targeted processing, queue loss, dashboard cooldown/recovery, owner/staff authorization, interaction registration staff exclusion, current-content embeds, portfolio detection, pinned-reference recovery and actual SDK publication payloads. They do not call live Discord. -Before opening posting access, exercise the new forums in staging: effective permissions and feed privacy, native Guidelines/tag repair, acknowledgement and immediate acceptance, duplicate/cooldown behavior, removal confirmation, pinned/reference protection, unanswered/Closed closure, restart recovery, image descriptions and mobile readability. Check initially empty forums, an unrelated moderated tag, restart between setup stages, the overview replacement cooldown and a quiet interval without repeated channel reads. Verify the mounted persistent directory and graceful shutdown. No live bot login, guild command registration, staging mutation, deployment or remote push is part of local implementation validation. +Before opening posting access, exercise the new forums in staging: effective permissions and feed privacy, native Guidelines/tag repair, acknowledgement and immediate acceptance, duplicate/cooldown behavior, removal confirmation, pinned/reference protection, unanswered/Closed closure, restart recovery, reference editing/relocking, starter/role corrections and mobile embed readability. Check initially empty forums, an unrelated moderated tag, restart between setup stages, the overview replacement cooldown and a quiet interval without repeated channel reads. Verify the mounted persistent directory and graceful shutdown. No live bot login, guild command registration, staging mutation, deployment or remote push is part of local implementation validation. From b8ac1d6b7396942b06c9a0c69433156a16371390 Mon Sep 17 00:00:00 2001 From: James Kellie Date: Thu, 15 Oct 2026 17:02:44 +1000 Subject: [PATCH 12/31] Preserve recruitment history and honor timely acknowledgements --- .../RecruitmentPublicationTests.cs | 4 +- .../Recruitment/RecruitmentRecoveryTests.cs | 5 +- .../Recruitment/RecruitmentReferenceTests.cs | 2 +- .../Recruitment/RecruitmentSchedulingTests.cs | 6 +- .../RecruitmentStorageAndSetupTests.cs | 8 +-- .../Recruitment/RecruitmentSubmissionTests.cs | 41 +++++++++++++ .../Recruitment/RecruitmentTestRig.cs | 2 +- .../Recruitment/RecruitmentWorkflowTests.cs | 53 +++++++--------- .../recruitment/guidelines/hobby-for-hire.md | 12 ++-- .../guidelines/hobby-recruiting.md | 12 ++-- .../recruitment/guidelines/paid-for-hire.md | 12 ++-- .../recruitment/guidelines/paid-recruiting.md | 12 ++-- .../Modules/Recruitment/RecruitmentModule.cs | 1 - .../Recruitment/DiscordRecruitmentAdapter.cs | 15 ----- .../DiscordRecruitmentReferences.cs | 8 +-- .../Recruitment/IRecruitmentDiscord.cs | 2 - .../Services/Recruitment/RecruitmentAssets.cs | 2 +- .../Services/Recruitment/RecruitmentEngine.cs | 61 +++++++------------ .../RecruitmentEngineScheduling.cs | 4 -- .../Recruitment/RecruitmentPresentation.cs | 1 - .../Recruitment/RecruitmentService.cs | 11 ---- .../Services/Recruitment/RecruitmentState.cs | 6 +- DiscordBot/Settings/Deserialized/Settings.cs | 6 +- docs/recruitment.md | 13 ++-- 24 files changed, 137 insertions(+), 162 deletions(-) create mode 100644 DiscordBot.Tests/Recruitment/RecruitmentSubmissionTests.cs diff --git a/DiscordBot.Tests/Recruitment/RecruitmentPublicationTests.cs b/DiscordBot.Tests/Recruitment/RecruitmentPublicationTests.cs index 453e32f0..1712fae4 100644 --- a/DiscordBot.Tests/Recruitment/RecruitmentPublicationTests.cs +++ b/DiscordBot.Tests/Recruitment/RecruitmentPublicationTests.cs @@ -39,7 +39,7 @@ public async Task InstalledSdkCreatesAndPinsReferenceWithoutChangingOtherFlagsOr var content = transport.StarterContent; Assert.True(await adapter.SecureReferenceAsync("11", id, true, marker, default)); Assert.Equal(32770, (int)transport.Thread["flags"]!); - Assert.True((bool)transport.Thread["thread_metadata"]!["archived"]!); + Assert.False((bool)transport.Thread["thread_metadata"]!["archived"]!); Assert.True((bool)transport.Thread["thread_metadata"]!["locked"]!); Assert.Equal(content, transport.StarterContent); Assert.DoesNotContain(transport.ChannelWrites, w => w["permission_overwrites"] is not null); @@ -123,6 +123,8 @@ private Task Respond(string method, string endpoint, JObject? patc foreach (var property in patch!.Properties()) if (property.Name is "locked" or "archived") Thread["thread_metadata"]![property.Name] = property.Value.DeepClone(); else Thread[property.Name] = property.Value.DeepClone(); + // Discord clears the forum pin when archiving; do not allow an impossible fixture state. + if ((bool)Thread["thread_metadata"]!["archived"]!) Thread["flags"] = (int)Thread["flags"]! & ~2; } body = Thread.ToString(); } diff --git a/DiscordBot.Tests/Recruitment/RecruitmentRecoveryTests.cs b/DiscordBot.Tests/Recruitment/RecruitmentRecoveryTests.cs index a6142e0c..f51fd3d4 100644 --- a/DiscordBot.Tests/Recruitment/RecruitmentRecoveryTests.cs +++ b/DiscordBot.Tests/Recruitment/RecruitmentRecoveryTests.cs @@ -27,7 +27,8 @@ public async Task LostCloseResponseIsConfirmedAfterRestartWithoutRepeatingAction { using var rig = new RecruitmentRig(); await rig.Start(); await rig.Post(); await rig.Ack(); rig.Discord.LoseNextCloseResponse = true; - rig.Clock.Advance(TimeSpan.FromDays(31)); await rig.Sweep(); await rig.Restart(); + await Assert.ThrowsAsync(() => rig.Staff("close")); + await rig.Restart(); Assert.Equal(ListingStatus.Closed, rig.State.Listings["100"].Status); Assert.Single(rig.Discord.Actions); Assert.Null(rig.State.Listings["100"].PendingAction); @@ -127,7 +128,7 @@ public async Task StaffPreviewRedactsRotatingAnswerAndIncludesFullGuidelines() { using var rig = new RecruitmentRig(); await rig.Start(); var preview = await rig.Staff("preview", "paid-recruiting"); - Assert.Contains("one open listing total", preview); Assert.Contains("Managed tags", preview); + Assert.Contains("One open listing", preview); Assert.Contains("Managed tags", preview); Assert.DoesNotContain(rig.State.RotationCode, preview); } diff --git a/DiscordBot.Tests/Recruitment/RecruitmentReferenceTests.cs b/DiscordBot.Tests/Recruitment/RecruitmentReferenceTests.cs index be0ee3f3..4b00bbbb 100644 --- a/DiscordBot.Tests/Recruitment/RecruitmentReferenceTests.cs +++ b/DiscordBot.Tests/Recruitment/RecruitmentReferenceTests.cs @@ -13,7 +13,7 @@ public async Task StartupCreatesOneLockedPinnedReferencePerForumAndRestartReuses foreach (var id in ids) { var reference = rig.Discord.Threads[id!]; - Assert.True(reference.Pinned && reference.Locked && reference.Archived); + Assert.True(reference.Pinned && reference.Locked && !reference.Archived); Assert.Contains("information below", reference.Starter); Assert.DoesNotContain(rig.State.RotationCode, reference.Starter); } diff --git a/DiscordBot.Tests/Recruitment/RecruitmentSchedulingTests.cs b/DiscordBot.Tests/Recruitment/RecruitmentSchedulingTests.cs index 7f4a7202..cae159b1 100644 --- a/DiscordBot.Tests/Recruitment/RecruitmentSchedulingTests.cs +++ b/DiscordBot.Tests/Recruitment/RecruitmentSchedulingTests.cs @@ -17,13 +17,11 @@ public async Task AcknowledgementAndUnchangedThreadEventDoNotScanForumsOrRewrite } [Fact] - public async Task PositiveReplyEvidenceDoesNotNeedFurtherHistoryScans() + public async Task AcceptedListingsNeverNeedReplyHistoryScans() { using var rig = new RecruitmentRig(); await rig.Start(); var post = await rig.Post(); await rig.Ack(); - rig.Engine.ObserveReply(new ReplyObservation(post.Id, "201", false, false, false)); - var reads = rig.Discord.ReplyReads; await rig.Sweep(); await rig.Engine.ThreadChangedAsync(post.Id, default); - Assert.Equal(reads, rig.Discord.ReplyReads); + Assert.Equal(0, rig.Discord.ReplyReads); } [Fact] diff --git a/DiscordBot.Tests/Recruitment/RecruitmentStorageAndSetupTests.cs b/DiscordBot.Tests/Recruitment/RecruitmentStorageAndSetupTests.cs index 7e746e1e..25456784 100644 --- a/DiscordBot.Tests/Recruitment/RecruitmentStorageAndSetupTests.cs +++ b/DiscordBot.Tests/Recruitment/RecruitmentStorageAndSetupTests.cs @@ -17,7 +17,7 @@ public void ShippedGuidelinesFitBudgetWithMaximumDurations() { var text = assets.Render(purpose, "ABCDE", settings); Assert.InRange(text.Length, 1, 1000); - Assert.Contains("pinned information post", text); + Assert.Contains("pinned rules", text); Assert.Contains("ABCDE", text); } } @@ -207,9 +207,9 @@ public async Task RestartNeverClosesAcceptedListingBasedOnIncompleteHistoricalRe using var rig = new RecruitmentRig(); await rig.Start(); await rig.Post(); await rig.Ack(); rig.Clock.Advance(TimeSpan.FromDays(31)); await rig.Restart(); Assert.Equal(ListingStatus.Open, rig.State.Listings["100"].Status); - Assert.False(rig.State.Listings["100"].ResponseCoverageComplete); - await rig.Staff("review-no-reply"); await rig.Sweep(); - Assert.Equal(ListingStatus.Closed, rig.State.Listings["100"].Status); + Assert.Contains("Unknown recruitment operation", await rig.Staff("review-no-reply")); + await rig.Sweep(); + Assert.Equal(ListingStatus.Open, rig.State.Listings["100"].Status); } [Fact] diff --git a/DiscordBot.Tests/Recruitment/RecruitmentSubmissionTests.cs b/DiscordBot.Tests/Recruitment/RecruitmentSubmissionTests.cs new file mode 100644 index 00000000..e5246abf --- /dev/null +++ b/DiscordBot.Tests/Recruitment/RecruitmentSubmissionTests.cs @@ -0,0 +1,41 @@ +using DiscordBot.Services.Recruitment; + +namespace DiscordBot.Tests.Recruitment; + +public class RecruitmentSubmissionTests +{ + [Theory] + [InlineData(-1, true, true)] + [InlineData(0, true, true)] + [InlineData(1, true, false)] + [InlineData(-1, false, false)] + public async Task QueuedSubmissionUsesReceiptTimeBeforeDeadlineActions(int secondsFromDeadline, bool correct, bool accepted) + { + using var rig = new RecruitmentRig(); + var service = new RecruitmentService(rig.Settings, rig.Discord, rig.Clock); + var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + try + { + await service.StartAsync(); + rig.Discord.Threads["100"] = new ThreadSnapshot("100", "11", "200", "Example", rig.Clock.Now, + false, false, false, false, Array.Empty(), "Budget AUD 500", rig.Clock.Now.AddYears(-2), rig.Clock.Now.AddMonths(-3)); + service.NotifyThread("100"); + var post = await service.RunAsync((engine, _) => Task.FromResult(engine.State!.Listings["100"])); + var received = post.Deadline!.Value.AddSeconds(secondsFromDeadline); + var code = correct ? post.ValidCodes.Last() : "!!!!!"; + var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var blocked = service.RunAsync(async (_, ct) => { entered.SetResult(); await release.Task.WaitAsync(ct); return true; }); + await entered.Task; + rig.Clock.Now = received; + var submission = service.RunAsync((engine, ct) => engine.AcknowledgeAsync("1", "100", "200", code, ct, received), "100"); + rig.Clock.Now = post.Deadline.Value.AddMinutes(1); + release.SetResult(); await blocked; await submission; + await service.RunAsync(async (engine, ct) => { await engine.RunDueAsync(ct); return true; }); + Assert.Equal(accepted, post.AcceptedAt.HasValue); + Assert.Equal(accepted ? ListingStatus.Open : ListingStatus.Deleted, post.Status); + if (accepted) { Assert.Equal(received, post.AcknowledgedAt); Assert.Empty(rig.Discord.Actions); } + else Assert.Single(rig.Discord.Actions); + } + finally { release.TrySetResult(); await service.StopAsync(default); } + } +} diff --git a/DiscordBot.Tests/Recruitment/RecruitmentTestRig.cs b/DiscordBot.Tests/Recruitment/RecruitmentTestRig.cs index 563ac30c..704e70a8 100644 --- a/DiscordBot.Tests/Recruitment/RecruitmentTestRig.cs +++ b/DiscordBot.Tests/Recruitment/RecruitmentTestRig.cs @@ -173,7 +173,7 @@ public Task SecureReferenceAsync(string forum, string id, bool botOwned, s if (!Threads.TryGetValue(id, out var thread)) return Task.FromResult(false); if (thread.ForumId != forum || (botOwned ? thread.OwnerId != "999" || thread.Starter?.Contains(marker) != true : !thread.OwnerIsStaff)) throw new InvalidOperationException("Reference identity mismatch"); - Threads[id] = thread with { Pinned = true, Locked = true, Archived = true }; + Threads[id] = thread with { Pinned = true, Locked = true, Archived = false }; return Task.FromResult(true); } public Task HasReplyAsync(ThreadSnapshot thread, CancellationToken ct) { ReplyReads++; return Task.FromResult(Replies.Contains(thread.Id)); } diff --git a/DiscordBot.Tests/Recruitment/RecruitmentWorkflowTests.cs b/DiscordBot.Tests/Recruitment/RecruitmentWorkflowTests.cs index 10ce5b35..e35b4afd 100644 --- a/DiscordBot.Tests/Recruitment/RecruitmentWorkflowTests.cs +++ b/DiscordBot.Tests/Recruitment/RecruitmentWorkflowTests.cs @@ -126,45 +126,34 @@ public async Task RestartGivesPendingAttemptFullWindow_AndHoldsIncompleteReplyHi rig.Clock.Advance(TimeSpan.FromHours(3)); await rig.Restart(); var post = rig.State.Listings["100"]; Assert.Equal(rig.Clock.Now.AddMinutes(30), post.Deadline); - Assert.False(post.ResponseCoverageComplete); Assert.Empty(rig.Discord.Actions); - } - - [Fact] - public async Task UnansweredListingClosesOnlyWithContinuousEvidence() - { - using var rig = new RecruitmentRig(); await rig.Start(); var post = await rig.Post(); await rig.Ack(); - rig.Clock.Advance(TimeSpan.FromDays(31)); await rig.Sweep(); - Assert.Equal(ListingStatus.Closed, post.Status); Assert.Contains(("100", "close"), rig.Discord.Actions); - } - - [Fact] - public async Task KnownReplySurvivesDeletionAndRestart() - { - using var rig = new RecruitmentRig(); await rig.Start(); await rig.Post(); await rig.Ack(); - rig.Engine.ObserveReply(new ReplyObservation("100", "333", false, false, false)); - await rig.Restart(); rig.Clock.Advance(TimeSpan.FromDays(31)); await rig.Sweep(); - Assert.True(rig.State.Listings["100"].HasQualifyingReply); Assert.Empty(rig.Discord.Actions); + Assert.Equal(ListingStatus.Open, post.Status); Assert.Empty(rig.Discord.Actions); } [Theory] - [InlineData("200", false, false, false)] - [InlineData("333", true, false, false)] - [InlineData("333", false, true, false)] - [InlineData("333", false, false, true)] - public async Task OwnerBotWebhookAndModeratorDoNotCountAsCommunityReply(string author, bool bot, bool webhook, bool staff) + [InlineData(false)] + [InlineData(true)] + public async Task AcceptedListingsRemainOpenIndefinitelyAcrossRestart(bool legacyReplyCoverage) { - using var rig = new RecruitmentRig(); await rig.Start(); var post = await rig.Post(); - rig.Engine.ObserveReply(new ReplyObservation(post.Id, author, bot, webhook, staff)); - Assert.False(post.HasQualifyingReply); + using var rig = new RecruitmentRig(); await rig.Start(); + var post = await rig.Post(live: false); await rig.Ack(); + post.ResponseCoverageComplete = legacyReplyCoverage; + await rig.Sweep(); await rig.Restart(); + rig.Clock.Advance(TimeSpan.FromDays(400)); await rig.Sweep(); + Assert.Equal(ListingStatus.Open, rig.State.Listings[post.Id].Status); + Assert.Empty(rig.Discord.Actions); Assert.Equal(0, rig.Discord.ReplyReads); } [Fact] - public async Task UnknownRolesHoldUnansweredClosure() + public async Task LegacyPendingUnansweredClosureIsCancelledAfterRestart() { using var rig = new RecruitmentRig(); await rig.Start(); var post = await rig.Post(); await rig.Ack(); - rig.Engine.ObserveReply(new ReplyObservation(post.Id, "333", false, false, null)); - rig.Clock.Advance(TimeSpan.FromDays(31)); await rig.Sweep(); - Assert.False(post.ResponseCoverageComplete); Assert.Equal(ListingStatus.Open, post.Status); + rig.Clock.Advance(TimeSpan.FromDays(31)); + post.PendingAction = new RecruitmentAction { Kind = RecruitmentActionKind.Close, Condition = "unanswered", Automatic = true, CreatedAt = rig.Clock.Now }; + File.WriteAllText(Path.Combine(rig.Root, "recruitment/recruitment-state.json"), Newtonsoft.Json.JsonConvert.SerializeObject(rig.State)); + await rig.Restart(); + Assert.Null(rig.State.Listings[post.Id].PendingAction); + Assert.Equal(ListingStatus.Open, rig.State.Listings[post.Id].Status); + Assert.Empty(rig.Discord.Actions); } [Fact] @@ -249,11 +238,11 @@ public async Task StaffAuthorizationIsEnforcedAtExecution_AndWaiverDoesNotFreeSl } [Fact] - public async Task ReopenChecksCapacityAndSuppressesImmediateRepeatClosure() + public async Task ReopenKeepsListingOpenWithoutAnExpiry() { using var rig = new RecruitmentRig(); await rig.Start(); await rig.Post(); await rig.Ack(); rig.Clock.Advance(TimeSpan.FromDays(31)); await rig.Sweep(); await rig.Staff("reopen"); await rig.Sweep(); Assert.Equal(ListingStatus.Open, rig.State.Listings["100"].Status); - Assert.True(rig.State.Listings["100"].SuppressUnansweredClose); + Assert.Equal(ListingStatus.Open, rig.State.Listings["100"].Status); } } diff --git a/DiscordBot/Assets/recruitment/guidelines/hobby-for-hire.md b/DiscordBot/Assets/recruitment/guidelines/hobby-for-hire.md index 3839f53d..9513c5f2 100644 --- a/DiscordBot/Assets/recruitment/guidelines/hobby-for-hire.md +++ b/DiscordBot/Assets/recruitment/guidelines/hobby-for-hire.md @@ -1,13 +1,13 @@ # Hobby for hire -Describe your skills, examples, availability and expectations for unpaid collaboration. +State **skills**, **original work samples**, **availability**, **project interests**, **collaboration expectations**, and **contact**. Explain **unpaid or revenue-share** terms. No vague “DM for details” posts or misleading portfolios. -Read the pinned information post at the top of this forum for fuller guidance. Use relevant tags; verify claims and agree terms in writing. +Read the pinned rules; use accurate tags. Verify claims and agree terms in writing. -You may have **one open listing total across all four recruitment forums**. Combine hiring and offering services in a for-hire listing. +**One open listing across all four recruitment forums.** Combine hiring and service offers in a for-hire listing. -Enter **{{code}}** using **Acknowledge** within **{{acknowledgementMinutes}} minutes of the bot's prompt**. Unacknowledged posts are removed; acknowledged posts still blocked by another listing or cooldown are closed. +Enter **{{code}}** via **Acknowledge** within **{{acknowledgementMinutes}} minutes** of the bot prompt. Unacknowledged posts are removed; acknowledged posts with listing/cooldown conflicts are closed. -Only accepted listings start a **{{cooldownDays}}-day cooldown from creation**. Closing/deleting does not change it; unaccepted attempts add none. +Accepted listings start a **{{cooldownDays}}-day cooldown** from creation. Closing/deleting won't reset it; unaccepted attempts add none. -Mark finished listings **Closed** to lock/archive them. Accepted listings with no qualifying community reply close after **{{unansweredDays}} days**. Natural auto-archive does not free your slot. +Keep this listing updated or bump it when still looking; its history stays visible. Mark finished listings **Closed** to lock/archive. Auto-archive does not free your slot. diff --git a/DiscordBot/Assets/recruitment/guidelines/hobby-recruiting.md b/DiscordBot/Assets/recruitment/guidelines/hobby-recruiting.md index c4d50e98..77de27e4 100644 --- a/DiscordBot/Assets/recruitment/guidelines/hobby-recruiting.md +++ b/DiscordBot/Assets/recruitment/guidelines/hobby-recruiting.md @@ -1,13 +1,13 @@ # Hobby recruiting -Describe the project, progress, skills and time needed. Clearly state unpaid or revenue-share terms. +State the **project and progress**, **roles/skills needed**, **tasks**, **time commitment**, **contact**, and **unpaid or revenue-share terms**. Show existing work where possible. Do not portray potential revenue as guaranteed pay. -Read the pinned information post at the top of this forum for fuller guidance. Use relevant tags; verify claims and agree terms in writing. +Read the pinned rules; use accurate tags. Verify claims and agree terms in writing. -You may have **one open listing total across all four recruitment forums**. Combine hiring and offering services in a for-hire listing. +**One open listing across all four recruitment forums.** Combine hiring and service offers in a for-hire listing. -Enter **{{code}}** using **Acknowledge** within **{{acknowledgementMinutes}} minutes of the bot's prompt**. Unacknowledged posts are removed; acknowledged posts still blocked by another listing or cooldown are closed. +Enter **{{code}}** via **Acknowledge** within **{{acknowledgementMinutes}} minutes** of the bot prompt. Unacknowledged posts are removed; acknowledged posts with listing/cooldown conflicts are closed. -Only accepted listings start a **{{cooldownDays}}-day cooldown from creation**. Closing/deleting does not change it; unaccepted attempts add none. +Accepted listings start a **{{cooldownDays}}-day cooldown** from creation. Closing/deleting won't reset it; unaccepted attempts add none. -Mark finished listings **Closed** to lock/archive them. Accepted listings with no qualifying community reply close after **{{unansweredDays}} days**. Natural auto-archive does not free your slot. +Keep this listing updated or bump it when still looking; its history stays visible. Mark finished listings **Closed** to lock/archive. Auto-archive does not free your slot. diff --git a/DiscordBot/Assets/recruitment/guidelines/paid-for-hire.md b/DiscordBot/Assets/recruitment/guidelines/paid-for-hire.md index 88dadd08..c3d87dcd 100644 --- a/DiscordBot/Assets/recruitment/guidelines/paid-for-hire.md +++ b/DiscordBot/Assets/recruitment/guidelines/paid-for-hire.md @@ -1,13 +1,13 @@ # Paid for hire -Describe your services, portfolio, availability, rates, currency and contact details. +State **services/deliverables**, **proof of work** (portfolio or samples), **availability**, **rates and currency**, and **contact method**. Vague “DM for details” posts or false claims may be removed. -Read the pinned information post at the top of this forum for fuller guidance. Use relevant tags; verify claims and agree terms in writing. +Read the pinned rules and use accurate tags. Verify claims; agree scope, deadlines, fees and payment terms in writing. -You may have **one open listing total across all four recruitment forums**. Combine hiring and offering services in a for-hire listing. +**One open listing total across all four recruitment forums.** Combine hiring and service offers here. -Enter **{{code}}** using **Acknowledge** within **{{acknowledgementMinutes}} minutes of the bot's prompt**. Unacknowledged posts are removed; acknowledged posts still blocked by another listing or cooldown are closed. +Enter **{{code}}** via **Acknowledge** within **{{acknowledgementMinutes}} minutes** of the bot prompt. Unacknowledged posts are removed; posts blocked by an existing listing or cooldown are closed. -Only accepted listings start a **{{cooldownDays}}-day cooldown from creation**. Closing/deleting does not change it; unaccepted attempts add none. +Only accepted listings start a **{{cooldownDays}}-day cooldown** from creation. Closing/deleting won't reset it; unaccepted attempts add none. -Mark finished listings **Closed** to lock/archive them. Accepted listings with no qualifying community reply close after **{{unansweredDays}} days**. Natural auto-archive does not free your slot. +Keep this listing updated or bump it when still looking; its history stays visible. Mark finished listings **Closed** to lock/archive. Auto-archive does not free your slot. diff --git a/DiscordBot/Assets/recruitment/guidelines/paid-recruiting.md b/DiscordBot/Assets/recruitment/guidelines/paid-recruiting.md index be3f7fb7..8e435ac1 100644 --- a/DiscordBot/Assets/recruitment/guidelines/paid-recruiting.md +++ b/DiscordBot/Assets/recruitment/guidelines/paid-recruiting.md @@ -1,13 +1,13 @@ # Paid recruiting -Describe the work, skills needed, budget, currency, payment schedule and contact details. +Describe **work/deliverables**, **skills**, **budget/rate and currency**, **payment schedule**, **timeline**, and **contact**. Name the project/organisation. No applicant fees, misleading pay claims, or “DM for details” listings. -Read the pinned information post at the top of this forum for fuller guidance. Use relevant tags; verify claims and agree terms in writing. +Read the pinned rules; use accurate tags. Verify claims and agree terms in writing. -You may have **one open listing total across all four recruitment forums**. Combine hiring and offering services in a for-hire listing. +**One open listing across all four recruitment forums.** Combine hiring and service offers in a for-hire listing. -Enter **{{code}}** using **Acknowledge** within **{{acknowledgementMinutes}} minutes of the bot's prompt**. Unacknowledged posts are removed; acknowledged posts still blocked by another listing or cooldown are closed. +Enter **{{code}}** via **Acknowledge** within **{{acknowledgementMinutes}} minutes** of the bot prompt. Unacknowledged posts are removed; acknowledged posts with listing/cooldown conflicts are closed. -Only accepted listings start a **{{cooldownDays}}-day cooldown from creation**. Closing/deleting does not change it; unaccepted attempts add none. +Accepted listings start a **{{cooldownDays}}-day cooldown** from creation. Closing/deleting won't reset it; unaccepted attempts add none. -Mark finished listings **Closed** to lock/archive them. Accepted listings with no qualifying community reply close after **{{unansweredDays}} days**. Natural auto-archive does not free your slot. +Keep this listing updated or bump it when still looking; its history stays visible. Mark finished listings **Closed** to lock/archive. Auto-archive does not free your slot. diff --git a/DiscordBot/Modules/Recruitment/RecruitmentModule.cs b/DiscordBot/Modules/Recruitment/RecruitmentModule.cs index daabfbec..c7c77fec 100644 --- a/DiscordBot/Modules/Recruitment/RecruitmentModule.cs +++ b/DiscordBot/Modules/Recruitment/RecruitmentModule.cs @@ -36,7 +36,6 @@ public async Task Status() public Task Manage( [Choice("Exempt", "exempt"), Choice("Remove exemption", "unexempt"), Choice("Reset acknowledgement/timeouts", "reset"), Choice("Reset timeout streak", "reset-timeouts"), Choice("Close", "close"), Choice("Reopen", "reopen"), Choice("Designate reference post", "reference"), - Choice("Confirm no qualifying reply", "review-no-reply"), Choice("Confirm qualifying reply", "review-reply"), Choice("Confirm unavailable thread is absent", "review-absent")] string operation, string threadId, string reason) => Staff(operation, threadId, reason); [SlashCommand("waive-cooldown", "Waive an author's creation cooldown without granting a second slot")] diff --git a/DiscordBot/Services/Recruitment/DiscordRecruitmentAdapter.cs b/DiscordBot/Services/Recruitment/DiscordRecruitmentAdapter.cs index 223f1077..c0aa8671 100644 --- a/DiscordBot/Services/Recruitment/DiscordRecruitmentAdapter.cs +++ b/DiscordBot/Services/Recruitment/DiscordRecruitmentAdapter.cs @@ -226,21 +226,6 @@ private async Task SnapshotAsync(RestThreadChannel thread, Cance return snapshot; } - public async Task HasReplyAsync(ThreadSnapshot thread, CancellationToken ct) - { - var channel = await MessageChannelAsync(thread.Id, ct); - var unknown = false; - // Positive evidence can be recovered; this scan never certifies complete historical coverage. - foreach (var message in await channel.GetMessagesAsync(100, CacheMode.AllowDownload, Options(ct)).FlattenAsync()) - { - if (message.Id.ToString() == thread.Id || message.Author.Id.ToString() == thread.OwnerId || message.Author.IsBot || message.Source == MessageSource.Webhook) continue; - var user = await _client.Rest.GetGuildUserAsync(_settings.GuildId, message.Author.Id, Options(ct)); - if (user is null) { unknown = true; continue; } - if (!user.GuildPermissions.Administrator && !user.RoleIds.Contains(_settings.Roles.Moderator)) return true; - } - return unknown ? null : false; - } - private async Task MessageChannelAsync(string id, CancellationToken ct) { if (_messageChannels.TryGetValue(id, out var cached)) return cached; diff --git a/DiscordBot/Services/Recruitment/DiscordRecruitmentReferences.cs b/DiscordBot/Services/Recruitment/DiscordRecruitmentReferences.cs index 5141a20d..8ed87785 100644 --- a/DiscordBot/Services/Recruitment/DiscordRecruitmentReferences.cs +++ b/DiscordBot/Services/Recruitment/DiscordRecruitmentReferences.cs @@ -39,13 +39,13 @@ public async Task SecureReferenceAsync(string forumId, string threadId, bo } else if (!await IsModeratorAsync(_settings.GuildId.ToString(), ((IThreadChannel)thread).OwnerId.ToString(), ct)) throw new InvalidOperationException("Existing reference is no longer owned by a moderator or administrator."); - if (thread.IsLocked && thread.IsArchived && thread.Flags.HasFlag(ChannelFlags.Pinned)) return true; + if (thread.IsLocked && !thread.IsArchived && thread.Flags.HasFlag(ChannelFlags.Pinned)) return true; // Forum threads inherit permissions. Locking is the supported member-posting restriction; - // staff with Manage Threads can reopen it to maintain their own messages below the introduction. + // Staff with Manage Threads can maintain it. Archiving would clear its forum pin. if (thread.IsArchived) await thread.ModifyAsync(p => { p.Archived = false; p.Flags = thread.Flags; }, Options(ct)); - await thread.ModifyAsync(p => { p.Flags = thread.Flags | ChannelFlags.Pinned; p.Locked = true; p.Archived = true; }, Options(ct)); + await thread.ModifyAsync(p => { p.Flags = thread.Flags | ChannelFlags.Pinned; p.Locked = true; p.Archived = false; }, Options(ct)); var verified = await _client.Rest.GetChannelAsync(thread.Id, Options(ct)) as RestThreadChannel; - if (verified is null || !verified.IsArchived || !verified.IsLocked || !verified.Flags.HasFlag(ChannelFlags.Pinned)) + if (verified is null || verified.IsArchived || !verified.IsLocked || !verified.Flags.HasFlag(ChannelFlags.Pinned)) throw new InvalidOperationException("Reference pin/lock was not confirmed."); InvalidateFacts(threadId); return true; diff --git a/DiscordBot/Services/Recruitment/IRecruitmentDiscord.cs b/DiscordBot/Services/Recruitment/IRecruitmentDiscord.cs index c07ce037..d496dfbd 100644 --- a/DiscordBot/Services/Recruitment/IRecruitmentDiscord.cs +++ b/DiscordBot/Services/Recruitment/IRecruitmentDiscord.cs @@ -7,7 +7,6 @@ public sealed record ThreadSnapshot(string Id, string ForumId, string OwnerId, s DateTimeOffset? AccountCreatedAt, DateTimeOffset? JoinedAt, bool OwnerIsStaff = false, int? MemberRoleCount = null); public sealed record ArchiveCursor(DateTimeOffset Since, DateTimeOffset? Before = null, DateTimeOffset? StartedAt = null); public sealed record ThreadInventory(IReadOnlyList Threads, bool Complete, IReadOnlyDictionary? Cursors = null); -public sealed record ReplyObservation(string ThreadId, string AuthorId, bool IsBot, bool IsWebhook, bool? IsStaff); public sealed record RecruitmentMessage(string Content, string Marker, bool OwnerControls, bool RestoreArchive = false, Embed? Embed = null, string? TextFallback = null) { public string RenderKey => Content + "\n" + OwnerControls + "\n" + EmbedKey(Embed); @@ -32,7 +31,6 @@ public interface IRecruitmentDiscord void InvalidateThread(string threadId); void InvalidateFacts(string threadId); void InvalidateCache(); - Task HasReplyAsync(ThreadSnapshot thread, CancellationToken ct); // Ambiguous sends must search the entire bounded recovery interval or hold; never blindly resend. Task UpsertAsync(string channelId, string? messageId, bool recovering, DateTimeOffset since, RecruitmentMessage message, Action beforeWrite, CancellationToken ct); diff --git a/DiscordBot/Services/Recruitment/RecruitmentAssets.cs b/DiscordBot/Services/Recruitment/RecruitmentAssets.cs index 3ccaacba..58943123 100644 --- a/DiscordBot/Services/Recruitment/RecruitmentAssets.cs +++ b/DiscordBot/Services/Recruitment/RecruitmentAssets.cs @@ -29,7 +29,7 @@ public RecruitmentAssets(string root, RecruitmentSettings settings) { var template = File.ReadAllText(Path.Combine(directory, "guidelines", purpose + ".md")); if (template.Split("{{code}}").Length != 2 || - new[] { "acknowledgementMinutes", "cooldownDays", "unansweredDays" }.Any(p => !template.Contains("{{" + p + "}}"))) + new[] { "acknowledgementMinutes", "cooldownDays" }.Any(p => !template.Contains("{{" + p + "}}"))) throw new InvalidDataException($"Invalid Guidelines placeholders for {purpose}."); Templates[purpose] = template; if (!Tags.TryGetValue(purpose, out var tags) || tags.Count is < 1 or > 20 || diff --git a/DiscordBot/Services/Recruitment/RecruitmentEngine.cs b/DiscordBot/Services/Recruitment/RecruitmentEngine.cs index 8d999af0..88297ca3 100644 --- a/DiscordBot/Services/Recruitment/RecruitmentEngine.cs +++ b/DiscordBot/Services/Recruitment/RecruitmentEngine.cs @@ -63,7 +63,6 @@ public void MarkEvidenceGap(bool invalidateAccess = true) _verifiedReferences.Clear(); _referenceCandidates.Clear(); _ignoredThreads.Clear(); foreach (var post in State.Listings.Values.Where(p => p.Status == ListingStatus.Open)) { - post.ResponseCoverageComplete = false; if (!post.AcceptedAt.HasValue) _recoverWindows.Add(post.Id); } Save(); @@ -76,7 +75,7 @@ public async Task SweepAsync(CancellationToken ct, bool retryHeld = true) var auditDue = _nextAudit <= Now; var held = !retryHeld && State.RecoveryFault is { } recovery && !recovery.IsDue(Now); _nextAudit = Now.AddMinutes(15 + Random.Shared.NextDouble() * 3); - _publicRenders.Clear(); _staffRenders.Clear(); _replyScanned.Clear(); + _publicRenders.Clear(); _staffRenders.Clear(); _discord.InvalidateCache(); try { @@ -199,15 +198,6 @@ public Task ObserveAsync(ThreadSnapshot live, bool observedFromCreation, Cancell return Task.CompletedTask; } - public void ObserveReply(ReplyObservation reply) - { - if (State?.Listings.GetValueOrDefault(reply.ThreadId) is not { } post || post.Status != ListingStatus.Open || - reply.AuthorId == post.OwnerId || reply.IsBot || reply.IsWebhook) return; - if (reply.IsStaff == false) post.HasQualifyingReply = true; - if (reply.IsStaff is null) post.ResponseCoverageComplete = false; - Save(); - } - private async Task ProcessAsync(Listing post, CancellationToken ct) { if (post.Status == ListingStatus.Deleted) @@ -258,9 +248,6 @@ private async Task ProcessAsync(Listing post, CancellationToken ct) if (post.StaffSendPending || post.PublicSendPending || post.PublicationPending) await PublishAsync(post, ct); return; } - var reply = post.HasQualifyingReply ? true : _replyScanned.Add(post.Id) ? await _discord.HasReplyAsync(live, ct) : false; - if (reply == true) post.HasQualifyingReply = true; - if (reply is null) post.ResponseCoverageComplete = false; if (_recoverWindows.Remove(post.Id)) { post.PromptDeliveredAt = post.Deadline = null; @@ -292,8 +279,6 @@ private async Task ProcessAsync(Listing post, CancellationToken ct) else await BeginActionAsync(post, RecruitmentActionKind.Close, "system", "Acknowledged but still ineligible at deadline", "ineligible", true, ct); } - else if (RecruitmentPolicy.CanCloseUnanswered(post, Now, _settings.UnansweredDays)) - await BeginActionAsync(post, RecruitmentActionKind.Close, "system", "No qualifying reply within the listing lifetime", "unanswered", true, ct); } private void RecordAbsence(Listing post) @@ -387,10 +372,6 @@ public async Task AcknowledgeAsync(string guild, string thread, string o // Startup/recovery establishes cross-forum identity coverage; ordinary acknowledgements only // refresh the candidate and its blocking author records. if (!_ready || !_feedReady || _feedGeneration != _evidenceGeneration()) return "Recruitment is waiting for recovery. Please try again shortly."; - _discord.InvalidateThread(post.Id); - post.Fault = null; - await ProcessAsync(post, ct); - await RefreshBlockersAsync(post, ct); if (post.Status != ListingStatus.Open || post.AcceptedAt.HasValue) return "This listing no longer needs acknowledgement."; var submittedAt = receivedAt ?? Now; if (!post.Deadline.HasValue || post.Deadline < submittedAt || post.PromptDeliveredAt > submittedAt) @@ -398,12 +379,19 @@ public async Task AcknowledgeAsync(string guild, string thread, string o if (post.LastCodeAttemptAt?.AddSeconds(5) > Now) return "Please wait a few seconds before trying again."; post.LastCodeAttemptAt = Now; Save(); if (!post.ValidCodes.Contains(code.Trim().ToUpperInvariant())) return "That code does not match. Read the native Guidelines for this forum and try again."; - post.AcknowledgedAt = Now; post.Version++; post.UpdatedAt = Now; + // Persist a timely valid submission before any processing can execute an overdue timeout. + // Invalid/late submissions never extend the window or fence the deadline worker. + post.AcknowledgedAt = submittedAt; post.Version++; post.UpdatedAt = Now; var author = RecruitmentPolicy.Author(State!, owner, Now); author.ConsecutiveTimeouts = 0; author.TimeoutAlerted = false; - var accepted = EvidenceCurrent && RecruitmentPolicy.TryAccept(State!, post, Now, _settings.CooldownDays); - Save(); await PublishAsync(post, ct); - return accepted ? "Guidelines acknowledged. Your listing is accepted." : !EvidenceCurrent + Save(); + _discord.InvalidateThread(post.Id); + post.Fault = null; + await ProcessAsync(post, ct); + if (post.PendingAction is null && post.Status == ListingStatus.Open && !post.AcceptedAt.HasValue) + await ProcessAsync(post, ct); // A recovered timeout intent may have just been cancelled. + if (post.Status != ListingStatus.Open) return "This listing is no longer open. Use your listing controls to check its status."; + return post.AcceptedAt.HasValue ? "Guidelines acknowledged. Your listing is accepted." : !EvidenceCurrent ? "Guidelines acknowledged. Acceptance is waiting for inventory recovery." : "Guidelines acknowledged. The existing listing or creation cooldown still blocks acceptance; see the advisory."; } @@ -457,6 +445,13 @@ private async Task BeginActionAsync(Listing post, RecruitmentActionKind kind, st private async Task ExecuteActionAsync(Listing post, CancellationToken ct) { var action = post.PendingAction!; + if (action.Automatic && action.Condition == "unanswered") + { + // Old state may contain an unsent expiry action. Listings now remain indefinitely. + post.PendingAction = null; post.PublicationPending = true; + post.LastOutcome = "Automatic expiry cancelled; listing remains open"; + Save(); return; + } if (action.RequiresModerator && !await _discord.IsModeratorAsync(State!.GuildId, action.ActorId, ct)) { post.PendingAction = null; post.LastOutcome = "Staff action cancelled: moderator access no longer present"; @@ -491,18 +486,8 @@ private async Task ExecuteActionAsync(Listing post, CancellationToken ct) if (action.Automatic && (!EvidenceCurrent || post.Exempt || post.Pinned || post.Status != ListingStatus.Open || (action.Condition == "timeout" && (post.AcknowledgedAt.HasValue || post.AcceptedAt.HasValue || post.Deadline > Now || _recoverWindows.Contains(post.Id))) || (action.Condition == "ineligible" && (post.AcceptedAt.HasValue || RecruitmentPolicy.Evaluate(State!, post, Now, _settings.CooldownDays).Allowed)) || - (action.Condition == "closed-tag" && !live.Tags.Contains(ClosingTag(post))) || - (action.Condition == "unanswered" && !RecruitmentPolicy.CanCloseUnanswered(post, Now, _settings.UnansweredDays)))) + (action.Condition == "closed-tag" && !live.Tags.Contains(ClosingTag(post))))) { post.PendingAction = null; Save(); return; } - if (action.Condition == "unanswered") - { - var reply = await _discord.HasReplyAsync(live, ct); - if (reply != false) - { - post.HasQualifyingReply |= reply == true; post.ResponseCoverageComplete &= reply.HasValue; - post.PendingAction = null; Save(); return; - } - } if (action.Kind == RecruitmentActionKind.Reopen && State!.Listings.Values.Any(p => p.OwnerId == post.OwnerId && p.Id != post.Id && p.Status == ListingStatus.Open)) throw new InvalidOperationException("Another listing occupies this author's slot."); // Gateway facts and timely owner interactions already queued must be applied before an automatic action. @@ -531,7 +516,7 @@ private async Task ExecuteActionAsync(Listing post, CancellationToken ct) break; case RecruitmentActionKind.Reopen: await _discord.ReopenAsync(live, ClosingTag(post), ct); - post.Status = ListingStatus.Open; post.SuppressUnansweredClose = true; + post.Status = ListingStatus.Open; if (!post.AcceptedAt.HasValue) _recoverWindows.Add(post.Id); break; } @@ -623,9 +608,7 @@ public async Task StaffAsync(string guild, string actor, string operatio if (State.Listings.Values.Any(p => p.OwnerId == post.OwnerId && p.Id != post.Id && p.Status == ListingStatus.Open)) return "Another listing occupies this author's slot."; await BeginActionAsync(post, RecruitmentActionKind.Reopen, actor, reason, "", false, ct, requiresModerator: true); return post.Status == ListingStatus.Open - ? "Listing reopened; automatic unanswered closure is suppressed for this listing." : "Reopening was cancelled or is awaiting reconciliation."; - case "review-no-reply": post.ResponseCoverageComplete = true; break; - case "review-reply": post.HasQualifyingReply = true; break; + ? "Listing reopened. Update this thread to preserve its conversation history." : "Reopening was cancelled or is awaiting reconciliation."; case "review-absent": if (!post.AvailabilityUnknown) return "Availability is known; use close or reconcile instead."; post.Status = ListingStatus.Deleted; post.AvailabilityUnknown = false; post.PendingAction = null; diff --git a/DiscordBot/Services/Recruitment/RecruitmentEngineScheduling.cs b/DiscordBot/Services/Recruitment/RecruitmentEngineScheduling.cs index 913dc13c..56126ada 100644 --- a/DiscordBot/Services/Recruitment/RecruitmentEngineScheduling.cs +++ b/DiscordBot/Services/Recruitment/RecruitmentEngineScheduling.cs @@ -8,7 +8,6 @@ public sealed partial class RecruitmentEngine private long _inventoryGeneration = -1; private DateTimeOffset _nextAudit = DateTimeOffset.MaxValue; private bool _accessInvalid = true; - private readonly HashSet _replyScanned = new(); private readonly Dictionary _publicRenders = new(); private readonly Dictionary _staffRenders = new(); private bool EvidenceCurrent => _gatewayReady() && !_pendingIdentities() && _inventoryComplete && _inventoryGeneration == _evidenceGeneration(); @@ -57,8 +56,6 @@ private DateTimeOffset? NextWorkAt if (!post.Exempt && !post.Pinned) { if (!post.AcceptedAt.HasValue && post.Deadline.HasValue) dates.Add(post.Deadline.Value); - if (post.AcceptedAt.HasValue && post.ResponseCoverageComplete && !post.HasQualifyingReply && !post.SuppressUnansweredClose) - dates.Add(post.CreatedAt.AddDays(_settings.UnansweredDays)); } if (post.AcknowledgedAt.HasValue && !post.AcceptedAt.HasValue) { @@ -177,7 +174,6 @@ private async Task ProcessSafelyAsync(Listing post, CancellationToken ct, bool v } catch (Exception ex) when (ex is not OperationCanceledException && ex is not System.IO.IOException) { - post.ResponseCoverageComplete = false; _discord.InvalidateThread(post.Id); post.Fault = RecruitmentFault.From(ex, Now, previousFault); post.Hold = post.Fault.Message; diff --git a/DiscordBot/Services/Recruitment/RecruitmentPresentation.cs b/DiscordBot/Services/Recruitment/RecruitmentPresentation.cs index e3d23796..5ff3c6f1 100644 --- a/DiscordBot/Services/Recruitment/RecruitmentPresentation.cs +++ b/DiscordBot/Services/Recruitment/RecruitmentPresentation.cs @@ -94,7 +94,6 @@ public static RecruitmentMessage Staff(RecruitmentState state, Listing post, Rec $"https://discord.com/channels/{state.GuildId}/{post.Id}\n" + $"Created {Timestamp(post.CreatedAt)} · acknowledged {(post.AcknowledgedAt.HasValue ? "yes" : "no")} · accepted {(post.AcceptedAt.HasValue ? "yes" : "no")}\n" + $"Next creation: {(next.HasValue ? Timestamp(next.Value) : "no accepted cooldown")}\n" + - $"Response: {(post.HasQualifyingReply ? "qualifying reply recorded" : post.ResponseCoverageComplete ? "none observed" : "history incomplete; unanswered closure held")}\n" + $"Outcome: {Safe(post.LastOutcome)}\n"; if (post.PendingAction is { } action) text += $"Pending {action.Kind}: {Safe(action.Reason)} · actor `{action.ActorId}` · action `{action.Id}`\n"; diff --git a/DiscordBot/Services/Recruitment/RecruitmentService.cs b/DiscordBot/Services/Recruitment/RecruitmentService.cs index d6378959..94086d93 100644 --- a/DiscordBot/Services/Recruitment/RecruitmentService.cs +++ b/DiscordBot/Services/Recruitment/RecruitmentService.cs @@ -184,12 +184,6 @@ private bool Enqueue(string key, Func run, WorkKind kin } internal void NotifyThread(string id) => Enqueue("thread:" + id, ct => _engine.ThreadChangedAsync(id, ct), thread: id); - internal void NotifyReply(ReplyObservation reply) => - Enqueue($"reply:{reply.ThreadId}:{reply.AuthorId}:{reply.IsStaff}", async ct => - { - if (_engine.State?.Listings.ContainsKey(reply.ThreadId) != true) await _engine.ThreadChangedAsync(reply.ThreadId, ct); - _engine.ObserveReply(reply); - }, thread: reply.ThreadId); internal void NotifyFactsChanged(string id) => Enqueue("facts:" + id, ct => _engine.FactsChangedAsync(id, ct), thread: id); private Task MessageUpdated(Cacheable before, SocketMessage after, ISocketMessageChannel channel) @@ -231,11 +225,6 @@ private Task MessageReceived(SocketMessage message) Enqueue("feed-activity", _ => { _engine.FeedActivity(message.Id.ToString(), false); return Task.CompletedTask; }); return Task.CompletedTask; } - if (message.Channel is not SocketThreadChannel thread || !IsRecruitment(thread) || message.Author.IsBot || message.Source == MessageSource.Webhook || - message.Author.Id == ((IThreadChannel)thread).OwnerId) return Task.CompletedTask; - var user = message.Author as SocketGuildUser; - NotifyReply(new ReplyObservation(thread.Id.ToString(), message.Author.Id.ToString(), false, false, - user is null ? null : user.GuildPermissions.Administrator || user.Roles.Any(r => r.Id == _settings.Roles.Moderator))); return Task.CompletedTask; } private Task MessageDeleted(Cacheable message, Cacheable channel) => diff --git a/DiscordBot/Services/Recruitment/RecruitmentState.cs b/DiscordBot/Services/Recruitment/RecruitmentState.cs index 1a041539..e26c0045 100644 --- a/DiscordBot/Services/Recruitment/RecruitmentState.cs +++ b/DiscordBot/Services/Recruitment/RecruitmentState.cs @@ -107,6 +107,7 @@ public sealed class Listing public bool StaffSendPending { get; set; } public bool Exempt { get; set; } public bool Pinned { get; set; } + // Legacy v2 fields remain readable; reply history no longer determines listing lifetime. public bool HasQualifyingReply { get; set; } public bool ResponseCoverageComplete { get; set; } public bool AvailabilityUnknown { get; set; } @@ -203,11 +204,6 @@ public static AuthorHistory Author(RecruitmentState state, string owner, DateTim return history; } - public static bool CanCloseUnanswered(Listing post, DateTimeOffset now, int days) => - post.Status == ListingStatus.Open && post.AcceptedAt.HasValue && !post.Exempt && !post.Pinned && - !post.AvailabilityUnknown && post.ResponseCoverageComplete && !post.HasQualifyingReply && - !post.SuppressUnansweredClose && post.CreatedAt.AddDays(days) <= now; - public static void Prune(RecruitmentState state, DateTimeOffset now, int postMonths, int authorMonths, int cooldownDays) { var floor = now.AddMonths(-postMonths); diff --git a/DiscordBot/Settings/Deserialized/Settings.cs b/DiscordBot/Settings/Deserialized/Settings.cs index 7ab2eb33..b4fe1532 100644 --- a/DiscordBot/Settings/Deserialized/Settings.cs +++ b/DiscordBot/Settings/Deserialized/Settings.cs @@ -108,7 +108,7 @@ public class RecruitmentSettings public RecruitmentForums Forums { get; set; } = new(); public int AcknowledgementMinutes { get; set; } = 30; public int CooldownDays { get; set; } = 30; - public int UnansweredDays { get; set; } = 30; + public int UnansweredDays { get; set; } = 30; // Legacy configuration; listings no longer expire. public int PostRetentionMonths { get; set; } = 12; public int AuthorRetentionMonths { get; set; } = 24; @@ -120,8 +120,8 @@ public List Validate() errors.Add("all four named forum IDs must be nonzero and distinct"); if (FeedChannelId == 0 || (forums?.Values.Contains(FeedChannelId) ?? false)) errors.Add("FeedChannelId must identify a separate staff text channel"); - if (AcknowledgementMinutes is < 1 or > 1440 || CooldownDays is < 1 or > 365 || UnansweredDays is < 1 or > 365) - errors.Add("durations must be positive (acknowledgement at most 1440 minutes; cooldown/unanswered at most 365 days)"); + if (AcknowledgementMinutes is < 1 or > 1440 || CooldownDays is < 1 or > 365) + errors.Add("durations must be positive (acknowledgement at most 1440 minutes; cooldown at most 365 days)"); if (PostRetentionMonths is < 1 or > 120 || AuthorRetentionMonths < PostRetentionMonths || AuthorRetentionMonths > 120) errors.Add("retention must be 1–120 months, with author retention at least post retention"); return errors; diff --git a/docs/recruitment.md b/docs/recruitment.md index b38e5555..1d289ded 100644 --- a/docs/recruitment.md +++ b/docs/recruitment.md @@ -13,7 +13,7 @@ Recruitment is disabled in the example, development and production settings. It - At the deadline, an unacknowledged attempt is removed. An acknowledged attempt still blocked by another listing/cooldown is locked and archived. Pins and explicit exemptions protect against automatic actions. - Only **accepted** listings start a cooldown, measured from the thread's original creation time. Closing/deleting on day 2 leaves 28 days; doing so on day 40 adds no wait. An open listing still occupies the slot after day 30, including when Discord naturally archives it. Removing an unaccepted attempt creates no cooldown. - The owner can close the listing or confirm removal through an expiring, single-use control. Accepted cooldowns survive removal. The `Closed` tag also requests lock-and-archive. Existing unrelated applied tags are preserved if there is no room for another tag. -- Accepted listings without a qualifying reply close after 30 days. Replies from other humans count; owner, bot, webhook, configured moderator and administrator replies do not. Recorded qualifying evidence survives message deletion. Incomplete response history holds unanswered closure for staff review. Discord can emit ThreadCreated when the bot gains access to an existing thread, so that event alone does not certify complete historical reply coverage. +- Accepted listings remain indefinitely until the owner or a moderator closes/removes them. Owners can edit or bump the existing thread to preserve its conversation history. Natural auto-archive does not free the slot. Legacy `UnansweredDays` and reply-history fields are accepted when loading old configuration/state but no longer control expiry, and pending automatic unanswered closures are cancelled. - Public replies and owner editing remain available while a listing is open. There is no short edit lock, reply deletion, or parent permission rewrite. Configured recruitment forums and their threads do not earn new XP, including while recruitment is disabled; existing totals are unchanged. ## Configure new forums @@ -32,7 +32,6 @@ Set these fields under `Recruitment` in the existing settings file: }, "AcknowledgementMinutes": 30, "CooldownDays": 30, - "UnansweredDays": 30, "PostRetentionMonths": 12, "AuthorRetentionMonths": 24 } @@ -54,7 +53,7 @@ Discord.Net 3.20.1 drops tag moderation during forum serialization and can send The persisted code rotates on Monday at 00:00 UTC. Publication is verified per forum; an interrupted rotation retries the same code. A challenge accepts its issued code and codes subsequently confirmed visible during its window. Codes are not included in advisories, staff entries, previews or diagnostic messages. -After setup and complete discovery, the bot reuses a discovered pinned staff reference or creates **Read first · Recruitment information** in each forum. The short introduction directs readers to staff guidance below and the native Guidelines; it does not repeat the rotating code. The reference uses the existing Closed tag, is pinned, locked and archived, and is excluded from member challenges and capacity. Creation intent and the returned thread ID are saved before later writes. Interrupted creates recover by a bot-owned marker; incomplete inventory prevents duplicate creation. A deleted reference is replaced after discovery, and status links to each reference or shows its recovery fault. +After setup and complete discovery, the bot reuses a discovered pinned staff reference or creates **Read first · Recruitment information** in each forum. The short introduction directs readers to staff guidance below and the native Guidelines; it does not repeat the rotating code. The reference uses the existing Closed tag, is pinned, locked and active (archiving would clear its pin), and is excluded from member challenges and capacity. Creation intent and the returned thread ID are saved before later writes. Interrupted creates recover by a bot-owned marker; incomplete inventory prevents duplicate creation. A deleted reference is replaced after discovery, and status links to each reference or shows its recovery fault. Forum posts inherit parent permissions, so a single reference cannot have admin-only permission overwrites. Administrators and moderators with **Manage Threads** can reopen it, add or edit their own messages, then close it again. The bot preserves those messages and rechecks the pin/lock on startup, recovery or an explicit repair/reconcile. It does not immediately close a thread while staff are editing. [Discord thread permissions](https://docs.discord.com/developers/topics/threads) @@ -74,7 +73,7 @@ All commands are ephemeral and check the configured guild and the caller's curre | `/recruitment manage operation thread-id reason` | Exempt/unexempt, reset a pending acknowledgement, reset timeout streak, designate reference, close/reopen, or review uncertain evidence. | | `/recruitment waive-cooldown author reason` | Clear the author's creation cooldown; never grants a second slot. | -**Confirm qualifying reply** preserves positive evidence. **Confirm no qualifying reply** certifies missing coverage after staff review and allows the normal unanswered deadline to apply. **Confirm unavailable thread is absent** releases a held slot only when Discord availability is uncertain; it does not infer a deletion actor or change the cooldown. Reopening checks capacity and suppresses repeated automatic unanswered closure for that listing. A closed-tag or later explicit close still applies. +**Confirm unavailable thread is absent** releases a held slot only when Discord availability is uncertain; it does not infer a deletion actor or change the cooldown. Reopening checks capacity. A Closed tag or explicit owner/moderator close still applies. Repair and reconciliation return a short job ID promptly. The worker rechecks moderator authorization when the job runs; `/recruitment status` shows its latest result. Status/help use the gateway's current member/role cache and never wait behind mutation work. Missing authorization evidence denies access. @@ -97,14 +96,14 @@ For storage recovery, stop the bot, preserve the damaged files, restore a valida The worker saves intent and staff evidence before destructive calls, then rechecks current identity and protection. Interrupted message sends recover bot-owned messages by marker before retrying. Recovery scans are bounded; a scan that cannot establish completeness holds instead of duplicating a message. An interrupted delete that finds an absent thread records an unknown actor. Archived-message updates save a restoration intent, temporarily unarchive when necessary, and restore archiving after updating the bot's own advisory. [Discord thread rules](https://docs.discord.com/developers/topics/threads) -Recruitment uses a single state writer and a bounded, coalesced event queue. Relevant thread/starter edits, member-role changes, human replies, deleted bot controls (including bulk deletion), permissions and readiness events drive targeted work. Deadlines and cooldown expiry have scheduled wake-ups. Ordinary acknowledgements do not run forum setup or enumerate all forums. A safety reconciliation runs every 15–18 minutes; incomplete inventory continues in bounded batches. Active-thread enumeration uses one guild request per discovery pass; newly found identities fetch their missing facts separately. The transport observes all identities before the SDK filters the response by forum. Member/starter facts and successful message renders are cached; targeted events or a bounded audit refresh them. Reference provisioning shares discovery instead of enumerating forums separately. Only work still waiting in the queue fences automatic actions; the event being processed does not cancel itself. State changes remain durable before external effects, and destructive actions retain fresh identity/protection checks. +Recruitment uses a single state writer and a bounded, coalesced event queue. Relevant thread/starter edits, member-role changes, deleted bot controls (including bulk deletion), permissions and readiness events drive targeted work. Deadlines and cooldown expiry have scheduled wake-ups. Ordinary acknowledgements do not run forum setup or enumerate all forums. A safety reconciliation runs every 15–18 minutes; incomplete inventory continues in bounded batches. Active-thread enumeration uses one guild request per discovery pass; newly found identities fetch their missing facts separately. The transport observes all identities before the SDK filters the response by forum. Member/starter facts and successful message renders are cached; targeted events or a bounded audit refresh them. Reference provisioning shares discovery instead of enumerating forums separately. Only work still waiting in the queue fences automatic actions; the event being processed does not cancel itself. State changes remain durable before external effects, and destructive actions retain fresh identity/protection checks. Confirmed thread-deletion events release listing capacity and clear stale holds even while setup is unavailable. Bounded safety audits also probe permanently held records for deletions missed during downtime, without blindly retrying their writes. Invalid setup, permission and ownership failures otherwise hold until a relevant invalidation or explicit recovery. Transient failures back off with jitter. Recruitment hands actual HTTP 429 retry deadlines back to the scheduler, with conservative channel/guild scope and global handling, and retains the SDK's shared request queue. Known SDK bucket exhaustion also fails promptly rather than sleeping through minutes. Repeated identical fault logs are suppressed; status shows the scope and retry eligibility. -Startup, disconnects, lost queue events and delivery failures invalidate relevant negative reply evidence and restore full usable windows for pending attempts. Timely queued owner submissions and reply facts are processed before automatic actions; acknowledgement uses the Discord interaction timestamp. Archive discovery resumes bounded pages across sweeps, with at most 50 new identities and 10 retained records processed per audit pass; scheduled listing work handles up to 20 due records at a time; incomplete inventory holds acceptance/actions. Retained known records are reconciled directly; terminal details expire after 12 months and inactive author aggregates after 24 months, while open/reference/held/action dependencies remain. The discovery floor prevents expired detail from being recreated by old archive scans. +Startup, disconnects, lost queue events and delivery failures invalidate inventory evidence and restore full usable windows for pending attempts. Timely queued owner submissions and reply facts are processed before automatic actions; a valid acknowledgement is persisted using the Discord interaction timestamp before overdue timeout processing. Archive discovery resumes bounded pages across sweeps, with at most 50 new identities and 10 retained records processed per audit pass; scheduled listing work handles up to 20 due records at a time; incomplete inventory holds acceptance/actions. Retained known records are reconciled directly; terminal details expire after 12 months and inactive author aggregates after 24 months, while open/reference/held/action dependencies remain. The discovery floor prevents expired detail from being recreated by old archive scans. ## Validation before deployment The automated tests cover policy, persistence faults, legacy v2 loading, staged setup/rotation, actual installed-SDK payloads, synthetic 235/537-second throttles, idle request budgets, targeted processing, queue loss, dashboard cooldown/recovery, owner/staff authorization, interaction registration staff exclusion, current-content embeds, portfolio detection, pinned-reference recovery and actual SDK publication payloads. They do not call live Discord. -Before opening posting access, exercise the new forums in staging: effective permissions and feed privacy, native Guidelines/tag repair, acknowledgement and immediate acceptance, duplicate/cooldown behavior, removal confirmation, pinned/reference protection, unanswered/Closed closure, restart recovery, reference editing/relocking, starter/role corrections and mobile embed readability. Check initially empty forums, an unrelated moderated tag, restart between setup stages, the overview replacement cooldown and a quiet interval without repeated channel reads. Verify the mounted persistent directory and graceful shutdown. No live bot login, guild command registration, staging mutation, deployment or remote push is part of local implementation validation. +Before opening posting access, exercise the new forums in staging: effective permissions and feed privacy, native Guidelines/tag repair, acknowledgement and immediate acceptance, duplicate/cooldown behavior, removal confirmation, pinned/reference protection, indefinite listing lifetime and Closed-tag closure, restart recovery, reference editing/relocking, starter/role corrections and mobile embed readability. Check initially empty forums, an unrelated moderated tag, restart between setup stages, the overview replacement cooldown and a quiet interval without repeated channel reads. Verify the mounted persistent directory and graceful shutdown. No live bot login, guild command registration, staging mutation, deployment or remote push is part of local implementation validation. From b906fb08ef4bab1e95c8953748fee08c16229268 Mon Sep 17 00:00:00 2001 From: James Kellie Date: Thu, 15 Oct 2026 17:05:39 +1000 Subject: [PATCH 13/31] Limit repeated recruitment attempts and expose eligibility --- .../Recruitment/RecruitmentAttemptTests.cs | 79 +++++++++++++++++++ .../Recruitment/RecruitmentRecoveryTests.cs | 2 +- .../Recruitment/RecruitmentSchedulingTests.cs | 11 +-- .../Recruitment/RecruitmentTestRig.cs | 4 +- .../Recruitment/RecruitmentWorkflowTests.cs | 13 +-- .../recruitment/guidelines/hobby-for-hire.md | 4 +- .../guidelines/hobby-recruiting.md | 4 +- .../recruitment/guidelines/paid-for-hire.md | 2 +- .../recruitment/guidelines/paid-recruiting.md | 4 +- .../Modules/Recruitment/RecruitmentModule.cs | 9 ++- .../Recruitment/DiscordRecruitmentAdapter.cs | 7 ++ .../Recruitment/IRecruitmentDiscord.cs | 1 + .../Recruitment/RecruitmentAttempts.cs | 55 +++++++++++++ .../Services/Recruitment/RecruitmentEngine.cs | 19 ++++- .../Recruitment/RecruitmentPresentation.cs | 1 + .../Services/Recruitment/RecruitmentState.cs | 9 ++- .../Recruitment/RecruitmentStateStore.cs | 3 +- DiscordBot/Settings/Deserialized/Settings.cs | 5 ++ docs/recruitment.md | 6 ++ 19 files changed, 211 insertions(+), 27 deletions(-) create mode 100644 DiscordBot.Tests/Recruitment/RecruitmentAttemptTests.cs create mode 100644 DiscordBot/Services/Recruitment/RecruitmentAttempts.cs diff --git a/DiscordBot.Tests/Recruitment/RecruitmentAttemptTests.cs b/DiscordBot.Tests/Recruitment/RecruitmentAttemptTests.cs new file mode 100644 index 00000000..64906d6f --- /dev/null +++ b/DiscordBot.Tests/Recruitment/RecruitmentAttemptTests.cs @@ -0,0 +1,79 @@ +using DiscordBot.Services.Recruitment; + +namespace DiscordBot.Tests.Recruitment; + +public class RecruitmentAttemptTests +{ + [Fact] + public async Task DeletedCreationsCountAcrossForumsAndRestartWithoutExtendingPause() + { + using var rig = new RecruitmentRig(); await rig.Start(); + for (var i = 0; i < 2; i++) + { + var post = await rig.Post((100 + i).ToString(), forum: (11 + i).ToString()); + rig.Discord.Threads.Remove(post.Id); await rig.Engine.ThreadChangedAsync(post.Id, default); + } + await rig.Restart(); + var third = await rig.Post("102", forum: "13"); + Assert.Equal(ListingStatus.Closed, third.Status); + Assert.Null(third.PromptDeliveredAt); Assert.Null(rig.State.Authors["200"].LastAcceptedCreation); + var pause = rig.State.Authors["200"].AttemptPausedUntil; + Assert.Equal(rig.Clock.Now.AddHours(1), pause); + rig.Clock.Advance(TimeSpan.FromMinutes(10)); + var fourth = await rig.Post("103", forum: "14"); + Assert.Equal(ListingStatus.Closed, fourth.Status); Assert.Equal(pause, fourth.AttemptPausedUntil); + await rig.Restart(); + Assert.Equal(pause, rig.State.Authors["200"].AttemptPausedUntil); + rig.Clock.Advance(TimeSpan.FromMinutes(50)); + await rig.Post("104"); Assert.Contains("accepted", await rig.Ack("104")); + } + + [Fact] + public async Task RediscoveryDoesNotCountRetriesOrOldHistory() + { + using var rig = new RecruitmentRig(); await rig.Start(); + await rig.Post(created: rig.Clock.Now.AddDays(-10)); + Assert.Empty(rig.State.Authors["200"].RecentCreations); + await rig.Post("101", owner: "201"); + await rig.Engine.ObserveAsync(rig.Discord.Threads["101"], true, default); + await rig.Sweep(); await rig.Restart(); + Assert.Single(rig.State.Authors["201"].RecentCreations); + } + + [Fact] + public async Task EarlierPendingAttemptCanStillAcceptAfterLaterAttemptsTriggerPause() + { + using var rig = new RecruitmentRig(); await rig.Start(); await rig.Post(); + await rig.Post("101"); await rig.Post("102"); + Assert.Equal(ListingStatus.Closed, rig.State.Listings["101"].Status); + Assert.NotNull(rig.State.Authors["200"].AttemptPausedUntil); + Assert.Contains("accepted", await rig.Ack()); + Assert.NotNull(rig.State.Listings["100"].AcceptedAt); + } + + [Fact] + public async Task ModeratorResetIsAuditedAndDoesNotWaiveAcceptedCooldownOrReopenAttempts() + { + using var rig = new RecruitmentRig(); await rig.Start(); await rig.Post(); await rig.Ack(); + await rig.Post("101"); var third = await rig.Post("102"); + Assert.Contains("Moderator access", await rig.Staff("reset-attempts", "102", actor: "201")); + Assert.NotNull(rig.State.Authors["200"].AttemptPausedUntil); + await rig.Staff("reset-attempts", "102"); await rig.Restart(); + var author = rig.State.Authors["200"]; + Assert.Null(author.AttemptPausedUntil); Assert.Empty(author.RecentCreations); + Assert.NotNull(author.LastAcceptedCreation); Assert.Equal(ListingStatus.Closed, rig.State.Listings[third.Id].Status); + Assert.Contains(rig.State.Listings[third.Id].Audit, a => a.Reason.Contains("reset-attempts")); + } + + [Fact] + public async Task PrivateEligibilityChecksMembershipAndExplainsExistingListing() + { + using var rig = new RecruitmentRig(); await rig.Start(); await rig.Post(); await rig.Ack(); + var result = await rig.Engine.EligibilityAsync("1", "200", default); + Assert.Contains("<#100>", result); Assert.Contains("conversation history", result); Assert.Contains("cooldown", result); + Assert.DoesNotContain("100", await rig.Engine.EligibilityAsync("2", "200", default)); + rig.Discord.MissingMembers.Add("200"); + Assert.Contains("current members", await rig.Engine.EligibilityAsync("1", "200", default)); + Assert.Contains("no recorded listing restrictions", await rig.Engine.EligibilityAsync("1", "201", default)); + } +} diff --git a/DiscordBot.Tests/Recruitment/RecruitmentRecoveryTests.cs b/DiscordBot.Tests/Recruitment/RecruitmentRecoveryTests.cs index f51fd3d4..e138296c 100644 --- a/DiscordBot.Tests/Recruitment/RecruitmentRecoveryTests.cs +++ b/DiscordBot.Tests/Recruitment/RecruitmentRecoveryTests.cs @@ -17,7 +17,7 @@ public async Task InteractionModuleRegistersWithoutConnectingOrStartingTheFeatur var service = new RecruitmentService(client, new BotSettings()); using var provider = new ServiceCollection().AddSingleton(service).BuildServiceProvider(); var module = await interactions.AddModuleAsync(provider); - Assert.Equal(8, module.SlashCommands.Count); + Assert.Equal(9, module.SlashCommands.Count); Assert.Equal(4, module.ComponentCommands.Count); Assert.Single(module.ModalCommands); } diff --git a/DiscordBot.Tests/Recruitment/RecruitmentSchedulingTests.cs b/DiscordBot.Tests/Recruitment/RecruitmentSchedulingTests.cs index cae159b1..8da307f3 100644 --- a/DiscordBot.Tests/Recruitment/RecruitmentSchedulingTests.cs +++ b/DiscordBot.Tests/Recruitment/RecruitmentSchedulingTests.cs @@ -25,7 +25,7 @@ public async Task AcceptedListingsNeverNeedReplyHistoryScans() } [Fact] - public async Task DeadlineRunsWithoutInventoryOrSetupAndCooldownWakeupAcceptsWaitingListing() + public async Task ClosedCooldownConflictDoesNotReopenWhenCooldownExpires() { using var rig = new RecruitmentRig(); await rig.Start(); var first = await rig.Post(); await rig.Ack(); await rig.Staff("close"); rig.Clock.Now = first.CreatedAt.AddDays(rig.Settings.Recruitment.CooldownDays).AddMinutes(-2); @@ -33,19 +33,19 @@ public async Task DeadlineRunsWithoutInventoryOrSetupAndCooldownWakeupAcceptsWai Assert.Null(waiting.AcceptedAt); var forums = rig.Discord.ForumReads; var inventories = rig.Discord.Inventories; rig.Clock.Advance(TimeSpan.FromMinutes(2)); await rig.Engine.RunDueAsync(default); - Assert.NotNull(waiting.AcceptedAt); + Assert.Null(waiting.AcceptedAt); Assert.Equal(ListingStatus.Closed, waiting.Status); Assert.Equal(forums, rig.Discord.ForumReads); Assert.Equal(inventories, rig.Discord.Inventories); } [Fact] - public async Task ConfirmedBlockerDeletionWakesOtherAttemptBySameAuthor() + public async Task ConfirmedBlockerDeletionDoesNotReopenClosedDuplicate() { using var rig = new RecruitmentRig(); await rig.Start(); await rig.Post(); await rig.Ack(); await rig.Staff("waive-cooldown", "200"); var waiting = await rig.Post("101"); await rig.Ack("101"); Assert.Null(waiting.AcceptedAt); rig.Discord.Threads.Remove("100"); await rig.Engine.ThreadChangedAsync("100", default); - Assert.NotNull(waiting.AcceptedAt); + Assert.Null(waiting.AcceptedAt); Assert.Equal(ListingStatus.Closed, waiting.Status); } [Fact] @@ -129,7 +129,8 @@ public async Task QueuedUnknownIdentityPreventsAcceptingANewerAttemptByTheSameAu Assert.DoesNotContain("is accepted", await acknowledgement); var accepted = await service.RunAsync((engine, _) => Task.FromResult(engine.State!.Listings["101"].AcceptedAt)); Assert.Null(accepted); - Assert.Equal(2, service.Health.Open); + Assert.Equal(1, service.Health.Open); + Assert.Equal(ListingStatus.Closed, await service.RunAsync((engine, _) => Task.FromResult(engine.State!.Listings["101"].Status))); Assert.Equal(1, rig.Discord.Inventories); } finally { await service.StopAsync(default); } diff --git a/DiscordBot.Tests/Recruitment/RecruitmentTestRig.cs b/DiscordBot.Tests/Recruitment/RecruitmentTestRig.cs index 704e70a8..af3d2eb1 100644 --- a/DiscordBot.Tests/Recruitment/RecruitmentTestRig.cs +++ b/DiscordBot.Tests/Recruitment/RecruitmentTestRig.cs @@ -83,7 +83,7 @@ public async Task Post(string id = "100", string owner = "200", string return State.Listings[id]; } public Task Ack(string id = "100", string owner = "200", string? code = null) => - Engine.AcknowledgeAsync("1", id, owner, code ?? State.Listings[id].ValidCodes.Last(), default); + Engine.AcknowledgeAsync("1", id, owner, code ?? State.Listings[id].ValidCodes.LastOrDefault() ?? State.RotationCode, default); public Task Staff(string operation, string target = "100", string reason = "Test recovery", string actor = "900") => Engine.StaffAsync("1", actor, operation, operation == "repair" && target == "100" ? "" : target, reason, default); public void Dispose() { Engine.Dispose(); if (Directory.Exists(Root)) Directory.Delete(Root, true); } @@ -135,6 +135,8 @@ public async Task ValidateAccessAsync(CancellationToken ct) } public Task ValidateFeedAsync(CancellationToken ct) => FailFeed ? Task.FromException(new InvalidOperationException("Feed privacy invalid")) : Task.FromResult(FeedEmbeds); public Task LatestFeedMessageAsync(CancellationToken ct) => Task.FromResult(Messages.Where(m => m.Key.StartsWith("99:")).Select(m => m.Value.Id).OrderByDescending(ulong.Parse).FirstOrDefault()); + public HashSet MissingMembers { get; } = new(); + public Task IsMemberAsync(string guildId, string userId, CancellationToken ct) => Task.FromResult(guildId == "1" && !MissingMembers.Contains(userId)); public Task IsModeratorAsync(string guildId, string userId, CancellationToken ct) => Task.FromResult(guildId == "1" && userId == "900"); public bool IsExpectedForumUpdate(ForumSnapshot snapshot) => Forums.GetValueOrDefault(snapshot.Id) == snapshot; public Task GetForumAsync(string id, CancellationToken ct) { ForumReads++; return Task.FromResult(Forums[id]); } diff --git a/DiscordBot.Tests/Recruitment/RecruitmentWorkflowTests.cs b/DiscordBot.Tests/Recruitment/RecruitmentWorkflowTests.cs index e35b4afd..33e4192a 100644 --- a/DiscordBot.Tests/Recruitment/RecruitmentWorkflowTests.cs +++ b/DiscordBot.Tests/Recruitment/RecruitmentWorkflowTests.cs @@ -47,15 +47,16 @@ public async Task AllForumsShareOneSlot_EvenAfterThirtyDaysOrNaturalArchive() rig.Discord.Replies.Add("100"); rig.Clock.Advance(TimeSpan.FromDays(40)); rig.Discord.Threads["100"] = rig.Discord.Threads["100"] with { Archived = true }; var second = await rig.Post("101", forum: "14"); await rig.Ack("101"); - Assert.Null(second.AcceptedAt); Assert.NotNull(second.AcknowledgedAt); + Assert.Null(second.AcceptedAt); Assert.Null(second.AcknowledgedAt); Assert.Equal(ListingStatus.Closed, second.Status); Assert.Equal("100", RecruitmentPolicy.Evaluate(rig.State, second, rig.Clock.Now, 30).BlockingListingId); } [Fact] - public async Task EarlierPendingAttemptWinsAcrossForums_AndAcknowledgedLoserCloses() + public async Task EarlierPendingAttemptWinsAcrossForums_AndDuplicateClosesPromptly() { using var rig = new RecruitmentRig(); await rig.Start(); await rig.Post(); var second = await rig.Post("101", forum: "12"); + Assert.Equal(ListingStatus.Closed, second.Status); Assert.Null(second.PromptDeliveredAt); await rig.Ack("101"); Assert.Null(second.AcceptedAt); await rig.Ack(); Assert.NotNull(rig.State.Listings["100"].AcceptedAt); rig.Clock.Advance(TimeSpan.FromMinutes(31)); await rig.Sweep(); @@ -64,12 +65,12 @@ public async Task EarlierPendingAttemptWinsAcrossForums_AndAcknowledgedLoserClos } [Fact] - public async Task AcknowledgedAttemptAcceptsWhenPendingBlockerDisappears() + public async Task ClosedDuplicateDoesNotReopenWhenPendingBlockerDisappears() { using var rig = new RecruitmentRig(); await rig.Start(); await rig.Post(); var second = await rig.Post("101", forum: "13"); await rig.Ack("101"); rig.Discord.Threads.Remove("100"); await rig.Sweep(); - Assert.NotNull(second.AcceptedAt); + Assert.Null(second.AcceptedAt); Assert.Equal(ListingStatus.Closed, second.Status); } [Fact] @@ -241,8 +242,8 @@ public async Task StaffAuthorizationIsEnforcedAtExecution_AndWaiverDoesNotFreeSl public async Task ReopenKeepsListingOpenWithoutAnExpiry() { using var rig = new RecruitmentRig(); await rig.Start(); await rig.Post(); await rig.Ack(); - rig.Clock.Advance(TimeSpan.FromDays(31)); await rig.Sweep(); await rig.Staff("reopen"); await rig.Sweep(); - Assert.Equal(ListingStatus.Open, rig.State.Listings["100"].Status); + await rig.Staff("close"); + rig.Clock.Advance(TimeSpan.FromDays(31)); await rig.Staff("reopen"); await rig.Sweep(); Assert.Equal(ListingStatus.Open, rig.State.Listings["100"].Status); } } diff --git a/DiscordBot/Assets/recruitment/guidelines/hobby-for-hire.md b/DiscordBot/Assets/recruitment/guidelines/hobby-for-hire.md index 9513c5f2..73f217d0 100644 --- a/DiscordBot/Assets/recruitment/guidelines/hobby-for-hire.md +++ b/DiscordBot/Assets/recruitment/guidelines/hobby-for-hire.md @@ -6,8 +6,8 @@ Read the pinned rules; use accurate tags. Verify claims and agree terms in writi **One open listing across all four recruitment forums.** Combine hiring and service offers in a for-hire listing. -Enter **{{code}}** via **Acknowledge** within **{{acknowledgementMinutes}} minutes** of the bot prompt. Unacknowledged posts are removed; acknowledged posts with listing/cooldown conflicts are closed. +Enter **{{code}}** via **Acknowledge** within **{{acknowledgementMinutes}} minutes** of the bot prompt. Unacknowledged posts are removed. Listing/cooldown conflicts close promptly. -Accepted listings start a **{{cooldownDays}}-day cooldown** from creation. Closing/deleting won't reset it; unaccepted attempts add none. +Accepted listings start a **{{cooldownDays}}-day cooldown** from creation. Closing/deleting won't reset it. Repeated attempts trigger a temporary pause; check **/recruitment eligibility**. Keep this listing updated or bump it when still looking; its history stays visible. Mark finished listings **Closed** to lock/archive. Auto-archive does not free your slot. diff --git a/DiscordBot/Assets/recruitment/guidelines/hobby-recruiting.md b/DiscordBot/Assets/recruitment/guidelines/hobby-recruiting.md index 77de27e4..b10de7fe 100644 --- a/DiscordBot/Assets/recruitment/guidelines/hobby-recruiting.md +++ b/DiscordBot/Assets/recruitment/guidelines/hobby-recruiting.md @@ -6,8 +6,8 @@ Read the pinned rules; use accurate tags. Verify claims and agree terms in writi **One open listing across all four recruitment forums.** Combine hiring and service offers in a for-hire listing. -Enter **{{code}}** via **Acknowledge** within **{{acknowledgementMinutes}} minutes** of the bot prompt. Unacknowledged posts are removed; acknowledged posts with listing/cooldown conflicts are closed. +Enter **{{code}}** via **Acknowledge** within **{{acknowledgementMinutes}} minutes** of the bot prompt. Unacknowledged posts are removed. Listing/cooldown conflicts close promptly. -Accepted listings start a **{{cooldownDays}}-day cooldown** from creation. Closing/deleting won't reset it; unaccepted attempts add none. +Accepted listings start a **{{cooldownDays}}-day cooldown** from creation. Closing/deleting won't reset it. Repeated attempts trigger a temporary pause; check **/recruitment eligibility**. Keep this listing updated or bump it when still looking; its history stays visible. Mark finished listings **Closed** to lock/archive. Auto-archive does not free your slot. diff --git a/DiscordBot/Assets/recruitment/guidelines/paid-for-hire.md b/DiscordBot/Assets/recruitment/guidelines/paid-for-hire.md index c3d87dcd..bc17ca31 100644 --- a/DiscordBot/Assets/recruitment/guidelines/paid-for-hire.md +++ b/DiscordBot/Assets/recruitment/guidelines/paid-for-hire.md @@ -8,6 +8,6 @@ Read the pinned rules and use accurate tags. Verify claims; agree scope, deadlin Enter **{{code}}** via **Acknowledge** within **{{acknowledgementMinutes}} minutes** of the bot prompt. Unacknowledged posts are removed; posts blocked by an existing listing or cooldown are closed. -Only accepted listings start a **{{cooldownDays}}-day cooldown** from creation. Closing/deleting won't reset it; unaccepted attempts add none. +Only accepted listings start a **{{cooldownDays}}-day cooldown** from creation. Closing/deleting won't reset it. Repeated attempts trigger a temporary pause; check **/recruitment eligibility**. Keep this listing updated or bump it when still looking; its history stays visible. Mark finished listings **Closed** to lock/archive. Auto-archive does not free your slot. diff --git a/DiscordBot/Assets/recruitment/guidelines/paid-recruiting.md b/DiscordBot/Assets/recruitment/guidelines/paid-recruiting.md index 8e435ac1..83def895 100644 --- a/DiscordBot/Assets/recruitment/guidelines/paid-recruiting.md +++ b/DiscordBot/Assets/recruitment/guidelines/paid-recruiting.md @@ -6,8 +6,8 @@ Read the pinned rules; use accurate tags. Verify claims and agree terms in writi **One open listing across all four recruitment forums.** Combine hiring and service offers in a for-hire listing. -Enter **{{code}}** via **Acknowledge** within **{{acknowledgementMinutes}} minutes** of the bot prompt. Unacknowledged posts are removed; acknowledged posts with listing/cooldown conflicts are closed. +Enter **{{code}}** via **Acknowledge** within **{{acknowledgementMinutes}} minutes** of the bot prompt. Unacknowledged posts are removed. Listing/cooldown conflicts close promptly. -Accepted listings start a **{{cooldownDays}}-day cooldown** from creation. Closing/deleting won't reset it; unaccepted attempts add none. +Accepted listings start a **{{cooldownDays}}-day cooldown** from creation. Closing/deleting won't reset it. Repeated attempts trigger a temporary pause; check **/recruitment eligibility**. Keep this listing updated or bump it when still looking; its history stays visible. Mark finished listings **Closed** to lock/archive. Auto-archive does not free your slot. diff --git a/DiscordBot/Modules/Recruitment/RecruitmentModule.cs b/DiscordBot/Modules/Recruitment/RecruitmentModule.cs index c7c77fec..3c076720 100644 --- a/DiscordBot/Modules/Recruitment/RecruitmentModule.cs +++ b/DiscordBot/Modules/Recruitment/RecruitmentModule.cs @@ -12,6 +12,13 @@ public sealed class RecruitmentModule : InteractionModuleBase Context.User.Id.ToString(); private string Thread => Context.Channel.Id.ToString(); + [SlashCommand("eligibility", "Check your posting slot, cooldown and attempt limit privately")] + public async Task Eligibility() + { + await DeferAsync(ephemeral: true); + await Reply(() => Recruitment.RunAsync((engine, ct) => engine.EligibilityAsync(Guild, Actor, ct))); + } + [SlashCommand("status", "Show recruitment availability")] public async Task Status() { @@ -34,7 +41,7 @@ public async Task Status() public Task Reconcile(string reason) => Staff("reconcile", reason: reason); [SlashCommand("manage", "Change a listing or review uncertain evidence")] public Task Manage( - [Choice("Exempt", "exempt"), Choice("Remove exemption", "unexempt"), Choice("Reset acknowledgement/timeouts", "reset"), Choice("Reset timeout streak", "reset-timeouts"), + [Choice("Exempt", "exempt"), Choice("Remove exemption", "unexempt"), Choice("Reset acknowledgement/timeouts", "reset"), Choice("Reset timeout streak", "reset-timeouts"), Choice("Reset posting attempts", "reset-attempts"), Choice("Close", "close"), Choice("Reopen", "reopen"), Choice("Designate reference post", "reference"), Choice("Confirm unavailable thread is absent", "review-absent")] string operation, string threadId, string reason) => Staff(operation, threadId, reason); diff --git a/DiscordBot/Services/Recruitment/DiscordRecruitmentAdapter.cs b/DiscordBot/Services/Recruitment/DiscordRecruitmentAdapter.cs index c0aa8671..dc943117 100644 --- a/DiscordBot/Services/Recruitment/DiscordRecruitmentAdapter.cs +++ b/DiscordBot/Services/Recruitment/DiscordRecruitmentAdapter.cs @@ -74,6 +74,13 @@ public async Task ValidateFeedAsync(CancellationToken ct) return (await channel.GetMessagesAsync(1, CacheMode.AllowDownload, Options(ct)).FlattenAsync()).FirstOrDefault()?.Id.ToString(); } + public async Task IsMemberAsync(string guildId, string userId, CancellationToken ct) + { + if (guildId != _settings.GuildId.ToString() || !ulong.TryParse(userId, out var id)) return false; + try { return await _client.Rest.GetGuildUserAsync(_settings.GuildId, id, Options(ct)) is not null; } + catch (HttpException ex) when (ex.DiscordCode == DiscordErrorCode.UnknownMember) { return false; } + } + public async Task IsModeratorAsync(string guildId, string userId, CancellationToken ct) { if (guildId != _settings.GuildId.ToString() || !ulong.TryParse(userId, out var id)) return false; diff --git a/DiscordBot/Services/Recruitment/IRecruitmentDiscord.cs b/DiscordBot/Services/Recruitment/IRecruitmentDiscord.cs index d496dfbd..ed0a9c75 100644 --- a/DiscordBot/Services/Recruitment/IRecruitmentDiscord.cs +++ b/DiscordBot/Services/Recruitment/IRecruitmentDiscord.cs @@ -20,6 +20,7 @@ public interface IRecruitmentDiscord Task ValidateAccessAsync(CancellationToken ct); Task ValidateFeedAsync(CancellationToken ct); Task LatestFeedMessageAsync(CancellationToken ct); + Task IsMemberAsync(string guildId, string userId, CancellationToken ct); Task IsModeratorAsync(string guildId, string userId, CancellationToken ct); bool IsExpectedForumUpdate(ForumSnapshot snapshot); Task GetForumAsync(string forumId, CancellationToken ct); diff --git a/DiscordBot/Services/Recruitment/RecruitmentAttempts.cs b/DiscordBot/Services/Recruitment/RecruitmentAttempts.cs new file mode 100644 index 00000000..8fd1ebb1 --- /dev/null +++ b/DiscordBot/Services/Recruitment/RecruitmentAttempts.cs @@ -0,0 +1,55 @@ +namespace DiscordBot.Services.Recruitment; + +public sealed partial class RecruitmentEngine +{ + // Called once per newly retained thread identity, regardless of later deletion or acknowledgement. + private void RecordCreation(Listing post) + { + var history = RecruitmentPolicy.Author(State!, post.OwnerId, Now); + if (post.Pinned || post.CreatedAt <= Now.AddMinutes(-_settings.AttemptWindowMinutes)) return; + if (history.AttemptPausedUntil > Now) + { + post.AttemptPausedUntil = history.AttemptPausedUntil; + return; // Extra attempts cannot prolong an existing pause. + } + if (history.AttemptPausedUntil.HasValue) + { + history.RecentCreations.Clear(); history.AttemptPausedUntil = null; + } + history.RecentCreations.RemoveAll(d => d <= Now.AddMinutes(-_settings.AttemptWindowMinutes)); + history.RecentCreations.Add(post.CreatedAt); + if (history.RecentCreations.Count > _settings.AttemptLimit) + post.AttemptPausedUntil = history.AttemptPausedUntil = Now.AddMinutes(_settings.AttemptPauseMinutes); + } + + private static string EligibilityReason(Eligibility eligibility) + { + if (eligibility.AttemptPausedUntil is { } pause) return $"Posting attempts paused until {pause:yyyy-MM-dd HH:mm} UTC. Check /recruitment eligibility."; + if (eligibility.BlockingListingId is { } id) return $"Existing listing {id} occupies your slot. Update or bump that thread; check /recruitment eligibility."; + return $"Accepted-listing cooldown ends {eligibility.NextAllowedAt:yyyy-MM-dd HH:mm} UTC. Check /recruitment eligibility."; + } + + public async Task EligibilityAsync(string guild, string owner, CancellationToken ct) + { + if (State is null || guild != State.GuildId || !await _discord.IsMemberAsync(guild, owner, ct)) + return "This control is only available to current members of the configured server."; + var lines = new List(); + foreach (var post in State.Listings.Values.Where(p => p.OwnerId == owner && p.Status == ListingStatus.Open).Take(10)) + lines.Add($"Your listing: <#{post.Id}>. Update or bump this thread to keep its conversation history. Close it when finished; auto-archive does not free your slot."); + if (State.Authors.TryGetValue(owner, out var author)) + { + if (author.LastAcceptedCreation?.AddDays(_settings.CooldownDays) is { } next && next > Now) + lines.Add($"A replacement can be accepted after {RecruitmentPresentation.Timestamp(next)}. Closing or deleting does not reset this cooldown."); + if (author.AttemptPausedUntil > Now) + lines.Add($"Posting attempts are paused until {RecruitmentPresentation.Timestamp(author.AttemptPausedUntil.Value)}. A moderator can review a mistake."); + else + { + var used = author.AttemptPausedUntil.HasValue ? 0 : author.RecentCreations.Count(d => d > Now.AddMinutes(-_settings.AttemptWindowMinutes)); + lines.Add($"Recent creations: {used}/{_settings.AttemptLimit} in {_settings.AttemptWindowMinutes} minutes. Another attempt over this limit starts a {_settings.AttemptPauseMinutes}-minute pause, including deleted attempts."); + } + } + else lines.Add($"You have no recorded listing restrictions. Up to {_settings.AttemptLimit} creations in {_settings.AttemptWindowMinutes} minutes; further attempts start a {_settings.AttemptPauseMinutes}-minute pause."); + if (!EvidenceCurrent) lines.Add("Inventory recovery is in progress; eligibility will be checked again when you post."); + return string.Join("\n", lines); + } +} diff --git a/DiscordBot/Services/Recruitment/RecruitmentEngine.cs b/DiscordBot/Services/Recruitment/RecruitmentEngine.cs index 88297ca3..c5d424ef 100644 --- a/DiscordBot/Services/Recruitment/RecruitmentEngine.cs +++ b/DiscordBot/Services/Recruitment/RecruitmentEngine.cs @@ -193,7 +193,7 @@ public Task ObserveAsync(ThreadSnapshot live, bool observedFromCreation, Cancell } }; State.Listings[post.Id] = post; - RecruitmentPolicy.Author(State, post.OwnerId, Now); + RecordCreation(post); Save(); return Task.CompletedTask; } @@ -248,6 +248,16 @@ private async Task ProcessAsync(Listing post, CancellationToken ct) if (post.StaffSendPending || post.PublicSendPending || post.PublicationPending) await PublishAsync(post, ct); return; } + if (EvidenceCurrent && !post.AcceptedAt.HasValue && !post.Exempt && !post.Pinned) + { + await RefreshBlockersAsync(post, ct); + var eligibility = RecruitmentPolicy.Evaluate(State!, post, Now, _settings.CooldownDays); + if (!eligibility.Allowed) + { + await BeginActionAsync(post, RecruitmentActionKind.Close, "system", EligibilityReason(eligibility), "ineligible", true, ct); + return; + } + } if (_recoverWindows.Remove(post.Id)) { post.PromptDeliveredAt = post.Deadline = null; @@ -412,7 +422,7 @@ public async Task CloseOwnerAsync(string guild, string thread, string ow var token = Guid.NewGuid().ToString("N"); _confirmations[token] = new RemovalConfirmation(guild, thread, owner, post.Version, Now.AddMinutes(2)); var next = State!.Authors.GetValueOrDefault(owner)?.LastAcceptedCreation?.AddDays(_settings.CooldownDays); - var timing = !post.AcceptedAt.HasValue ? "Removing this unaccepted attempt adds no cooldown." : next.HasValue + var timing = !post.AcceptedAt.HasValue ? "Removing this unaccepted attempt adds no accepted-listing cooldown; its creation still counts toward the attempt limit." : next.HasValue ? "Its creation cooldown remains " + RecruitmentPresentation.Timestamp(next.Value) + "." : "The accepted creation cooldown has been waived."; return ($"Permanently remove this forum post? {timing} This confirmation expires in two minutes.", token); } @@ -584,6 +594,11 @@ public async Task StaffAsync(string guild, string actor, string operatio { case "exempt": post.Exempt = true; break; case "unexempt": post.Exempt = false; _recoverWindows.Add(post.Id); break; + case "reset-attempts": + var attemptHistory = RecruitmentPolicy.Author(State, post.OwnerId, Now); + attemptHistory.RecentCreations.Clear(); attemptHistory.AttemptPausedUntil = null; + foreach (var attempt in State.Listings.Values.Where(p => p.OwnerId == post.OwnerId)) attempt.AttemptPausedUntil = null; + break; case "reset-timeouts": var timeoutHistory = RecruitmentPolicy.Author(State, post.OwnerId, Now); timeoutHistory.ConsecutiveTimeouts = 0; timeoutHistory.TimeoutAlerted = false; break; diff --git a/DiscordBot/Services/Recruitment/RecruitmentPresentation.cs b/DiscordBot/Services/Recruitment/RecruitmentPresentation.cs index 5ff3c6f1..9646c5c8 100644 --- a/DiscordBot/Services/Recruitment/RecruitmentPresentation.cs +++ b/DiscordBot/Services/Recruitment/RecruitmentPresentation.cs @@ -21,6 +21,7 @@ public static RecruitmentMessage Public(RecruitmentState state, Listing post, Re if (eligibility.BlockingListingId is not null) instructions.Add($"Your other listing <#{eligibility.BlockingListingId}> occupies your listing slot."); if (eligibility.NextAllowedAt.HasValue) instructions.Add($"Creation cooldown ends {Timestamp(eligibility.NextAllowedAt.Value)}."); } + if (post.Status == ListingStatus.Closed && !post.AcceptedAt.HasValue) instructions.Add(Safe(post.LastOutcome, 400)); if (post.AcceptedAt.HasValue) { var next = state.Authors.GetValueOrDefault(post.OwnerId)?.LastAcceptedCreation?.AddDays(settings.CooldownDays); diff --git a/DiscordBot/Services/Recruitment/RecruitmentState.cs b/DiscordBot/Services/Recruitment/RecruitmentState.cs index e26c0045..b62de9ff 100644 --- a/DiscordBot/Services/Recruitment/RecruitmentState.cs +++ b/DiscordBot/Services/Recruitment/RecruitmentState.cs @@ -96,6 +96,7 @@ public sealed class Listing public DateTimeOffset? Deadline { get; set; } public List ValidCodes { get; set; } = new(); public DateTimeOffset? LastCodeAttemptAt { get; set; } + public DateTimeOffset? AttemptPausedUntil { get; set; } public string? PublicMessageId { get; set; } public string? StaffMessageId { get; set; } public DateTimeOffset? PublicSendStartedAt { get; set; } @@ -146,6 +147,8 @@ public sealed class AuthorHistory public DateTimeOffset UpdatedAt { get; set; } public int ConsecutiveTimeouts { get; set; } public bool TimeoutAlerted { get; set; } + public List RecentCreations { get; set; } = new(); + public DateTimeOffset? AttemptPausedUntil { get; set; } } /// Durable mutation intent. CallStarted may represent a successful write whose response was lost. @@ -165,9 +168,9 @@ public sealed class RecruitmentAction public DateTimeOffset CreatedAt { get; set; } } -public sealed record Eligibility(string? BlockingListingId, DateTimeOffset? NextAllowedAt) +public sealed record Eligibility(string? BlockingListingId, DateTimeOffset? NextAllowedAt, DateTimeOffset? AttemptPausedUntil = null) { - public bool Allowed => BlockingListingId is null && NextAllowedAt is null; + public bool Allowed => BlockingListingId is null && NextAllowedAt is null && AttemptPausedUntil is null; } public static class RecruitmentPolicy @@ -181,7 +184,7 @@ public static Eligibility Evaluate(RecruitmentState state, Listing candidate, Da ulong.Parse(p.Id) < ulong.Parse(candidate.Id))) .OrderBy(p => p.CreatedAt).ThenBy(p => ulong.Parse(p.Id)).FirstOrDefault(); var next = state.Authors.GetValueOrDefault(candidate.OwnerId)?.LastAcceptedCreation?.AddDays(cooldownDays); - return new Eligibility(blocker?.Id, next > now ? next : null); + return new Eligibility(blocker?.Id, next > now ? next : null, candidate.AttemptPausedUntil > now ? candidate.AttemptPausedUntil : null); } public static bool TryAccept(RecruitmentState state, Listing post, DateTimeOffset now, int cooldownDays) diff --git a/DiscordBot/Services/Recruitment/RecruitmentStateStore.cs b/DiscordBot/Services/Recruitment/RecruitmentStateStore.cs index 06e33992..aa443d41 100644 --- a/DiscordBot/Services/Recruitment/RecruitmentStateStore.cs +++ b/DiscordBot/Services/Recruitment/RecruitmentStateStore.cs @@ -125,7 +125,8 @@ state.Forums is null || state.Listings is null || state.Authors is null || state (p.PendingAction is not null && !Enum.IsDefined(p.PendingAction.Kind))) throw new InvalidDataException("Recruitment listing state is invalid."); foreach (var (id, a) in state.Authors) - if (!Id(id) || a is null || a.ConsecutiveTimeouts < 0 || a.UpdatedAt == default) + if (!Id(id) || a is null || a.ConsecutiveTimeouts < 0 || a.UpdatedAt == default || + a.RecentCreations is null || a.RecentCreations.Count > 21 || a.RecentCreations.Any(d => d == default)) throw new InvalidDataException("Recruitment author history is invalid."); } diff --git a/DiscordBot/Settings/Deserialized/Settings.cs b/DiscordBot/Settings/Deserialized/Settings.cs index b4fe1532..7df9b7c7 100644 --- a/DiscordBot/Settings/Deserialized/Settings.cs +++ b/DiscordBot/Settings/Deserialized/Settings.cs @@ -108,6 +108,9 @@ public class RecruitmentSettings public RecruitmentForums Forums { get; set; } = new(); public int AcknowledgementMinutes { get; set; } = 30; public int CooldownDays { get; set; } = 30; + public int AttemptLimit { get; set; } = 2; + public int AttemptWindowMinutes { get; set; } = 60; + public int AttemptPauseMinutes { get; set; } = 60; public int UnansweredDays { get; set; } = 30; // Legacy configuration; listings no longer expire. public int PostRetentionMonths { get; set; } = 12; public int AuthorRetentionMonths { get; set; } = 24; @@ -122,6 +125,8 @@ public List Validate() errors.Add("FeedChannelId must identify a separate staff text channel"); if (AcknowledgementMinutes is < 1 or > 1440 || CooldownDays is < 1 or > 365) errors.Add("durations must be positive (acknowledgement at most 1440 minutes; cooldown at most 365 days)"); + if (AttemptLimit is < 1 or > 20 || AttemptWindowMinutes is < 1 or > 1440 || AttemptPauseMinutes is < 1 or > 1440) + errors.Add("attempt limit must be 1–20; attempt window and pause must be 1–1440 minutes"); if (PostRetentionMonths is < 1 or > 120 || AuthorRetentionMonths < PostRetentionMonths || AuthorRetentionMonths > 120) errors.Add("retention must be 1–120 months, with author retention at least post retention"); return errors; diff --git a/docs/recruitment.md b/docs/recruitment.md index 1d289ded..dfd8d2aa 100644 --- a/docs/recruitment.md +++ b/docs/recruitment.md @@ -107,3 +107,9 @@ Startup, disconnects, lost queue events and delivery failures invalidate invento The automated tests cover policy, persistence faults, legacy v2 loading, staged setup/rotation, actual installed-SDK payloads, synthetic 235/537-second throttles, idle request budgets, targeted processing, queue loss, dashboard cooldown/recovery, owner/staff authorization, interaction registration staff exclusion, current-content embeds, portfolio detection, pinned-reference recovery and actual SDK publication payloads. They do not call live Discord. Before opening posting access, exercise the new forums in staging: effective permissions and feed privacy, native Guidelines/tag repair, acknowledgement and immediate acceptance, duplicate/cooldown behavior, removal confirmation, pinned/reference protection, indefinite listing lifetime and Closed-tag closure, restart recovery, reference editing/relocking, starter/role corrections and mobile embed readability. Check initially empty forums, an unrelated moderated tag, restart between setup stages, the overview replacement cooldown and a quiet interval without repeated channel reads. Verify the mounted persistent directory and graceful shutdown. No live bot login, guild command registration, staging mutation, deployment or remote push is part of local implementation validation. + +### Creation attempts + +Every distinct recently created member listing counts across the four forums, including attempts later deleted. Defaults: `AttemptLimit: 2`, `AttemptWindowMinutes: 60`, `AttemptPauseMinutes: 60`. The third creation starts a pause; further creations during the pause do not extend it. Discovery retries and old historical listings do not consume attempts. Earlier eligible pending listings can still be acknowledged. Known slot/cooldown/pause conflicts are closed promptly, with their reason preserved. + +Members can use `/recruitment eligibility` for private slot, cooldown and pause information. `/recruitment manage reset-attempts ` clears that author's attempt budget; it does not reopen closed attempts, waive the accepted-listing cooldown or grant a second slot. From b02722196b9cd8b2902d5bb7d3e0b308750d52a4 Mon Sep 17 00:00:00 2001 From: James Kellie Date: Thu, 15 Oct 2026 17:23:53 +1000 Subject: [PATCH 14/31] Add neutral reader notes and private recruitment reports - Retain bounded starter evidence and moderator review notices. - Keep the staff overview fixed and quiet routine processing. --- .../Recruitment/RecruitmentContentTests.cs | 63 ++++++++ .../Recruitment/RecruitmentEvidenceTests.cs | 71 +++++++++ .../Recruitment/RecruitmentOverviewTests.cs | 72 ++++------ .../RecruitmentPresentationTests.cs | 45 ++++-- .../RecruitmentPublicationTests.cs | 57 +++++++- .../Recruitment/RecruitmentRecoveryTests.cs | 4 +- .../Recruitment/RecruitmentReportTests.cs | 77 ++++++++++ .../Recruitment/RecruitmentSchedulingTests.cs | 16 +++ .../RecruitmentStorageAndSetupTests.cs | 2 +- .../Recruitment/RecruitmentWorkflowTests.cs | 13 ++ .../Modules/Recruitment/RecruitmentModule.cs | 46 ++++++ .../Recruitment/DiscordRecruitmentAdapter.cs | 40 ++++-- .../Recruitment/IRecruitmentDiscord.cs | 7 +- .../Recruitment/RecruitmentAttempts.cs | 3 + .../Recruitment/RecruitmentAttention.cs | 33 +++++ .../Recruitment/RecruitmentContent.cs | 133 ++++++++++++++--- .../Services/Recruitment/RecruitmentEngine.cs | 33 +++-- .../RecruitmentEngineScheduling.cs | 10 +- .../Recruitment/RecruitmentEvidence.cs | 63 ++++++++ .../Services/Recruitment/RecruitmentHealth.cs | 4 +- .../Recruitment/RecruitmentOverview.cs | 23 +-- .../Recruitment/RecruitmentPresentation.cs | 135 ++++++++---------- .../Recruitment/RecruitmentReports.cs | 80 +++++++++++ .../Recruitment/RecruitmentService.cs | 37 ++--- .../Services/Recruitment/RecruitmentState.cs | 32 +++++ .../Recruitment/RecruitmentStateStore.cs | 20 ++- DiscordBot/Settings/Settings.example.json | 4 +- docs/recruitment.md | 48 +++++-- k8s/dev/bot-config.yaml | 4 +- k8s/prod/bot-config.yaml | 4 +- 30 files changed, 920 insertions(+), 259 deletions(-) create mode 100644 DiscordBot.Tests/Recruitment/RecruitmentContentTests.cs create mode 100644 DiscordBot.Tests/Recruitment/RecruitmentEvidenceTests.cs create mode 100644 DiscordBot.Tests/Recruitment/RecruitmentReportTests.cs create mode 100644 DiscordBot/Services/Recruitment/RecruitmentAttention.cs create mode 100644 DiscordBot/Services/Recruitment/RecruitmentEvidence.cs create mode 100644 DiscordBot/Services/Recruitment/RecruitmentReports.cs diff --git a/DiscordBot.Tests/Recruitment/RecruitmentContentTests.cs b/DiscordBot.Tests/Recruitment/RecruitmentContentTests.cs new file mode 100644 index 00000000..e96e3791 --- /dev/null +++ b/DiscordBot.Tests/Recruitment/RecruitmentContentTests.cs @@ -0,0 +1,63 @@ +using DiscordBot.Services.Recruitment; + +namespace DiscordBot.Tests.Recruitment; + +public class RecruitmentContentTests +{ + [Theory] + [InlineData("Unpaid volunteer project. The game will sell for $20. DM for details.", "unpaid collaboration stated")] + [InlineData("The game sells for $20", "amount mentioned; compensation unclear")] + [InlineData("Application fee $20 to join", "amount mentioned; compensation unclear")] + [InlineData("Budget: AUD 500 for the project", "concrete rate or budget stated")] + [InlineData("My rate is $40/hour", "concrete rate or budget stated")] + [InlineData("Unpaid role. Budget $500 for some tasks", "mixed compensation terms; clarify what is guaranteed")] + [InlineData("Revenue share, plus a rate of $30/hour", "mixed compensation terms; clarify what is guaranteed")] + [InlineData("Revenue share only", "revenue-share stated")] + public void PaymentNeedsCompensationContextAndPreservesConflicts(string content, string summary) + { + var observation = RecruitmentContent.ReadPayment(content); + Assert.Equal(summary, observation.Summary); Assert.NotEmpty(observation.Evidence); + } + + [Theory] + [InlineData("No portfolio available; apply at https://example.org/contact", "external link found; purpose unclear")] + [InlineData("Portfolio coming soon: https://example.org/contact", "external link found; purpose unclear")] + [InlineData("Not my work: https://artstation.com/artist", "external link found; purpose unclear")] + [InlineData("My portfolio: https://example.org/work", "work-sample link detected")] + public void NegatedWorkClaimsDoNotClearSampleCheck(string content, string expected) => Assert.Equal(expected, RecruitmentContent.Portfolio(content)); + + [Theory] + [InlineData("image/png", "media sample attached")] + [InlineData("video/mp4", "media sample attached")] + [InlineData("audio/ogg", "media sample attached")] + [InlineData("application/octet-stream", "no work-sample link found")] + public void MediaAttachmentsCanSupplySamplesWithoutTreatingExecutablesAsProof(string type, string expected) => + Assert.Equal(expected, RecruitmentContent.Portfolio("Samples attached", new[] { new StarterAttachment("1", "sample", type) })); + + [Theory] + [InlineData("DM for rates and payment terms", "private-terms")] + [InlineData("Payment details only in DMs", "private-terms")] + [InlineData("Pay an application fee of $10", "applicant-fee")] + [InlineData("No refunds for the application fee", "applicant-fee")] + [InlineData("Send money immediately to reserve the job", "payment-pressure")] + public void BehavioralObservationsRetainAnExcerpt(string content, string code) + { + var observation = Assert.Single(RecruitmentContent.Observations(content), o => o.Code == code); + Assert.Contains(content, observation.Evidence); + } + + [Theory] + [InlineData("Budget USD 500. Deliver in two weeks. DM to apply with your portfolio.")] + [InlineData("No application fees or deposits required.")] + [InlineData("Do not DM for payment details; terms must stay public.")] + [InlineData("Never send money immediately to someone you do not know.")] + public void OrdinaryContactAndExplicitSafetyAdviceAreNotPressure(string content) => Assert.Empty(RecruitmentContent.Observations(content)); + + [Fact] + public void HostRootsAndOverlongContentDoNotCreateBroadContactMatches() + { + Assert.Empty(RecruitmentContent.ContactLinks("https://github.com https://example.org/")); + Assert.Equal(new[] { "https://example.org/my-project" }, RecruitmentContent.ContactLinks("https://example.org/my-project?tracking=one https://example.org/my-project?tracking=two")); + Assert.Empty(RecruitmentContent.Observations(new string('a', 8000) + " DM for rates")); + } +} diff --git a/DiscordBot.Tests/Recruitment/RecruitmentEvidenceTests.cs b/DiscordBot.Tests/Recruitment/RecruitmentEvidenceTests.cs new file mode 100644 index 00000000..179510e4 --- /dev/null +++ b/DiscordBot.Tests/Recruitment/RecruitmentEvidenceTests.cs @@ -0,0 +1,71 @@ +using DiscordBot.Services.Recruitment; + +namespace DiscordBot.Tests.Recruitment; + +public class RecruitmentEvidenceTests +{ + [Fact] + public async Task TermsChangesBeyondDisplayedExcerptAreStillRecordedAndBounded() + { + using var rig = new RecruitmentRig(); await rig.Start(); var post = await rig.Post(); + rig.Discord.Threads[post.Id] = rig.Discord.Threads[post.Id] with { Starter = "Budget " + new string('a', 2400) + " USD 500" }; + await rig.Engine.FactsChangedAsync(post.Id, default); await rig.Ack(); + var evidence = post.Facts.PaymentEvidence; + rig.Discord.Threads[post.Id] = rig.Discord.Threads[post.Id] with { Starter = "Budget " + new string('a', 2400) + " USD 50" }; + await rig.Engine.FactsChangedAsync(post.Id, default); + Assert.Equal(evidence, post.Facts.PaymentEvidence); Assert.Equal(1, post.MaterialChanges); + Assert.Equal(2000, post.LatestStarter!.Content.Length); Assert.True(post.LatestStarter.Truncated); + } + + [Fact] + public async Task MaterialEditsKeepOriginalAndBoundedHistoryWithoutClosingListing() + { + using var rig = new RecruitmentRig(); await rig.Start(); var post = await rig.Post(); await rig.Ack(); + var original = post.OriginalStarter; + for (var i = 0; i < 6; i++) + { + rig.Clock.Advance(TimeSpan.FromMinutes(1)); + rig.Discord.Threads[post.Id] = rig.Discord.Threads[post.Id] with { Starter = $"Budget AUD {100 + i}. Portfolio: https://example.org/works/{i}" }; + await rig.Engine.FactsChangedAsync(post.Id, default); + } + Assert.Equal(original, post.OriginalStarter); Assert.Equal(6, post.MaterialChanges); + Assert.Equal(3, post.EarlierMaterialVersions.Count); Assert.Equal(ListingStatus.Open, post.Status); + Assert.Empty(rig.Discord.Actions); Assert.Single(post.Attention); + Assert.Contains("changed terms/links", rig.Engine.Health().Text()); + await rig.Restart(); + Assert.Contains("AUD 105", await rig.Staff("lookup")); Assert.Contains("AUD 500", await rig.Staff("lookup")); + await rig.Staff("review-changes"); + Assert.DoesNotContain("changed terms/links", rig.Engine.Health().Text()); + Assert.Equal(6, rig.State.Listings[post.Id].ReviewedMaterialChanges); + } + + [Fact] + public async Task CosmeticEditsAndUnavailableStarterDoNotInventMaterialChangesOrEraseEvidence() + { + using var rig = new RecruitmentRig(); await rig.Start(); var post = await rig.Post(); await rig.Ack(); + var original = post.OriginalStarter; + rig.Discord.Threads[post.Id] = rig.Discord.Threads[post.Id] with { Starter = "Budget AUD 500 for a short Unity project\n\nExtra description", HasCustomAvatar = false }; + await rig.Engine.FactsChangedAsync(post.Id, default); + Assert.Equal(0, post.MaterialChanges); Assert.False(post.Facts.HasCustomAvatar); + var latest = post.LatestStarter; + rig.Discord.Threads[post.Id] = rig.Discord.Threads[post.Id] with { Starter = null, HasCustomAvatar = null }; + await rig.Engine.FactsChangedAsync(post.Id, default); + Assert.Equal(original, post.OriginalStarter); Assert.Equal(latest, post.LatestStarter); + Assert.Equal("unknown", post.Facts.Payment); Assert.Null(post.Facts.HasCustomAvatar); + Assert.Contains("unavailable", RecruitmentPresentation.Details(post)); + } + + [Fact] + public async Task SharedContactHintsArePrivateAndRequireSpecificLinks() + { + using var rig = new RecruitmentRig(); await rig.Start(); await rig.Post(); await rig.Post("101", "201"); + foreach (var id in new[] { "100", "101" }) + { + rig.Discord.Threads[id] = rig.Discord.Threads[id] with { Starter = "Contact https://example.org/team" }; + await rig.Engine.FactsChangedAsync(id, default); + } + Assert.Contains("<#101>", await rig.Staff("lookup")); + Assert.DoesNotContain("101", await rig.Engine.DetailsAsync("1", "100", "200", default)); + Assert.DoesNotContain("101", rig.Discord.Messages["100:recruitment:100:public"].Message.TextFallback); + } +} diff --git a/DiscordBot.Tests/Recruitment/RecruitmentOverviewTests.cs b/DiscordBot.Tests/Recruitment/RecruitmentOverviewTests.cs index bcc413ec..5c95a15c 100644 --- a/DiscordBot.Tests/Recruitment/RecruitmentOverviewTests.cs +++ b/DiscordBot.Tests/Recruitment/RecruitmentOverviewTests.cs @@ -5,75 +5,51 @@ namespace DiscordBot.Tests.Recruitment; public class RecruitmentOverviewTests { [Fact] - public async Task FeedActivityReplacesOverviewOnlyAfterCooldownAndRetainsListingAudit() + public async Task OverviewStaysFixedWhileListingsAndHealthChange() { - using var rig = new RecruitmentRig(); await rig.Start(); var listing = await rig.Post(); - await rig.Engine.RefreshOverviewAsync(default); + using var rig = new RecruitmentRig(); await rig.Start(); await rig.Engine.RefreshOverviewAsync(default); var first = rig.State.Overview.MessageId; - Assert.NotNull(first); - rig.Engine.FeedActivity("999999"); - rig.Clock.Advance(TimeSpan.FromMinutes(4)); await rig.Engine.RefreshOverviewAsync(default); - Assert.Equal(first, rig.State.Overview.MessageId); - rig.Clock.Advance(TimeSpan.FromMinutes(1)); await rig.Engine.RefreshOverviewAsync(default); - Assert.NotEqual(first, rig.State.Overview.MessageId); - Assert.Contains(first, rig.Discord.DeletedMessages); - Assert.DoesNotContain(listing.StaffMessageId, rig.Discord.DeletedMessages); - Assert.Single(rig.Discord.Messages.Values, m => m.Message.Marker.StartsWith("recruitment:overview:")); + var listing = await rig.Post(); + await rig.Engine.RefreshOverviewAsync(default); + rig.Clock.Advance(TimeSpan.FromMinutes(6)); await rig.Engine.RefreshOverviewAsync(default); + Assert.Equal(first, rig.State.Overview.MessageId); Assert.Empty(rig.Discord.DeletedMessages); + var overview = Assert.Single(rig.Discord.Messages.Values, m => m.Message.Marker.StartsWith("recruitment:overview:")); + Assert.Contains("Open: 1", overview.Message.Embed!.Description); + Assert.NotNull(listing.StaffMessageId); var upserts = rig.Discord.Upserts; - rig.Engine.FeedActivity(rig.State.Overview.MessageId!, overview: true); rig.Clock.Advance(TimeSpan.FromMinutes(1)); await rig.Engine.RefreshOverviewAsync(default); Assert.Equal(upserts, rig.Discord.Upserts); } [Fact] - public async Task ContinuousActivityCannotStarveReplacementAfterCooldown() - { - using var rig = new RecruitmentRig(); await rig.Start(); await rig.Engine.RefreshOverviewAsync(default); - var first = rig.State.Overview.MessageId; - for (var i = 0; i < 73; i++) - { - rig.Engine.FeedActivity((900000 + i).ToString()); - rig.Clock.Advance(TimeSpan.FromSeconds(5)); await rig.Engine.RefreshOverviewAsync(default); - if (i < 59) Assert.Equal(first, rig.State.Overview.MessageId); - } - Assert.NotEqual(first, rig.State.Overview.MessageId); - Assert.Single(rig.Discord.DeletedMessages); - } - - [Fact] - public async Task LostReplacementResponseRecoversSameMessageAfterRestartBeforeRetiringOldOne() + public async Task LostInitialOverviewResponseRecoversSameMessageAfterRestart() { - using var rig = new RecruitmentRig(); await rig.Start(); await rig.Engine.RefreshOverviewAsync(default); - var first = rig.State.Overview.MessageId; - rig.Engine.FeedActivity("999999"); rig.Clock.Advance(TimeSpan.FromMinutes(6)); + using var rig = new RecruitmentRig(); await rig.Start(); rig.Discord.LoseNextSendResponse = true; await rig.Engine.RefreshOverviewAsync(default); - Assert.True(rig.State.Overview.SendPending); Assert.Equal(first, rig.State.Overview.MessageId); - Assert.DoesNotContain(first, rig.Discord.DeletedMessages); - await rig.Restart(); - await rig.Staff("reconcile"); - var sends = rig.Discord.Sends; + Assert.True(rig.State.Overview.SendPending); Assert.Null(rig.State.Overview.MessageId); + var sent = Assert.Single(rig.Discord.Messages.Values).Id; + await rig.Restart(); await rig.Staff("reconcile"); await rig.Engine.RefreshOverviewAsync(default); - Assert.Equal(sends, rig.Discord.Sends); - Assert.NotEqual(first, rig.State.Overview.MessageId); - Assert.Contains(first, rig.Discord.DeletedMessages); - Assert.False(rig.State.Overview.SendPending); + Assert.Equal(sent, rig.State.Overview.MessageId); + Assert.Single(rig.Discord.Messages.Values); Assert.False(rig.State.Overview.SendPending); } [Fact] - public async Task CleanupAndCosmeticFailureDoNotFreezeListingAcceptance() + public async Task LegacyRetirementFinishesWithoutMovingOverviewOrBlockingAcceptance() { using var rig = new RecruitmentRig(); await rig.Start(); await rig.Engine.RefreshOverviewAsync(default); - rig.Discord.FailCleanup = true; rig.Engine.FeedActivity("999999"); rig.Clock.Advance(TimeSpan.FromMinutes(6)); - await rig.Engine.RefreshOverviewAsync(default); - Assert.NotNull(rig.State.Overview.RetiredMessageId); var current = rig.State.Overview.MessageId; + rig.State.Overview.RetiredMessageId = "888888"; + rig.State.Overview.RetiredMarker = $"recruitment:overview:{rig.State.InstanceId}:legacy"; + rig.Discord.FailCleanup = true; + await rig.Engine.RefreshOverviewAsync(default); + Assert.NotNull(rig.State.Overview.Fault); await rig.Post(); Assert.Contains("accepted", await rig.Ack()); rig.Discord.FailCleanup = false; rig.Clock.Now = rig.State.Overview.Fault!.RetryAt!.Value; await rig.Engine.RefreshOverviewAsync(default); - Assert.Null(rig.State.Overview.RetiredMessageId); - Assert.Equal(current, rig.State.Overview.MessageId); + Assert.Null(rig.State.Overview.RetiredMessageId); Assert.Equal(current, rig.State.Overview.MessageId); } [Fact] @@ -85,7 +61,7 @@ public async Task MissingEmbedPermissionFallsBackToTextAndUnsafeFeedPublishesNot Assert.Null(message.Embed); Assert.Contains("Recruitment status", message.Content); rig.Discord.FailFeed = true; rig.Engine.InvalidateAccess(); await rig.Sweep(); var sends = rig.Discord.Sends; - rig.Engine.FeedActivity("999999"); rig.Clock.Advance(TimeSpan.FromMinutes(6)); + rig.Clock.Advance(TimeSpan.FromMinutes(6)); await rig.Engine.RefreshOverviewAsync(default); Assert.Equal(sends, rig.Discord.Sends); Assert.Contains("privacy invalid", rig.Engine.Health().Status); diff --git a/DiscordBot.Tests/Recruitment/RecruitmentPresentationTests.cs b/DiscordBot.Tests/Recruitment/RecruitmentPresentationTests.cs index e1cde2e2..58ba1e08 100644 --- a/DiscordBot.Tests/Recruitment/RecruitmentPresentationTests.cs +++ b/DiscordBot.Tests/Recruitment/RecruitmentPresentationTests.cs @@ -16,6 +16,26 @@ public class RecruitmentPresentationTests public void WorkSampleDetectionDistinguishesInvitesAndUnlabelledLinks(string? content, string expected) => Assert.Equal(expected, RecruitmentContent.Portfolio(content)); + [Fact] + public async Task MaximumReaderNotesFitTextFallbackAndDoNotImplyClearance() + { + using var rig = new RecruitmentRig(); await rig.Start(); var post = await rig.Post(forum: "12"); + post.Facts.Payment = "mixed compensation terms; clarify what is guaranteed"; + post.Facts.PaymentEvidence = new string('x', 363); + post.Facts.Observations = new List { + new("private-terms", "Post directs readers to private messages for terms", new string('x', 180)), + new("applicant-fee", "Post mentions an applicant payment or fee", new string('x', 180)), + new("payment-pressure", "Post combines payment with urgency", new string('x', 180)) }; + post.Hold = "Recovery needed"; + var message = RecruitmentPresentation.Public(rig.State, post, rig.Settings.Recruitment, rig.Clock.Now); + Assert.InRange(message.TextFallback!.Length, 1, 2000); + var color = message.Embed!.Color; + post.Facts.Observations.Clear(); post.Facts.Payment = "concrete rate or budget stated"; post.Facts.Portfolio = "work-sample link detected"; + var quiet = RecruitmentPresentation.Public(rig.State, post, rig.Settings.Recruitment, rig.Clock.Now); + Assert.Equal(color, quiet.Embed!.Color); + Assert.DoesNotContain("trusted", quiet.TextFallback); Assert.DoesNotContain("no red flags", quiet.TextFallback); + } + [Fact] public async Task ForHireEmbedHighlightsMissingEvidenceWithoutImplyingEndorsement() { @@ -24,14 +44,16 @@ public async Task ForHireEmbedHighlightsMissingEvidenceWithoutImplyingEndorsemen post.Facts.MemberRoleCount = 0; post.Facts.Payment = RecruitmentContent.Payment("DM for rates"); var message = RecruitmentPresentation.Public(rig.State, post, rig.Settings.Recruitment, rig.Clock.Now); var embed = Assert.IsType(message.Embed); - Assert.Equal(Discord.Color.Orange, embed.Color); - Assert.Equal(5, embed.Fields.Count(f => f.Name.StartsWith("⚠"))); - Assert.Contains("2 days at posting", embed.Fields[0].Value); - Assert.Contains("Clarify scope", embed.Fields.Last().Value); + Assert.Equal(new Discord.Color(0x747F8D), embed.Color); + Assert.Contains("account age: **2 days**", embed.Description); + Assert.Contains("Clarify scope", embed.Description); + Assert.Contains("No portfolio link", embed.Description); + Assert.DoesNotContain("roles", embed.Description); + Assert.True(message.AcknowledgementRequired); Assert.DoesNotContain("Initial snapshot", message.TextFallback); Assert.DoesNotContain("Community activity", message.TextFallback); Assert.DoesNotContain("Protected from", message.TextFallback); - Assert.Contains("do not verify identity", embed.Footer!.Value.Text); + Assert.Contains("identity and work are unverified", embed.Footer!.Value.Text); Assert.True(message.TextFallback!.Length < 2000); } @@ -42,10 +64,9 @@ public async Task HobbyRecruitingDoesNotDemandPaymentOrAForHirePortfolio() post.Facts.MemberRoleCount = 2; post.Facts.Payment = RecruitmentContent.Payment("Unpaid volunteer project"); var embed = RecruitmentPresentation.Public(rig.State, post, rig.Settings.Recruitment, rig.Clock.Now).Embed!; Assert.DoesNotContain(embed.Fields, f => f.Name.StartsWith("⚠") || f.Name.Contains("Portfolio")); - Assert.Equal(Discord.Color.Blue, embed.Color); + Assert.Equal(new Discord.Color(0x747F8D), embed.Color); post.Facts.Payment = RecruitmentContent.Payment("Revenue share"); - Assert.Contains(RecruitmentPresentation.Public(rig.State, post, rig.Settings.Recruitment, rig.Clock.Now).Embed!.Fields, - f => f.Name == "⚠ Payment" && f.Value.Contains("no guaranteed payment")); + Assert.Contains("guaranteed payment is unclear", RecruitmentPresentation.Public(rig.State, post, rig.Settings.Recruitment, rig.Clock.Now).Embed!.Description); } [Fact] @@ -56,14 +77,16 @@ public async Task StarterAndRoleCorrectionsRefreshEmbedFieldsWithoutScanningForu rig.Discord.Threads[post.Id] = rig.Discord.Threads[post.Id] with { Starter = "Portfolio https://example.org/works. Rates USD 50/hour", MemberRoleCount = 3 }; await rig.Engine.FactsChangedAsync(post.Id, default); var message = rig.Discord.Messages["100:recruitment:100:public"].Message; - Assert.Contains(message.Embed!.Fields, f => f.Name == "Portfolio / work samples"); - Assert.Contains(message.Embed.Fields, f => f.Name == "Server roles" && f.Value == "3 assigned"); + Assert.DoesNotContain("No portfolio", message.Embed!.Description); + Assert.False(message.AcknowledgementRequired); Assert.True(message.ReaderControls); + Assert.Contains("3.", RecruitmentPresentation.Details(post)); Assert.Equal(messageId, post.PublicMessageId); Assert.Equal(inventories, rig.Discord.Inventories); var writes = rig.Discord.Upserts; await rig.Engine.FactsChangedAsync(post.Id, default); Assert.Equal(writes, rig.Discord.Upserts); rig.Discord.Threads[post.Id] = rig.Discord.Threads[post.Id] with { MemberRoleCount = 0 }; await rig.Engine.FactsChangedAsync(post.Id, default); - Assert.Contains(rig.Discord.Messages["100:recruitment:100:public"].Message.Embed!.Fields, f => f.Name == "⚠ Server roles"); + Assert.Contains("0.", RecruitmentPresentation.Details(post)); + Assert.DoesNotContain("roles", rig.Discord.Messages["100:recruitment:100:public"].Message.Embed!.Description); } } diff --git a/DiscordBot.Tests/Recruitment/RecruitmentPublicationTests.cs b/DiscordBot.Tests/Recruitment/RecruitmentPublicationTests.cs index 1712fae4..fd382d7e 100644 --- a/DiscordBot.Tests/Recruitment/RecruitmentPublicationTests.cs +++ b/DiscordBot.Tests/Recruitment/RecruitmentPublicationTests.cs @@ -69,6 +69,51 @@ public async Task InstalledSdkUpdatesFieldOnlyChangesAndRemovesLegacyBannerOnce( Assert.Equal(2, writes); } + [Theory] + [InlineData(null, null, false)] + [InlineData("global-hash", null, true)] + [InlineData(null, "server-hash", true)] + public async Task InstalledSdkUsesEffectiveAvatarAndMediaAttachments(string? globalAvatar, string? serverAvatar, bool custom) + { + var transport = new PublicationTransport { GlobalAvatar = globalAvatar, ServerAvatar = serverAvatar, + Attachments = new JArray(new JObject { ["id"] = "800", ["filename"] = "demo.mp4", ["content_type"] = "video/mp4", ["size"] = 100, + ["url"] = "https://cdn.discordapp.com/demo.mp4", ["proxy_url"] = "https://cdn.discordapp.com/demo.mp4" }) }; + using var client = await Client(transport); + var thread = await new DiscordRecruitmentAdapter(client, Settings()).GetThreadAsync("100", default); + Assert.Equal(custom, thread!.HasCustomAvatar); + Assert.Equal("media sample attached", RecruitmentContent.Portfolio(thread.Starter, thread.Attachments)); + } + + [Fact] + public async Task InstalledSdkRemovesAcknowledgementButtonWhileKeepingReaderAndOwnerControls() + { + var transport = new PublicationTransport(); using var client = await Client(transport); + var adapter = new DiscordRecruitmentAdapter(client, Settings()); + const string marker = "recruitment:100:public"; + var message = new RecruitmentMessage($"||{marker}||", marker, true, AcknowledgementRequired: true, ReaderControls: true); + await adapter.UpsertAsync("100", "101", false, DateTimeOffset.UtcNow, message, () => { }, default); + Assert.Contains("recruit:ack:100", transport.MessageWrites.Last().ToString()); + await adapter.UpsertAsync("100", "101", false, DateTimeOffset.UtcNow, message with { AcknowledgementRequired = false }, () => { }, default); + var controls = transport.MessageWrites.Last()["components"]!.ToString(); + Assert.DoesNotContain("recruit:ack:", controls); Assert.Contains("recruit:report:100", controls); + Assert.Contains("recruit:details:100", controls); Assert.Contains("recruit:owner:100", controls); + Assert.DoesNotContain("recruit:remove:", controls); + var writes = transport.MessageWrites.Count; + await adapter.UpsertAsync("100", "101", false, DateTimeOffset.UtcNow, message with { AcknowledgementRequired = false }, () => { }, default); + Assert.Equal(writes, transport.MessageWrites.Count); + } + + [Fact] + public async Task DepartedMemberLeavesProfileFactsUnavailableWithoutLosingStarter() + { + var transport = new PublicationTransport { MissingMember = true }; using var client = await Client(transport); + var adapter = new DiscordRecruitmentAdapter(client, Settings()); + var thread = await adapter.GetThreadAsync("100", default); + Assert.NotNull(thread); Assert.NotNull(thread.Starter); + Assert.Null(thread.JoinedAt); Assert.Null(thread.HasCustomAvatar); Assert.Null(thread.MemberRoleCount); + Assert.False(await adapter.IsMemberAsync("1", "200", default)); + } + private static BotSettings Settings() => new() { GuildId = 1, Recruitment = new RecruitmentSettings { Forums = new RecruitmentForums { PaidRecruitingId = 11, PaidForHireId = 12, HobbyRecruitingId = 13, HobbyForHireId = 14 } }, Roles = new() { Moderator = 50 } }; @@ -90,6 +135,10 @@ private sealed class PublicationTransport : IRestClient { public string Role { get; init; } = "52"; public bool OwnerBot { get; init; } + public bool MissingMember { get; init; } + public string? GlobalAvatar { get; init; } + public string? ServerAvatar { get; init; } + public JArray Attachments { get; init; } = new(); public string StarterContent { get; private set; } = "Example listing"; public JObject? CreatePayload { get; private set; } public List ChannelWrites { get; } = new(); @@ -105,9 +154,13 @@ public void SetCancelToken(CancellationToken token) { } private Task Respond(string method, string endpoint, JObject? patch) { string body; + if (endpoint.StartsWith("guilds/1/members/200") && MissingMember) + return Task.FromResult(new RestResponse(HttpStatusCode.NotFound, new Dictionary(), new MemoryStream(Encoding.UTF8.GetBytes("{\"code\":10007,\"message\":\"Unknown Member\"}")))); if (endpoint == "users/@me") body = """{"id":"999","username":"offline","discriminator":"0","bot":true}"""; else if (endpoint == "oauth2/applications/@me") body = """{"id":"999","name":"offline","description":"","bot_public":true,"bot_require_code_grant":false,"verify_key":"00"}"""; - else if (endpoint.StartsWith("guilds/1/members/200")) body = $$"""{"user":{"id":"200","username":"member","discriminator":"0","bot":{{OwnerBot.ToString().ToLowerInvariant()}}},"roles":["{{Role}}"],"joined_at":"2020-01-01T00:00:00Z"}"""; + else if (endpoint.StartsWith("guilds/1/members/200")) body = new JObject { + ["user"] = new JObject { ["id"] = "200", ["username"] = "member", ["discriminator"] = "0", ["bot"] = OwnerBot, ["avatar"] = GlobalAvatar }, + ["avatar"] = ServerAvatar, ["roles"] = new JArray(Role), ["joined_at"] = "2020-01-01T00:00:00Z" }.ToString(); else if (endpoint.StartsWith("guilds/1")) body = """{"id":"1","name":"Offline","owner_id":"900","roles":[{"id":"1","name":"@everyone","permissions":"0"},{"id":"50","name":"Moderator","permissions":"0"},{"id":"51","name":"Admin","permissions":"8"},{"id":"52","name":"Artist","permissions":"0"}],"emojis":[],"features":[],"stickers":[],"afk_timeout":300,"preferred_locale":"en-US"}"""; else if (endpoint == "channels/11") body = """{"id":"11","type":15,"guild_id":"1","name":"forum","flags":16,"available_tags":[{"id":"71","name":"Closed","moderated":false}]}"""; else if (endpoint == "channels/11/threads" && method == "POST") @@ -129,7 +182,7 @@ private Task Respond(string method, string endpoint, JObject? patc body = Thread.ToString(); } else if (endpoint == "channels/100/messages/100") body = new JObject { ["id"] = "100", ["channel_id"] = "100", ["type"] = 0, - ["content"] = StarterContent, ["timestamp"] = "2026-10-14T12:00:00Z", ["author"] = new JObject { ["id"] = Thread["owner_id"]!.ToString(), ["username"] = "owner", ["discriminator"] = "0", ["bot"] = (string?)Thread["owner_id"] == "999" || OwnerBot }, ["embeds"] = new JArray(), ["attachments"] = new JArray() }.ToString(); + ["content"] = StarterContent, ["timestamp"] = "2026-10-14T12:00:00Z", ["author"] = new JObject { ["id"] = Thread["owner_id"]!.ToString(), ["username"] = "owner", ["discriminator"] = "0", ["bot"] = (string?)Thread["owner_id"] == "999" || OwnerBot }, ["embeds"] = new JArray(), ["attachments"] = Attachments }.ToString(); else if (endpoint == "channels/100/messages/101") { if (method == "PATCH") { MessageWrites.Add(patch!); foreach (var property in patch!.Properties()) _message[property.Name] = property.Value.DeepClone(); } diff --git a/DiscordBot.Tests/Recruitment/RecruitmentRecoveryTests.cs b/DiscordBot.Tests/Recruitment/RecruitmentRecoveryTests.cs index e138296c..f40988e3 100644 --- a/DiscordBot.Tests/Recruitment/RecruitmentRecoveryTests.cs +++ b/DiscordBot.Tests/Recruitment/RecruitmentRecoveryTests.cs @@ -18,8 +18,8 @@ public async Task InteractionModuleRegistersWithoutConnectingOrStartingTheFeatur using var provider = new ServiceCollection().AddSingleton(service).BuildServiceProvider(); var module = await interactions.AddModuleAsync(provider); Assert.Equal(9, module.SlashCommands.Count); - Assert.Equal(4, module.ComponentCommands.Count); - Assert.Single(module.ModalCommands); + Assert.Equal(7, module.ComponentCommands.Count); + Assert.Equal(2, module.ModalCommands.Count); } [Fact] diff --git a/DiscordBot.Tests/Recruitment/RecruitmentReportTests.cs b/DiscordBot.Tests/Recruitment/RecruitmentReportTests.cs new file mode 100644 index 00000000..d26371bd --- /dev/null +++ b/DiscordBot.Tests/Recruitment/RecruitmentReportTests.cs @@ -0,0 +1,77 @@ +using DiscordBot.Services.Recruitment; + +namespace DiscordBot.Tests.Recruitment; + +public class RecruitmentReportTests +{ + [Fact] + public async Task ReportsRemainPrivateAndDeduplicateAcrossRestarts() + { + using var rig = new RecruitmentRig(); await rig.Start(); var post = await rig.Post(); await rig.Ack(); + var before = rig.Discord.Messages["100:recruitment:100:public"].Message.RenderKey; + const string concern = "They asked for an application deposit in DMs."; + Assert.Contains("saved for moderators", await rig.Engine.ReportAsync("1", "100", "201", concern, default)); + Assert.Equal(before, rig.Discord.Messages["100:recruitment:100:public"].Message.RenderKey); + Assert.DoesNotContain(concern, await rig.Engine.DetailsAsync("1", "100", "200", default)); + Assert.Contains(concern, await rig.Staff("lookup")); + Assert.Contains("private reports", rig.Engine.Health().Text()); + await rig.Restart(); + Assert.Contains("already reported", await rig.Engine.ReportAsync("1", "100", "201", concern, default)); + Assert.Single(rig.State.Listings[post.Id].Reports); + Assert.Single(rig.Discord.Messages.Values, m => m.Message.Marker.Contains(":attention:")); + } + + [Fact] + public async Task ReporterMustBeCurrentMemberOfCorrectGuildAndCannotReportOwnListing() + { + using var rig = new RecruitmentRig(); await rig.Start(); await rig.Post(); + Assert.Contains("own listing", await rig.Engine.ReportAsync("1", "100", "200", "Concern details", default)); + Assert.Contains("unavailable", await rig.Engine.ReportAsync("2", "100", "201", "Concern details", default)); + rig.Discord.MissingMembers.Add("201"); + Assert.Contains("not a current", await rig.Engine.ReportAsync("1", "100", "201", "Concern details", default)); + Assert.Contains("unavailable", await rig.Engine.DetailsAsync("1", "100", "201", default)); + Assert.Empty(rig.State.Listings["100"].Reports); + var controls = await rig.Engine.OwnerDetailsAsync("1", "100", "201", default); + Assert.False(controls.Open); Assert.False(controls.Removable); + } + + [Fact] + public async Task PerReporterLimitSurvivesRestartAndExpiresAfterOneDay() + { + using var rig = new RecruitmentRig(); await rig.Start(); + for (var i = 0; i < 4; i++) await rig.Post((100 + i).ToString(), (200 + i).ToString()); + for (var i = 0; i < 3; i++) Assert.Contains("saved", await rig.Engine.ReportAsync("1", (100 + i).ToString(), "300", "Concern details", default)); + await rig.Restart(); + Assert.Contains("three listing reports", await rig.Engine.ReportAsync("1", "103", "300", "Concern details", default)); + rig.Clock.Advance(TimeSpan.FromDays(1)); + Assert.Contains("saved", await rig.Engine.ReportAsync("1", "103", "300", "Concern details", default)); + } + + [Fact] + public async Task UnsafeFeedRejectsReportWithoutRetainingIt() + { + using var rig = new RecruitmentRig(); await rig.Start(); await rig.Post(); + rig.Discord.FailFeed = true; rig.Engine.InvalidateAccess(); await rig.Sweep(); + Assert.Contains("temporarily unavailable", await rig.Engine.ReportAsync("1", "100", "201", "Concern details", default)); + Assert.Empty(rig.State.Listings["100"].Reports); + } + + [Fact] + public async Task LostNoticeResponseIsRecoveredOnceAndReportReviewIsAudited() + { + using var rig = new RecruitmentRig(); await rig.Start(); await rig.Post(); await rig.Ack(); + rig.Discord.LoseNextSendResponse = true; + Assert.Contains("saved", await rig.Engine.ReportAsync("1", "100", "201", "Concern details", default)); + Assert.True(Assert.Single(rig.State.Listings["100"].Attention).SendPending); + await rig.Restart(); rig.Clock.Advance(TimeSpan.FromMinutes(2)); await rig.Engine.RunDueAsync(default); + var notice = Assert.Single(rig.State.Listings["100"].Attention); + Assert.False(notice.SendPending); Assert.NotNull(notice.MessageId); + Assert.Single(rig.Discord.Messages.Values, m => m.Message.Marker.Contains(":attention:")); + Assert.Contains("Moderator access", await rig.Staff("resolve-reports", actor: "201")); + Assert.Null(rig.State.Listings["100"].Reports[0].ResolvedAt); + await rig.Staff("resolve-reports"); + Assert.NotNull(rig.State.Listings["100"].Reports[0].ResolvedAt); + Assert.DoesNotContain("private reports", rig.Engine.Health().Text()); + Assert.Contains(rig.State.Listings["100"].Audit, a => a.Reason.Contains("resolve-reports")); + } +} diff --git a/DiscordBot.Tests/Recruitment/RecruitmentSchedulingTests.cs b/DiscordBot.Tests/Recruitment/RecruitmentSchedulingTests.cs index 8da307f3..81b712c3 100644 --- a/DiscordBot.Tests/Recruitment/RecruitmentSchedulingTests.cs +++ b/DiscordBot.Tests/Recruitment/RecruitmentSchedulingTests.cs @@ -4,6 +4,22 @@ namespace DiscordBot.Tests.Recruitment; public class RecruitmentSchedulingTests { + [Fact] + public async Task QueuedIdentityIsRoutineButStillFencesAutomaticActions() + { + using var rig = new RecruitmentRig(); + var pending = false; + using var engine = new RecruitmentEngine(rig.Settings, rig.Discord, rig.Clock, pendingIdentities: () => pending); + await engine.StartAsync(default); + pending = true; + Assert.True(engine.Health().RoutinePendingIdentity); Assert.False(engine.Health().InventoryComplete); + Assert.Equal("Processing new listing identities", engine.Health().Status); + pending = false; + Assert.True(engine.Health().InventoryComplete); Assert.Equal("Operating", engine.Health().Status); + engine.MarkEvidenceGap(); + Assert.False(engine.Health().RoutinePendingIdentity); Assert.False(engine.Health().InventoryComplete); + } + [Fact] public async Task AcknowledgementAndUnchangedThreadEventDoNotScanForumsOrRewriteMessages() { diff --git a/DiscordBot.Tests/Recruitment/RecruitmentStorageAndSetupTests.cs b/DiscordBot.Tests/Recruitment/RecruitmentStorageAndSetupTests.cs index 25456784..72a35c3b 100644 --- a/DiscordBot.Tests/Recruitment/RecruitmentStorageAndSetupTests.cs +++ b/DiscordBot.Tests/Recruitment/RecruitmentStorageAndSetupTests.cs @@ -249,7 +249,7 @@ public async Task DisabledServiceDoesNotCreateStateOrPublish_ButForumsStillExclu } [Theory] - [InlineData("USD 2000", "concrete rate or budget stated")] + [InlineData("USD 2000", "amount mentioned; compensation unclear")] [InlineData("$35/hour", "concrete rate or budget stated")] [InlineData("150 EUR per day", "concrete rate or budget stated")] [InlineData("Revenue-share", "revenue-share stated")] diff --git a/DiscordBot.Tests/Recruitment/RecruitmentWorkflowTests.cs b/DiscordBot.Tests/Recruitment/RecruitmentWorkflowTests.cs index 33e4192a..9a6b02ee 100644 --- a/DiscordBot.Tests/Recruitment/RecruitmentWorkflowTests.cs +++ b/DiscordBot.Tests/Recruitment/RecruitmentWorkflowTests.cs @@ -157,6 +157,19 @@ public async Task LegacyPendingUnansweredClosureIsCancelledAfterRestart() Assert.Empty(rig.Discord.Actions); } + [Fact] + public async Task LegacyExpiryAlreadyAppliedBeforeUpgradeStaysClosed() + { + using var rig = new RecruitmentRig(); await rig.Start(); var post = await rig.Post(); await rig.Ack(); + post.PendingAction = new RecruitmentAction { Kind = RecruitmentActionKind.Close, Condition = "unanswered", Automatic = true, + CallStarted = true, CreatedAt = rig.Clock.Now }; + rig.Discord.Threads[post.Id] = rig.Discord.Threads[post.Id] with { Locked = true, Archived = true }; + File.WriteAllText(Path.Combine(rig.Root, "recruitment/recruitment-state.json"), Newtonsoft.Json.JsonConvert.SerializeObject(rig.State)); + await rig.Restart(); + Assert.Null(rig.State.Listings[post.Id].PendingAction); Assert.Equal(ListingStatus.Closed, rig.State.Listings[post.Id].Status); + Assert.Empty(rig.Discord.Actions); + } + [Fact] public async Task AccessFailureDoesNotFreeAcceptedSlot() { diff --git a/DiscordBot/Modules/Recruitment/RecruitmentModule.cs b/DiscordBot/Modules/Recruitment/RecruitmentModule.cs index 3c076720..6b2ff8d8 100644 --- a/DiscordBot/Modules/Recruitment/RecruitmentModule.cs +++ b/DiscordBot/Modules/Recruitment/RecruitmentModule.cs @@ -42,6 +42,7 @@ public async Task Status() [SlashCommand("manage", "Change a listing or review uncertain evidence")] public Task Manage( [Choice("Exempt", "exempt"), Choice("Remove exemption", "unexempt"), Choice("Reset acknowledgement/timeouts", "reset"), Choice("Reset timeout streak", "reset-timeouts"), Choice("Reset posting attempts", "reset-attempts"), + Choice("Resolve reader reports", "resolve-reports"), Choice("Mark material changes reviewed", "review-changes"), Choice("Close", "close"), Choice("Reopen", "reopen"), Choice("Designate reference post", "reference"), Choice("Confirm unavailable thread is absent", "review-absent")] string operation, string threadId, string reason) => Staff(operation, threadId, reason); @@ -56,6 +57,51 @@ private async Task Staff(string operation, string target = "", string reason = " await Reply(() => Recruitment.RunAsync((engine, ct) => engine.StaffAsync(Guild, Actor, operation, target, reason, ct))); } + [ComponentInteraction("recruit:details:*", true)] + public async Task Details(string thread) + { + await DeferAsync(ephemeral: true); + if (thread != Thread) { await FollowupAsync("This control belongs to another thread.", ephemeral: true); return; } + await Reply(() => Recruitment.RunAsync((engine, ct) => engine.DetailsAsync(Guild, thread, Actor, ct))); + } + + [ComponentInteraction("recruit:owner:*", true)] + public async Task OwnerControls(string thread) + { + await DeferAsync(ephemeral: true); + if (thread != Thread) { await FollowupAsync("This control belongs to another thread.", ephemeral: true); return; } + try + { + var result = await Recruitment.RunAsync((engine, ct) => engine.OwnerDetailsAsync(Guild, thread, Actor, ct)); + var controls = new ComponentBuilder(); + if (result.Open) controls.WithButton("Close listing", $"recruit:close:{thread}", ButtonStyle.Secondary); + if (result.Removable) controls.WithButton("Remove post", $"recruit:remove:{thread}", ButtonStyle.Danger); + await FollowupAsync(result.Message, ephemeral: true, components: controls.Build(), allowedMentions: AllowedMentions.None); + } + catch { await FollowupAsync("Listing controls are unavailable. Please try again or contact a moderator.", ephemeral: true); } + } + + [ComponentInteraction("recruit:report:*", true)] + public async Task Report(string thread) + { + if (thread != Thread || Context.Guild is null) { await RespondAsync("This control belongs to another thread.", ephemeral: true); return; } + await RespondWithModalAsync($"recruit:concern:{thread}"); + } + public sealed class ConcernModal : IModal + { + public string Title => "Private report to moderators"; + [InputLabel("Concern and relevant message links or context")] + [ModalTextInput("concern", TextInputStyle.Paragraph, minLength: 10, maxLength: 1000)] + public string Concern { get; set; } = ""; + } + [ModalInteraction("recruit:concern:*", true)] + public async Task SubmitConcern(string thread, ConcernModal modal) + { + await DeferAsync(ephemeral: true); + if (thread != Thread) { await FollowupAsync("This control belongs to another thread.", ephemeral: true); return; } + await Reply(() => Recruitment.RunAsync((engine, ct) => engine.ReportAsync(Guild, thread, Actor, modal.Concern, ct))); + } + [ComponentInteraction("recruit:ack:*", true)] public async Task Acknowledge(string thread) { diff --git a/DiscordBot/Services/Recruitment/DiscordRecruitmentAdapter.cs b/DiscordBot/Services/Recruitment/DiscordRecruitmentAdapter.cs index dc943117..8b78ff89 100644 --- a/DiscordBot/Services/Recruitment/DiscordRecruitmentAdapter.cs +++ b/DiscordBot/Services/Recruitment/DiscordRecruitmentAdapter.cs @@ -68,23 +68,22 @@ public async Task ValidateFeedAsync(CancellationToken ct) return access.EmbedLinks; } - public async Task LatestFeedMessageAsync(CancellationToken ct) + public async Task IsMemberAsync(string guildId, string userId, CancellationToken ct) { - var channel = await MessageChannelAsync(_settings.Recruitment.FeedChannelId.ToString(), ct); - return (await channel.GetMessagesAsync(1, CacheMode.AllowDownload, Options(ct)).FlattenAsync()).FirstOrDefault()?.Id.ToString(); + if (guildId != _settings.GuildId.ToString() || !ulong.TryParse(userId, out var id)) return false; + return await MemberAsync(id, ct) is not null; } - public async Task IsMemberAsync(string guildId, string userId, CancellationToken ct) + private async Task MemberAsync(ulong userId, CancellationToken ct) { - if (guildId != _settings.GuildId.ToString() || !ulong.TryParse(userId, out var id)) return false; - try { return await _client.Rest.GetGuildUserAsync(_settings.GuildId, id, Options(ct)) is not null; } - catch (HttpException ex) when (ex.DiscordCode == DiscordErrorCode.UnknownMember) { return false; } + try { return await _client.Rest.GetGuildUserAsync(_settings.GuildId, userId, Options(ct)); } + catch (HttpException ex) when (ex.DiscordCode == DiscordErrorCode.UnknownMember) { return null; } } public async Task IsModeratorAsync(string guildId, string userId, CancellationToken ct) { if (guildId != _settings.GuildId.ToString() || !ulong.TryParse(userId, out var id)) return false; - var user = await _client.Rest.GetGuildUserAsync(_settings.GuildId, id, Options(ct)); + var user = await MemberAsync(id, ct); return user is not null && (user.GuildPermissions.Administrator || user.RoleIds.Contains(_settings.Roles.Moderator)); } @@ -211,13 +210,14 @@ private async Task SnapshotAsync(RestThreadChannel thread, Cance var id = thread.Id.ToString(); if (!_initialFacts.TryGetValue(id, out var facts)) { - var user = await _client.Rest.GetGuildUserAsync(_settings.GuildId, ownerId, Options(ct)); + var user = await MemberAsync(ownerId, ct); var starter = await thread.GetMessageAsync(thread.Id, Options(ct)); facts = new ThreadSnapshot(id, thread.ParentChannelId.ToString(), ownerId.ToString(), thread.Name, thread.CreatedAt.ToUniversalTime(), user?.IsBot ?? starter?.Author.IsBot ?? false, false, false, false, Array.Empty(), starter?.Content, SnowflakeUtils.FromSnowflake(ownerId), user?.JoinedAt, user is not null && (user.GuildPermissions.Administrator || user.RoleIds.Contains(_settings.Roles.Moderator)), - user?.RoleIds.Count(r => r != _settings.GuildId)); + user?.RoleIds.Count(r => r != _settings.GuildId), user is null ? null : !string.IsNullOrEmpty(user.DisplayAvatarId), + starter?.Attachments.Take(10).Select(a => new StarterAttachment(a.Id.ToString(), RecruitmentPresentation.Safe(a.Filename, 120), a.ContentType?[..Math.Min(a.ContentType.Length, 80)])).ToArray()); // Missing starters remain retryable. Bound cached initial facts independently of retained history. if (starter is not null) { @@ -269,15 +269,17 @@ public async Task UpsertAsync(string channelId, string? messageId, boo } if (existing is null && !covered) throw new InvalidOperationException("Message recovery history exceeds the scan bound; staff review required before resending."); } - var components = message.OwnerControls ? new ComponentBuilder() - .WithButton("Acknowledge", $"recruit:ack:{channelId}", ButtonStyle.Primary) - .WithButton("Close listing", $"recruit:close:{channelId}", ButtonStyle.Secondary) - .WithButton("Remove post", $"recruit:remove:{channelId}", ButtonStyle.Danger).Build() : new ComponentBuilder().Build(); + var builder = new ComponentBuilder(); + if (message.AcknowledgementRequired) builder.WithButton("Acknowledge", $"recruit:ack:{channelId}", ButtonStyle.Primary); + if (message.ReaderControls) builder.WithButton("Details", $"recruit:details:{channelId}", ButtonStyle.Secondary) + .WithButton("Report concern", $"recruit:report:{channelId}", ButtonStyle.Secondary); + if (message.OwnerControls) builder.WithButton("Your listing", $"recruit:owner:{channelId}", ButtonStyle.Secondary); + var components = builder.Build(); if (existing is not null) { var unarchivedForEdit = false; var edit = existing.Content != message.Content || RecruitmentMessage.EmbedKey(existing.Embeds.FirstOrDefault()) != RecruitmentMessage.EmbedKey(message.Embed) || - existing.Embeds.Count > (message.Embed is null ? 0 : 1) || existing.Attachments.Count > 0 || existing.Components.Count == 0 && message.OwnerControls || existing.Components.Count > 0 && !message.OwnerControls; + existing.Embeds.Count > (message.Embed is null ? 0 : 1) || existing.Attachments.Count > 0 || ComponentKey(existing.Components) != ComponentKey(components.Components); if (edit) { beforeWrite(); @@ -309,6 +311,14 @@ public async Task UpsertAsync(string channelId, string? messageId, boo return new Delivery(sent.Id.ToString(), true); } + // Compare controls, not just presence, so acknowledgement disappears after acceptance. + private static string ComponentKey(IEnumerable components) => string.Join("|", components.Select(c => c switch + { + ActionRowComponent row => "row:" + ComponentKey(row.Components), + ButtonComponent button => $"{button.CustomId}:{button.Label}:{button.Style}:{button.IsDisabled}:{button.Url}", + _ => c.Type.ToString() + })); + public async Task CloseAsync(ThreadSnapshot thread, string? closedTagId, bool protectPinned, CancellationToken ct, Func? allowWrite = null) { var live = await ThreadAsync(thread.Id, ct); diff --git a/DiscordBot/Services/Recruitment/IRecruitmentDiscord.cs b/DiscordBot/Services/Recruitment/IRecruitmentDiscord.cs index ed0a9c75..2f5adfa2 100644 --- a/DiscordBot/Services/Recruitment/IRecruitmentDiscord.cs +++ b/DiscordBot/Services/Recruitment/IRecruitmentDiscord.cs @@ -4,12 +4,12 @@ public sealed record LiveTag(string Id, string Name, bool Moderated, IEmote? Emo public sealed record ForumSnapshot(string Id, string Topic, bool RequireTag, IReadOnlyList Tags, ChannelFlags Flags = ChannelFlags.None); public sealed record ThreadSnapshot(string Id, string ForumId, string OwnerId, string Title, DateTimeOffset CreatedAt, bool OwnerIsBot, bool Pinned, bool Archived, bool Locked, IReadOnlyList Tags, string? Starter, - DateTimeOffset? AccountCreatedAt, DateTimeOffset? JoinedAt, bool OwnerIsStaff = false, int? MemberRoleCount = null); + DateTimeOffset? AccountCreatedAt, DateTimeOffset? JoinedAt, bool OwnerIsStaff = false, int? MemberRoleCount = null, bool? HasCustomAvatar = null, IReadOnlyList? Attachments = null); public sealed record ArchiveCursor(DateTimeOffset Since, DateTimeOffset? Before = null, DateTimeOffset? StartedAt = null); public sealed record ThreadInventory(IReadOnlyList Threads, bool Complete, IReadOnlyDictionary? Cursors = null); -public sealed record RecruitmentMessage(string Content, string Marker, bool OwnerControls, bool RestoreArchive = false, Embed? Embed = null, string? TextFallback = null) +public sealed record RecruitmentMessage(string Content, string Marker, bool OwnerControls, bool RestoreArchive = false, Embed? Embed = null, string? TextFallback = null, bool AcknowledgementRequired = false, bool ReaderControls = false) { - public string RenderKey => Content + "\n" + OwnerControls + "\n" + EmbedKey(Embed); + public string RenderKey => Content + "\n" + OwnerControls + "\n" + AcknowledgementRequired + "\n" + ReaderControls + "\n" + EmbedKey(Embed); public static string EmbedKey(IEmbed? embed) => embed is null ? "" : Newtonsoft.Json.JsonConvert.SerializeObject(embed.ToEmbedBuilder().Build()); } public sealed record Delivery(string Id, bool NewlySent); @@ -19,7 +19,6 @@ public interface IRecruitmentDiscord { Task ValidateAccessAsync(CancellationToken ct); Task ValidateFeedAsync(CancellationToken ct); - Task LatestFeedMessageAsync(CancellationToken ct); Task IsMemberAsync(string guildId, string userId, CancellationToken ct); Task IsModeratorAsync(string guildId, string userId, CancellationToken ct); bool IsExpectedForumUpdate(ForumSnapshot snapshot); diff --git a/DiscordBot/Services/Recruitment/RecruitmentAttempts.cs b/DiscordBot/Services/Recruitment/RecruitmentAttempts.cs index 8fd1ebb1..daaadbca 100644 --- a/DiscordBot/Services/Recruitment/RecruitmentAttempts.cs +++ b/DiscordBot/Services/Recruitment/RecruitmentAttempts.cs @@ -19,7 +19,10 @@ private void RecordCreation(Listing post) history.RecentCreations.RemoveAll(d => d <= Now.AddMinutes(-_settings.AttemptWindowMinutes)); history.RecentCreations.Add(post.CreatedAt); if (history.RecentCreations.Count > _settings.AttemptLimit) + { post.AttemptPausedUntil = history.AttemptPausedUntil = Now.AddMinutes(_settings.AttemptPauseMinutes); + QueueAttention(post, "Repeated creations triggered a posting pause"); + } } private static string EligibilityReason(Eligibility eligibility) diff --git a/DiscordBot/Services/Recruitment/RecruitmentAttention.cs b/DiscordBot/Services/Recruitment/RecruitmentAttention.cs new file mode 100644 index 00000000..7ca21b83 --- /dev/null +++ b/DiscordBot/Services/Recruitment/RecruitmentAttention.cs @@ -0,0 +1,33 @@ +namespace DiscordBot.Services.Recruitment; + +public sealed partial class RecruitmentEngine +{ + private string? AttentionSummary() + { + if (State is null) return null; + var rows = new List(); + foreach (var post in State.Listings.Values.OrderByDescending(p => p.UpdatedAt)) + { + var reasons = new List(); + if (post.Reports.Any(r => r.ResolvedAt is null)) reasons.Add("private reports"); + if (post.MaterialChanges > post.ReviewedMaterialChanges) reasons.Add("changed terms/links"); + if (post.Hold is not null) reasons.Add("processing held"); + if (post.AttemptPausedUntil > Now) reasons.Add("posting pause"); + if (post.TimeoutAlertCount >= 3 && State.Authors.GetValueOrDefault(post.OwnerId)?.TimeoutAlerted == true) reasons.Add("repeated timeouts"); + if (post.Attention.Any(n => n.Fault is not null)) reasons.Add("notice delivery held"); + if (reasons.Count > 0) rows.Add($"• <#{post.Id}> · {string.Join(", ", reasons)}"); + } + if (rows.Count == 0) return null; + return string.Join("\n", rows.Take(5)) + (rows.Count > 5 ? $"\n…and {rows.Count - 5} more; use lookup for individual records." : ""); + } + + private string? ActivitySummary() + { + if (State is null) return null; + var recent = State.Listings.Values.Where(p => p.CreatedAt > Now.AddDays(-1)).ToArray(); + var accepted = recent.Where(p => p.AcceptedAt.HasValue).ToArray(); + var delay = accepted.Length == 0 ? "—" : Math.Round(accepted.Average(p => (p.AcceptedAt!.Value - (p.PromptDeliveredAt ?? p.CreatedAt)).TotalMinutes), 1).ToString("0.#", System.Globalization.CultureInfo.InvariantCulture) + " min"; + return $"Past 24h: {recent.Length} creations · {accepted.Length} accepted · {recent.Count(p => p.Status == ListingStatus.Closed && !p.AcceptedAt.HasValue)} closed conflicts · " + + $"{recent.Count(p => p.LastOutcome == "Guidelines acknowledgement timed out")} timeouts · mean prompt-to-accept: {delay}."; + } +} diff --git a/DiscordBot/Services/Recruitment/RecruitmentContent.cs b/DiscordBot/Services/Recruitment/RecruitmentContent.cs index 412e6644..1fa75612 100644 --- a/DiscordBot/Services/Recruitment/RecruitmentContent.cs +++ b/DiscordBot/Services/Recruitment/RecruitmentContent.cs @@ -2,46 +2,64 @@ namespace DiscordBot.Services.Recruitment; +public sealed record ContentObservation(string Code, string Description, string Evidence); +public sealed record PaymentObservation(string Summary, string Evidence); +public sealed record StarterAttachment(string Id, string Name, string? ContentType); + public static partial class RecruitmentContent { - // Bounded input and regex timeout keep member-provided content off the unbounded work path. - public static string Payment(string? content) + private static string Bounded(string text) => text[..Math.Min(text.Length, 8000)]; + private static string Excerpt(string text) => RecruitmentPresentation.Safe(text.Trim(), 180); + public static string Payment(string? content) => ReadPayment(content).Summary; + + // An amount alone is not compensation. Labels and units provide context, never verification. + public static PaymentObservation ReadPayment(string? content) { - if (content is null) return "unknown"; - var text = content[..Math.Min(content.Length, 8000)]; + if (content is null) return new("unknown", ""); + var text = Bounded(content); try { - if (Concrete().IsMatch(text)) return "concrete rate or budget stated"; - if (RevenueShare().IsMatch(text)) return "revenue-share stated"; - if (Unpaid().IsMatch(text)) return "unpaid collaboration stated"; - if (Ambiguous().IsMatch(text)) return "ambiguous / discuss payment terms"; - return "no concrete rate or budget found"; + var sentences = Sentences().Split(text); + var rate = sentences.FirstOrDefault(s => Concrete().IsMatch(s) && + (PaymentLabel().IsMatch(s) || RateUnit().IsMatch(s)) && !OtherPrice().IsMatch(s) && !ApplicantFee().IsMatch(s)); + var unpaid = sentences.FirstOrDefault(s => Unpaid().IsMatch(s) && !(ApplicantFee().IsMatch(s) && NoFee().IsMatch(s))); + var share = sentences.FirstOrDefault(s => RevenueShare().IsMatch(s)); + if (rate is not null && (unpaid is not null || share is not null)) + return new("mixed compensation terms; clarify what is guaranteed", Excerpt(rate) + " / " + Excerpt(unpaid ?? share!)); + if (unpaid is not null) return new("unpaid collaboration stated", Excerpt(unpaid)); + if (share is not null) return new("revenue-share stated", Excerpt(share)); + if (rate is not null) return new("concrete rate or budget stated", Excerpt(rate)); + var amount = sentences.FirstOrDefault(s => Concrete().IsMatch(s)); + if (amount is not null) return new("amount mentioned; compensation unclear", Excerpt(amount)); + var vague = sentences.FirstOrDefault(s => PaymentLabel().IsMatch(s)); + return vague is not null ? new("ambiguous / discuss payment terms", Excerpt(vague)) : new("no concrete rate or budget found", ""); } - catch (RegexMatchTimeoutException) { return "unknown"; } + catch (RegexMatchTimeoutException) { return new("unknown", ""); } } - // A link is evidence of a reference to work, never evidence that the author made it. No URLs are fetched. - public static string Portfolio(string? content) + // A labelled reference or media attachment is a sample supplied, not proof of authorship. + public static string Portfolio(string? content, IReadOnlyList? attachments = null) { + if (attachments?.Any(a => a.ContentType?.StartsWith("image/", StringComparison.OrdinalIgnoreCase) == true || + a.ContentType?.StartsWith("video/", StringComparison.OrdinalIgnoreCase) == true || + a.ContentType?.StartsWith("audio/", StringComparison.OrdinalIgnoreCase) == true) == true) + return "media sample attached"; if (content is null) return "unknown"; - var text = content[..Math.Min(content.Length, 8000)]; + var text = Bounded(content); var external = false; try { foreach (Match match in Links().Matches(text)) { - if (!Uri.TryCreate(match.Value.TrimEnd('.', ',', ';', '!', '?'), UriKind.Absolute, out var uri) || - uri.Scheme is not ("https" or "http") || uri.HostNameType != UriHostNameType.Dns) continue; - var host = uri.IdnHost; - static bool Domain(string host, string domain) => host.Equals(domain, StringComparison.OrdinalIgnoreCase) || host.EndsWith("." + domain, StringComparison.OrdinalIgnoreCase); - if (Domain(host, "discord.com") || Domain(host, "discord.gg") || Domain(host, "discordapp.com")) continue; + if (!TryLink(match.Value, out var uri) || Domain(uri!.IdnHost, "discord.com") || Domain(uri.IdnHost, "discord.gg") || Domain(uri.IdnHost, "discordapp.com")) continue; external = true; var workHost = new[] { "github.com", "gitlab.com", "artstation.com", "behance.net", "dribbble.com", "itch.io", "vimeo.com", "youtube.com", "youtu.be", "deviantart.com" } - .Any(domain => Domain(host, domain)); + .Any(domain => Domain(uri.IdnHost, domain)); var lineStart = text.LastIndexOf('\n', Math.Max(0, match.Index - 1)) + 1; var lineEnd = text.IndexOf('\n', match.Index); if (lineEnd < 0) lineEnd = text.Length; - if (workHost && uri.AbsolutePath.Length > 1 || WorkLabel().IsMatch(text[lineStart..lineEnd])) + var line = text[lineStart..lineEnd]; + if (!NoWork().IsMatch(line) && (workHost && uri.AbsolutePath.Length > 1 || WorkLabel().IsMatch(line))) return "work-sample link detected"; } return external ? "external link found; purpose unclear" : "no work-sample link found"; @@ -49,16 +67,85 @@ public static string Portfolio(string? content) catch (RegexMatchTimeoutException) { return "unknown"; } } + public static List Observations(string? content) + { + var observations = new List(); + if (content is null) return observations; + try + { + var sentences = Sentences().Split(Bounded(content)); + void Add(string code, string description, Func match) + { + var evidence = sentences.FirstOrDefault(match); + if (evidence is not null) observations.Add(new(code, description, Excerpt(evidence))); + } + Add("private-terms", "Post directs readers to private messages for terms", s => PrivateTerms().IsMatch(s) && !NoPrivateTerms().IsMatch(s)); + Add("applicant-fee", "Post mentions an applicant payment or fee", s => ApplicantFee().IsMatch(s) && !NoFee().IsMatch(s)); + Add("payment-pressure", "Post combines payment with urgency", s => Urgency().IsMatch(s) && PaymentAction().IsMatch(s) && !NoPressure().IsMatch(s)); + } + catch (RegexMatchTimeoutException) { /* Observations are optional, never enforcement evidence. */ } + return observations; + } + + public static string CompensationTerms(string content) + { + try + { + var terms = string.Join("\n", Sentences().Split(Bounded(content)).Where(s => + PaymentLabel().IsMatch(s) || Unpaid().IsMatch(s) || RevenueShare().IsMatch(s) || RateUnit().IsMatch(s))); + return string.Join(' ', terms.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries)).ToLowerInvariant(); + } + catch (RegexMatchTimeoutException) { return ""; } + } + + public static List ContactLinks(string? content) + { + if (content is null) return new(); + try + { + return Links().Matches(Bounded(content)).Select(m => TryLink(m.Value, out var uri) ? uri : null) + .Where(u => u is not null && u.AbsolutePath.Length > 1) + .Select(u => u!.GetLeftPart(UriPartial.Path).TrimEnd('/')) + .Where(u => u.Length <= 300).Distinct(StringComparer.OrdinalIgnoreCase).Take(8).ToList(); + } + catch (RegexMatchTimeoutException) { return new(); } + } + private static bool TryLink(string value, out Uri? uri) => Uri.TryCreate(value.TrimEnd('.', ',', ';', '!', '?'), UriKind.Absolute, out uri) && + uri.Scheme is "https" or "http" && uri.HostNameType == UriHostNameType.Dns; + private static bool Domain(string host, string domain) => host.Equals(domain, StringComparison.OrdinalIgnoreCase) || host.EndsWith("." + domain, StringComparison.OrdinalIgnoreCase); + [GeneratedRegex(@"(?:[$€£¥]\s*\d+(?:[.,]\d+)*(?:\s*[kK])?|\b\d+(?:[.,]\d+)*\s*(?:USD|AUD|CAD|EUR|GBP|JPY)\b|\b(?:USD|AUD|CAD|EUR|GBP|JPY)\s*\d+)", RegexOptions.IgnoreCase, 100)] private static partial Regex Concrete(); - [GeneratedRegex(@"\b(?:rev(?:enue)?[ -]?share|royalt(?:y|ies)|negotiable|competitive|paid|budget|payment|rates?|profit[ -]?share)\b", RegexOptions.IgnoreCase, 100)] - private static partial Regex Ambiguous(); + [GeneratedRegex(@"\b(?:pay(?:ing|ment)?|paid|budget|compensation|salary|rates?|wages?|offer(?:ing)?)\b", RegexOptions.IgnoreCase, 100)] + private static partial Regex PaymentLabel(); + [GeneratedRegex(@"(?:/|\bper\s+)(?:hr|hour|day|week|month|asset|model|sprite|project)\b", RegexOptions.IgnoreCase, 100)] + private static partial Regex RateUnit(); + [GeneratedRegex(@"\b(?:sells? for|retail|game costs?|software costs?|licen[cs]e costs?|purchase price)\b", RegexOptions.IgnoreCase, 100)] + private static partial Regex OtherPrice(); [GeneratedRegex(@"\b(?:rev(?:enue)?[ -]?share|royalt(?:y|ies)|profit[ -]?share)\b", RegexOptions.IgnoreCase, 100)] private static partial Regex RevenueShare(); - [GeneratedRegex(@"\b(?:unpaid|volunteer|no (?:pay|payment|compensation))\b", RegexOptions.IgnoreCase, 100)] + [GeneratedRegex(@"\b(?:unpaid|volunteer|not paid|no (?:pay|payment|compensation))\b", RegexOptions.IgnoreCase, 100)] private static partial Regex Unpaid(); [GeneratedRegex(@"https?://[^\s<>\[\]()]+", RegexOptions.IgnoreCase, 100)] private static partial Regex Links(); [GeneratedRegex(@"\b(?:portfolio|showreel|demo|work[ -]?samples?|examples? of (?:my |our )?work|previous work|proof of work)\b", RegexOptions.IgnoreCase, 100)] private static partial Regex WorkLabel(); + [GeneratedRegex(@"\b(?:no|without|don't have|do not have|not (?:my|our|a))\b.{0,35}\b(?:portfolio|samples?|work|demo|showreel)\b|\b(?:portfolio|samples?|work|demo|showreel)\b.{0,25}\b(?:unavailable|not available|coming soon)\b", RegexOptions.IgnoreCase, 100)] + private static partial Regex NoWork(); + [GeneratedRegex(@"\b(?:dm|pm|private messages?)\b.{0,35}\b(?:rates?|budget|pay|payment|terms|details)\b|\b(?:rates?|budget|payment|terms|details)\b.{0,35}\b(?:in|via|by|only)\s+(?:dms?|pms?|private messages?)\b", RegexOptions.IgnoreCase, 100)] + private static partial Regex PrivateTerms(); + [GeneratedRegex(@"\b(?:no|never|don't|do not)\b.{0,20}\b(?:dm|pm|private messages?)\b", RegexOptions.IgnoreCase, 100)] + private static partial Regex NoPrivateTerms(); + [GeneratedRegex(@"\b(?:application|applicant|registration|onboarding|training|verification)\s+(?:fees?|deposit|payment)\b|\bpay\b.{0,30}\b(?:to apply|to (?:start|join)|before (?:starting|joining))\b", RegexOptions.IgnoreCase, 100)] + private static partial Regex ApplicantFee(); + [GeneratedRegex(@"\b(?:no|without)\s+(?:(?:application|applicant|registration|onboarding|training|verification)\s+)?(?:fees?|payments?|deposits?)\b|\b(?:never|do not|don't)\s+pay\b|\b(?:fees?|payment|deposit)\b.{0,20}\b(?:not required|not charged)\b", RegexOptions.IgnoreCase, 100)] + private static partial Regex NoFee(); + [GeneratedRegex(@"\b(?:urgently|immediately|right now|today only|within \d+ (?:minutes?|hours?)|act fast)\b", RegexOptions.IgnoreCase, 100)] + private static partial Regex Urgency(); + [GeneratedRegex(@"\b(?:pay|send money|transfer|deposit|crypto|payment)\b", RegexOptions.IgnoreCase, 100)] + private static partial Regex PaymentAction(); + [GeneratedRegex(@"\b(?:never|don't|do not|avoid)\b.{0,40}\b(?:pay|send|transfer|deposit)\b", RegexOptions.IgnoreCase, 100)] + private static partial Regex NoPressure(); + [GeneratedRegex(@"(?<=[.!?])\s+|[\r\n;]+", RegexOptions.None, 100)] + private static partial Regex Sentences(); } diff --git a/DiscordBot/Services/Recruitment/RecruitmentEngine.cs b/DiscordBot/Services/Recruitment/RecruitmentEngine.cs index c5d424ef..86ceceb7 100644 --- a/DiscordBot/Services/Recruitment/RecruitmentEngine.cs +++ b/DiscordBot/Services/Recruitment/RecruitmentEngine.cs @@ -86,7 +86,6 @@ public async Task SweepAsync(CancellationToken ct, bool retryHeld = true) _feedEmbeds = await _discord.ValidateFeedAsync(ct); _feedGeneration = feedGeneration; _feedReady = true; - if (State!.Overview.MessageId is not null && await _discord.LatestFeedMessageAsync(ct) is { } latest) FeedActivity(latest); if (!held) await _discord.ValidateAccessAsync(ct); _accessInvalid = false; } @@ -103,9 +102,11 @@ public async Task SweepAsync(CancellationToken ct, bool retryHeld = true) _ready = true; State.RecoveryFault = null; await DiscoverAsync(ct); + if (retryHeld) + foreach (var notice in State.Listings.Values.SelectMany(p => p.Attention).Where(n => n.MessageId is null || n.SendPending)) notice.Fault = null; var cursor = ulong.TryParse(State.SweepAfterId, out var savedCursor) ? savedCursor : 0; var batch = State.Listings.Values - .Where(p => p.Status == ListingStatus.Open || p.PendingAction is not null || p.StaffSendPending || p.PublicSendPending || p.PublicationPending) + .Where(p => p.Status == ListingStatus.Open || p.PendingAction is not null || p.StaffSendPending || p.PublicSendPending || p.PublicationPending || AttentionDue(p)) .OrderBy(p => ulong.Parse(p.Id) <= cursor).ThenBy(p => ulong.Parse(p.Id)).Take(10).ToArray(); foreach (var post in batch) { @@ -193,6 +194,7 @@ public Task ObserveAsync(ThreadSnapshot live, bool observedFromCreation, Cancell } }; State.Listings[post.Id] = post; + RefreshFacts(post, live); RecordCreation(post); Save(); return Task.CompletedTask; @@ -202,7 +204,7 @@ private async Task ProcessAsync(Listing post, CancellationToken ct) { if (post.Status == ListingStatus.Deleted) { - if (post.PublicationPending || post.StaffSendPending) await PublishAsync(post, ct); + if (post.PublicationPending || post.StaffSendPending || AttentionDue(post)) await PublishAsync(post, ct); return; } if (post.Status == ListingStatus.Reference) @@ -229,10 +231,7 @@ private async Task ProcessAsync(Listing post, CancellationToken ct) return; } post.Title = RecruitmentPresentation.Safe(live.Title); - post.Facts.AccountCreatedAt = live.AccountCreatedAt; post.Facts.JoinedAt = live.JoinedAt; - post.Facts.MemberRoleCount = live.MemberRoleCount; - post.Facts.Payment = RecruitmentContent.Payment(live.Starter); - post.Facts.Portfolio = RecruitmentContent.Portfolio(live.Starter); + RefreshFacts(post, live); post.Facts.TagAdvice = _assets!.TagAdvice(Purpose(post.ForumId), live.Tags, State!.Forums[Purpose(post.ForumId)]); if (!post.PublicSendPending) post.PublicRestoreArchive = live.Archived && post.AcceptedAt.HasValue; post.Hold = null; @@ -245,7 +244,7 @@ private async Task ProcessAsync(Listing post, CancellationToken ct) if (post.PendingAction is not null) { await ExecuteActionAsync(post, ct); return; } if (post.Status != ListingStatus.Open) { - if (post.StaffSendPending || post.PublicSendPending || post.PublicationPending) await PublishAsync(post, ct); + if (post.StaffSendPending || post.PublicSendPending || post.PublicationPending || AttentionDue(post)) await PublishAsync(post, ct); return; } if (EvidenceCurrent && !post.AcceptedAt.HasValue && !post.Exempt && !post.Pinned) @@ -335,6 +334,7 @@ private async Task RefreshBlockersAsync(Listing candidate, CancellationToken ct) private async Task PublishStaffAsync(Listing post, CancellationToken ct) { + await PublishAttentionAsync(post, ct); var message = RecruitmentPresentation.Staff(State!, post, _settings); if (!post.StaffSendPending && post.StaffMessageId is not null && _staffRenders.GetValueOrDefault(post.Id) == message.Content) return; var recovering = post.StaffSendPending; @@ -348,7 +348,6 @@ private async Task PublishStaffAsync(Listing post, CancellationToken ct) var changed = post.StaffMessageId != delivery.Id || post.StaffSendPending; post.StaffMessageId = delivery.Id; post.StaffSendPending = false; _staffRenders[post.Id] = message.Content; - if (delivery.NewlySent) FeedActivity(delivery.Id); if (changed) Save(); } @@ -457,9 +456,13 @@ private async Task ExecuteActionAsync(Listing post, CancellationToken ct) var action = post.PendingAction!; if (action.Automatic && action.Condition == "unanswered") { - // Old state may contain an unsent expiry action. Listings now remain indefinitely. + // Cancel unsent legacy expiry; preserve a closure already applied before the upgrade. + var previous = action.CallStarted ? await ReadAsync(post, ct, refresh: true) : null; + if (action.CallStarted && previous is null) { RecordAbsence(post); return; } + var alreadyClosed = action.CallStarted && previous is { Locked: true, Archived: true }; + if (alreadyClosed) post.Status = ListingStatus.Closed; post.PendingAction = null; post.PublicationPending = true; - post.LastOutcome = "Automatic expiry cancelled; listing remains open"; + post.LastOutcome = alreadyClosed ? "Closure applied before upgrade confirmed" : "Automatic expiry cancelled; listing remains open"; Save(); return; } if (action.RequiresModerator && !await _discord.IsModeratorAsync(State!.GuildId, action.ActorId, ct)) @@ -517,7 +520,7 @@ private async Task ExecuteActionAsync(Listing post, CancellationToken ct) var author = RecruitmentPolicy.Author(State!, post.OwnerId, Now); author.ConsecutiveTimeouts++; if (author.ConsecutiveTimeouts >= 3 && !author.TimeoutAlerted) - { post.TimeoutAlertCount = author.ConsecutiveTimeouts; author.TimeoutAlerted = true; } + { post.TimeoutAlertCount = author.ConsecutiveTimeouts; author.TimeoutAlerted = true; QueueAttention(post, "Three consecutive confirmed Guidelines timeouts"); } } break; case RecruitmentActionKind.Close: @@ -550,7 +553,7 @@ public async Task StaffAsync(string guild, string actor, string operatio if (State is null || !_settings.Enabled) return "Recruitment is unavailable. Enable valid settings and restart first."; if (operation == "lookup") { - if (State.Listings.TryGetValue(target, out var found)) return RecruitmentPresentation.Staff(State, found, _settings).Content; + if (State.Listings.TryGetValue(target, out var found)) return EvidenceDetails(found); if (State.Authors.TryGetValue(target, out var history)) return $"Author `{target}` · next creation: {history.LastAcceptedCreation?.AddDays(_settings.CooldownDays).ToString("u") ?? "none"} · confirmed timeout streak: {history.ConsecutiveTimeouts}"; return "No retained recruitment record for that thread or author."; } @@ -594,6 +597,10 @@ public async Task StaffAsync(string guild, string actor, string operatio { case "exempt": post.Exempt = true; break; case "unexempt": post.Exempt = false; _recoverWindows.Add(post.Id); break; + case "resolve-reports": + foreach (var report in post.Reports.Where(r => r.ResolvedAt is null)) report.ResolvedAt = Now; + break; + case "review-changes": post.ReviewedMaterialChanges = post.MaterialChanges; break; case "reset-attempts": var attemptHistory = RecruitmentPolicy.Author(State, post.OwnerId, Now); attemptHistory.RecentCreations.Clear(); attemptHistory.AttemptPausedUntil = null; diff --git a/DiscordBot/Services/Recruitment/RecruitmentEngineScheduling.cs b/DiscordBot/Services/Recruitment/RecruitmentEngineScheduling.cs index 56126ada..3a6f95ad 100644 --- a/DiscordBot/Services/Recruitment/RecruitmentEngineScheduling.cs +++ b/DiscordBot/Services/Recruitment/RecruitmentEngineScheduling.cs @@ -11,9 +11,10 @@ public sealed partial class RecruitmentEngine private readonly Dictionary _publicRenders = new(); private readonly Dictionary _staffRenders = new(); private bool EvidenceCurrent => _gatewayReady() && !_pendingIdentities() && _inventoryComplete && _inventoryGeneration == _evidenceGeneration(); + private bool RoutinePendingIdentity => _ready && _gatewayReady() && _pendingIdentities() && _inventoryComplete && _inventoryGeneration == _evidenceGeneration(); public event Action? Changed; - public RecruitmentHealth Health(string work = "Idle") => new(_ready ? EvidenceCurrent ? "Operating" : "Inventory incomplete; acceptance and automatic actions held" : Status, work, Now, + public RecruitmentHealth Health(string work = "Idle") => new(_ready ? EvidenceCurrent ? "Operating" : RoutinePendingIdentity ? "Processing new listing identities" : "Inventory incomplete; acceptance and automatic actions held" : Status, work, Now, State?.Listings.Values.Count(p => p.Status == ListingStatus.Open) ?? 0, State?.Listings.Values.Count(p => p.Status == ListingStatus.Open && !p.AcknowledgedAt.HasValue) ?? 0, State?.Listings.Values.Count(p => p.Hold is not null) ?? 0, EvidenceCurrent, @@ -21,7 +22,7 @@ public sealed partial class RecruitmentEngine State?.Forums.Select(f => new ForumHealth(f.Key, f.Value.ForumId, f.Value.Fault is not null ? "held" : f.Value.PendingTopic is not null ? "publication pending" : f.Value.ConfirmedTopic.Length == 0 ? "not verified" : "Guidelines and tags verified", - f.Value.Fault?.Message, f.Value.Fault?.RetryAt, f.Value.Reference.ThreadId, f.Value.Reference.Fault?.Message)).ToArray() ?? Array.Empty(), State?.Overview.Fault?.Message); + f.Value.Fault?.Message, f.Value.Fault?.RetryAt, f.Value.Reference.ThreadId, f.Value.Reference.Fault?.Message)).ToArray() ?? Array.Empty(), State?.Overview.Fault?.Message, RoutinePendingIdentity: RoutinePendingIdentity, Attention: AttentionSummary(), Metrics: ActivitySummary()); private DateTimeOffset? NextWorkAt { @@ -49,10 +50,13 @@ private DateTimeOffset? NextWorkAt { if (post.Fault is { } fault) return fault.RetryAt; if (post.PendingAction is not null || post.PublicSendPending || post.StaffSendPending || post.PublicationPending) return Now; - if (post.Status != ListingStatus.Open) return null; + var attention = post.Attention.Where(n => n.MessageId is null || n.SendPending) + .Select(n => n.Fault is null ? (DateTimeOffset?)Now : n.Fault.RetryAt).Where(t => t.HasValue).Min(); + if (post.Status != ListingStatus.Open) return attention; if (_recoverWindows.Contains(post.Id)) return Now; if (!post.PromptDeliveredAt.HasValue && !post.AcceptedAt.HasValue) return Now; var dates = new List(); + if (attention.HasValue) dates.Add(attention.Value); if (!post.Exempt && !post.Pinned) { if (!post.AcceptedAt.HasValue && post.Deadline.HasValue) dates.Add(post.Deadline.Value); diff --git a/DiscordBot/Services/Recruitment/RecruitmentEvidence.cs b/DiscordBot/Services/Recruitment/RecruitmentEvidence.cs new file mode 100644 index 00000000..c9fda600 --- /dev/null +++ b/DiscordBot/Services/Recruitment/RecruitmentEvidence.cs @@ -0,0 +1,63 @@ +using System.Security.Cryptography; +using System.Text; + +namespace DiscordBot.Services.Recruitment; + +public sealed partial class RecruitmentEngine +{ + private void RefreshFacts(Listing post, ThreadSnapshot live) + { + var payment = RecruitmentContent.ReadPayment(live.Starter); + post.Facts.AccountCreatedAt = live.AccountCreatedAt; post.Facts.JoinedAt = live.JoinedAt; + post.Facts.MemberRoleCount = live.MemberRoleCount; post.Facts.HasCustomAvatar = live.HasCustomAvatar; + post.Facts.Payment = payment.Summary; post.Facts.PaymentEvidence = payment.Evidence; + post.Facts.Portfolio = RecruitmentContent.Portfolio(live.Starter, live.Attachments); + post.Facts.Observations = RecruitmentContent.Observations(live.Starter); + if (live.Starter is null) return; // Missing data must not overwrite previously observed evidence. + var attachments = live.Attachments?.Take(10).ToList() ?? new(); + var normalized = string.Join(' ', live.Starter.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries)).ToLowerInvariant(); + var fingerprint = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(normalized + "\n" + string.Join('|', attachments.Select(a => a.Id))))); + if (post.LatestStarter?.Fingerprint == fingerprint) return; + var version = new StarterVersion(Now, live.Starter[..Math.Min(2000, live.Starter.Length)], live.Starter.Length > 2000, + fingerprint, payment.Summary, payment.Evidence, RecruitmentContent.ContactLinks(live.Starter), attachments, + Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(RecruitmentContent.CompensationTerms(live.Starter))))); + if (post.LatestStarter is { } previous && post.AcceptedAt.HasValue && + (previous.Payment != version.Payment || previous.PaymentEvidence != version.PaymentEvidence || + previous.TermsFingerprint is not null && previous.TermsFingerprint != version.TermsFingerprint || + !previous.Links.SequenceEqual(version.Links) || !previous.Attachments.SequenceEqual(version.Attachments))) + { + if (post.MaterialChanges == post.ReviewedMaterialChanges) QueueAttention(post, "Payment terms, links or attachments changed after acceptance"); + post.MaterialChanges++; + post.EarlierMaterialVersions.Add(previous); + if (post.EarlierMaterialVersions.Count > 3) post.EarlierMaterialVersions.RemoveAt(0); + } + post.OriginalStarter ??= version; + post.LatestStarter = version; + post.UpdatedAt = Now; + } + + private string EvidenceDetails(Listing post) + { + var text = RecruitmentPresentation.Staff(State!, post, _settings).Content + "\n" + + $"Created {RecruitmentPresentation.Timestamp(post.CreatedAt)} · accepted {(post.AcceptedAt.HasValue ? RecruitmentPresentation.Timestamp(post.AcceptedAt.Value) : "no")}\n"; + var versions = new[] { post.OriginalStarter }.Concat(post.EarlierMaterialVersions).Append(post.LatestStarter) + .OfType().DistinctBy(v => (v.ObservedAt, v.Fingerprint)); + foreach (var version in versions) + { + text += $"\n**Starter observed {version.ObservedAt:u}{(version.Truncated ? " (first 2,000 characters)" : "")}**\n" + + RecruitmentPresentation.Safe(version.Content, 2000) + "\n"; + if (version.Attachments.Count > 0) text += "Attachments: " + string.Join(", ", version.Attachments.Select(a => RecruitmentPresentation.Safe(a.Name, 120))) + "\n"; + } + if (post.LatestStarter is { } latest) + { + var related = State!.Listings.Values.Where(p => p.Id != post.Id && p.OwnerId != post.OwnerId && p.LatestStarter is { } other && + (latest.Content.Length >= 120 && latest.Fingerprint == other.Fingerprint || latest.Links.Intersect(other.Links, StringComparer.OrdinalIgnoreCase).Any())) + .OrderByDescending(p => p.CreatedAt).Take(5).Select(p => $"<#{p.Id}> (author `{p.OwnerId}`)").ToArray(); + if (related.Length > 0) text += "\nShared starter text or link with: " + string.Join(", ", related) + ". Review context; shared projects can be legitimate.\n"; + } + foreach (var report in post.Reports) + text += $"\n**Private report · {(report.ResolvedAt.HasValue ? "resolved" : "open")} · {report.At:u} · reporter `{report.ReporterId}`**\n{RecruitmentPresentation.Safe(report.Concern, 1000)}\n"; + foreach (var audit in post.Audit.TakeLast(10)) text += $"\n{audit.At:u} · `{audit.Actor}` · {RecruitmentPresentation.Safe(audit.Reason, 300)}"; + return text; + } +} diff --git a/DiscordBot/Services/Recruitment/RecruitmentHealth.cs b/DiscordBot/Services/Recruitment/RecruitmentHealth.cs index 5ee22c0e..5d8cd581 100644 --- a/DiscordBot/Services/Recruitment/RecruitmentHealth.cs +++ b/DiscordBot/Services/Recruitment/RecruitmentHealth.cs @@ -5,7 +5,7 @@ public sealed record ForumHealth(string Purpose, string Id, string Setup, string /// Detached from mutable state so moderators can inspect progress while the worker awaits Discord. public sealed record RecruitmentHealth(string Status, string Work, DateTimeOffset ObservedAt, int Open, int Pending, int Held, bool InventoryComplete, DateTimeOffset? LastRecovery, - DateTimeOffset? NextDue, IReadOnlyList Forums, string? OverviewFault = null, string? Job = null) + DateTimeOffset? NextDue, IReadOnlyList Forums, string? OverviewFault = null, string? Job = null, bool RoutinePendingIdentity = false, string? Attention = null, string? Metrics = null) { public static RecruitmentHealth Starting(DateTimeOffset now) => new("Starting", "Opening recruitment state", now, 0, 0, 0, false, null, null, Array.Empty()); @@ -26,6 +26,8 @@ public string Text() (f.ReferenceFault is null ? "" : " · reference held: " + RecruitmentPresentation.Safe(f.ReferenceFault, 180)))); return RecruitmentPresentation.Safe(Status, 400) + $"\nOpen: {Open} · awaiting acknowledgement: {Pending} · held: {Held}\n" + $"Inventory: {(InventoryComplete ? "complete" : "recovery needed")} · work: {Work}\n" + + (Attention is null ? "" : "**Needs review**\n" + Attention + "\n") + + (Metrics is null ? "" : Metrics + "\n") + (LastRecovery.HasValue ? $"Last recovery: {RecruitmentPresentation.Timestamp(LastRecovery.Value)}\n" : "No completed recovery yet.\n") + (NextDue.HasValue ? $"Next scheduled work: {RecruitmentPresentation.Timestamp(NextDue.Value)}\n" : "") + forums + (OverviewFault is null ? "" : "\nOverview: " + OverviewFault) + diff --git a/DiscordBot/Services/Recruitment/RecruitmentOverview.cs b/DiscordBot/Services/Recruitment/RecruitmentOverview.cs index a01ac4e2..c6e0aba5 100644 --- a/DiscordBot/Services/Recruitment/RecruitmentOverview.cs +++ b/DiscordBot/Services/Recruitment/RecruitmentOverview.cs @@ -7,8 +7,6 @@ public sealed partial class RecruitmentEngine private bool _feedEmbeds; private string? _overviewRender; private DateTimeOffset? _overviewDirtyAt; - private DateTimeOffset? _feedActivityFirst; - private DateTimeOffset? _feedActivityLast; private void GuardFeedWrite() { @@ -28,26 +26,11 @@ private DateTimeOffset? OverviewDueAt var times = new List(); if (_overviewDirtyAt is { } dirty) times.Add(Max(dirty.AddSeconds(5), (receipt.LastEditedAt ?? DateTimeOffset.MinValue).AddSeconds(30))); - if (_feedActivityLast is { } activity) - { - var eligible = (receipt.LastPostedAt ?? Now).AddMinutes(5); - var quiet = activity.AddSeconds(10); - var bound = Max(_feedActivityFirst ?? activity, eligible).AddSeconds(60); - times.Add(Max(eligible, quiet < bound ? quiet : bound)); - } return times.Count == 0 ? null : times.Min(); } } private static DateTimeOffset Max(DateTimeOffset a, DateTimeOffset b) => a > b ? a : b; - public void FeedActivity(string messageId, bool overview = false) - { - if (State is null || overview || !ulong.TryParse(messageId, out var id)) return; - if (ulong.TryParse(State.Overview.MessageId, out var current) && id <= current) return; - _feedActivityFirst ??= Now; - _feedActivityLast = Now; - } - public async Task RefreshOverviewAsync(CancellationToken ct) { if (!_feedReady || !_gatewayReady() || _feedGeneration != _evidenceGeneration() || State is null) return; @@ -65,9 +48,7 @@ public async Task RefreshOverviewAsync(CancellationToken ct) receipt.RetiredMessageId = receipt.RetiredMarker = null; Save(); } - var replace = receipt.SendPending ? receipt.PendingReplacement : receipt.MessageId is null || - _feedActivityLast.HasValue && (receipt.LastPostedAt?.AddMinutes(5) ?? Now) <= Now && - (Now >= _feedActivityLast.Value.AddSeconds(10) || Now >= Max(_feedActivityFirst!.Value, receipt.LastPostedAt!.Value.AddMinutes(5)).AddSeconds(60)); + var replace = receipt.SendPending ? receipt.PendingReplacement : receipt.MessageId is null; if (!receipt.SendPending) { receipt.PendingReplacement = replace; @@ -85,7 +66,7 @@ public async Task RefreshOverviewAsync(CancellationToken ct) receipt.PendingMarker = null; receipt.PendingReplacement = false; receipt.Fault = null; receipt.LastEditedAt = Now; if (replace || delivery.NewlySent) - { receipt.LastPostedAt = Now; _feedActivityFirst = _feedActivityLast = null; } + { receipt.LastPostedAt = Now; } _overviewRender = render; _overviewDirtyAt = null; Save(); if (receipt.RetiredMessageId is not null) diff --git a/DiscordBot/Services/Recruitment/RecruitmentPresentation.cs b/DiscordBot/Services/Recruitment/RecruitmentPresentation.cs index 9646c5c8..066050b2 100644 --- a/DiscordBot/Services/Recruitment/RecruitmentPresentation.cs +++ b/DiscordBot/Services/Recruitment/RecruitmentPresentation.cs @@ -6,101 +6,82 @@ public static class RecruitmentPresentation { public static string Timestamp(DateTimeOffset date) => $""; public static string Safe(string value, int max = 180) => new string(value.Take(max) - .Select(c => char.IsControl(c) || "`*_~|<>@".Contains(c) ? ' ' : c).ToArray()); + .Select(c => char.IsControl(c) || "`*_~|<>@[]\\".Contains(c) ? ' ' : c).ToArray()); public static RecruitmentMessage Public(RecruitmentState state, Listing post, RecruitmentSettings settings, DateTimeOffset now) { - var eligibility = RecruitmentPolicy.Evaluate(state, post, now, settings.CooldownDays); - var status = post.Status != ListingStatus.Open ? post.Status.ToString() : post.AcceptedAt.HasValue ? "Listing active" : - post.AcknowledgedAt.HasValue ? "Waiting for eligibility" : "Acknowledgement needed"; - var instructions = new List(); - if (post.Status == ListingStatus.Open && !post.AcceptedAt.HasValue) - { - if (!post.AcknowledgedAt.HasValue) instructions.Add("Read this forum's Guidelines and enter the code using **Acknowledge**."); - instructions.Add(post.Deadline.HasValue ? $"Deadline: {Timestamp(post.Deadline.Value)}." : "Your acknowledgement window starts when these controls are available."); - if (eligibility.BlockingListingId is not null) instructions.Add($"Your other listing <#{eligibility.BlockingListingId}> occupies your listing slot."); - if (eligibility.NextAllowedAt.HasValue) instructions.Add($"Creation cooldown ends {Timestamp(eligibility.NextAllowedAt.Value)}."); - } - if (post.Status == ListingStatus.Closed && !post.AcceptedAt.HasValue) instructions.Add(Safe(post.LastOutcome, 400)); - if (post.AcceptedAt.HasValue) - { - var next = state.Authors.GetValueOrDefault(post.OwnerId)?.LastAcceptedCreation?.AddDays(settings.CooldownDays); - if (next > now) instructions.Add($"Next listing: {Timestamp(next.Value)}." + (post.Status == ListingStatus.Open ? " Close this listing before replacing it." : "")); - else if (post.Status == ListingStatus.Open) instructions.Add("Close this listing before posting a replacement."); - } - if (post.Status == ListingStatus.Open && (post.Exempt || post.Pinned)) instructions.Add("This listing stays open until its owner or a moderator closes it."); - if (post.Hold is not null || post.AvailabilityUnknown) instructions.Add("Processing paused; moderator review or Discord recovery is needed."); - var purpose = state.Forums.First(f => f.Value.ForumId == post.ForumId).Key; - var paid = purpose.StartsWith("paid-"); - var forHire = purpose.EndsWith("for-hire"); - var embed = new EmbedBuilder().WithTitle("Recruitment · " + status).WithDescription(string.Join("\n", instructions)); - var flagged = false; - void Field(string name, string value, bool flag, bool inline = true) + var lines = new List(); + var accountAge = post.Facts.AccountCreatedAt is { } created ? Age(post.CreatedAt - created) : "unavailable"; + var membership = post.Facts.JoinedAt is { } joined ? joined > post.CreatedAt ? "joined/rejoined after posting" : Age(post.CreatedAt - joined) : "unavailable"; + lines.Add($"At posting · account age: **{accountAge}** · in this server: **{membership}**."); + var notes = new List(); + if (post.Facts.Payment == "unknown") notes.Add("Starter text is unavailable; payment details could not be checked."); + else if (purpose.StartsWith("paid-") && post.Facts.Payment != "concrete rate or budget stated" || + post.Facts.Payment.StartsWith("mixed") || post.Facts.Payment == "revenue-share stated") { - flagged |= flag; - embed.AddField((flag ? "⚠ " : "") + name, value, inline); - } - var accountAge = post.Facts.AccountCreatedAt.HasValue ? post.CreatedAt - post.Facts.AccountCreatedAt.Value : (TimeSpan?)null; - Field("Account age", accountAge.HasValue - ? $"{Age(accountAge.Value)} at posting\nCreated " : "Unavailable", - !accountAge.HasValue || accountAge.Value.TotalDays < 30); - var joined = post.Facts.JoinedAt; - Field("Joined server", joined.HasValue ? $"" + - (post.CreatedAt - joined.Value < TimeSpan.FromDays(7) ? "\nJoined less than 7 days before posting" : "") : "Unavailable", - !joined.HasValue || post.CreatedAt - joined.Value < TimeSpan.FromDays(7)); - Field("Server roles", post.Facts.MemberRoleCount switch - { - null => "Unavailable", - 0 => "None assigned. Add relevant skill roles.", - var count => $"{count} assigned" - }, post.Facts.MemberRoleCount is null or 0); - var concrete = post.Facts.Payment == "concrete rate or budget stated"; - var unpaid = post.Facts.Payment == "unpaid collaboration stated"; - var revenueShare = post.Facts.Payment == "revenue-share stated"; - Field("Payment", concrete ? "Rate or budget detected; terms unverified." : revenueShare ? "Revenue share stated; no guaranteed payment detected." : - unpaid ? paid ? "Unpaid terms detected in a paid forum." : "Unpaid collaboration stated." : - post.Facts.Payment == "unknown" ? "Starter text unavailable." : paid ? "No concrete rate or budget detected." : "No clear unpaid, revenue-share or payment terms detected.", - !concrete && (paid || !unpaid), false); - if (forHire) - Field("Portfolio / work samples", post.Facts.Portfolio switch + var summary = post.Facts.Payment switch { - "work-sample link detected" => "Link detected; authorship and contents unverified.", - "external link found; purpose unclear" => "External link found. Label your portfolio or work samples.", - "unknown" => "Starter text unavailable.", - _ => "No link detected. Add a portfolio, demo or work samples." - }, post.Facts.Portfolio != "work-sample link detected", false); - embed.AddField("Before moving to DMs", "Clarify scope, deliverables, timing and payment in this thread first. Verify work samples and agree terms in writing.") - .WithColor(flagged ? Color.Orange : Color.Blue) - .WithFooter("Checks use the starter post and server membership; they do not verify identity or endorse the listing."); - var built = embed.Build(); + "unpaid collaboration stated" => "Unpaid terms appear in a paid forum", + "revenue-share stated" => "Revenue share is stated; guaranteed payment is unclear", + "mixed compensation terms; clarify what is guaranteed" => "Mixed compensation terms; clarify what is guaranteed", + _ => "A clear compensation rate or budget was not found" + }; + notes.Add(summary + (post.Facts.PaymentEvidence.Length > 0 ? $": “{Safe(post.Facts.PaymentEvidence, 110)}”." : ".")); + } + if (purpose.EndsWith("for-hire") && post.Facts.Portfolio is not ("work-sample link detected" or "media sample attached")) + notes.Add(post.Facts.Portfolio == "unknown" ? "Work-sample information is unavailable." : + post.Facts.Portfolio == "external link found; purpose unclear" ? "A link is supplied; its purpose as a work sample is unclear." : "No portfolio link or attached media sample was found."); + notes.AddRange(post.Facts.Observations.Take(3).Select(o => $"{o.Description}: “{Safe(o.Evidence, 110)}”.")); + if (notes.Count > 0) lines.Add("\n**Things to check**\n" + string.Join("\n", notes.Select(n => "• " + n))); + lines.Add("\nClarify scope, work samples and payment terms here before moving to DMs."); + var needsAck = post.Status == ListingStatus.Open && !post.AcceptedAt.HasValue && !post.AcknowledgedAt.HasValue; + if (needsAck) + lines.Add("\n**Author:** Read this forum's Guidelines and enter its code with **Acknowledge**." + + (post.Deadline is { } deadline ? $" Due {Timestamp(deadline)}." : " Your window starts when these controls are available.")); + if (post.Status == ListingStatus.Closed && !post.AcceptedAt.HasValue) lines.Add("\n**Closed:** " + Safe(post.LastOutcome, 220)); + if (post.Hold is not null || post.AvailabilityUnknown) lines.Add("\nBot processing is paused; listing details may be incomplete."); + var embed = new EmbedBuilder().WithTitle(post.Status == ListingStatus.Closed ? "Recruitment · closed" : "Recruitment · reader notes") + .WithDescription(string.Join("\n", lines)).WithColor(new Color(0x747F8D)) + .WithFooter("Automated observations; identity and work are unverified.").Build(); var marker = $"recruitment:{post.Id}:public"; - var fallback = $"**{built.Title}**\n{built.Description}\n" + string.Join("\n", built.Fields.Select(f => $"**{f.Name}**: {f.Value}")) + $"\n{built.Footer?.Text}\n||{marker}||"; - return new RecruitmentMessage($"||{marker}||", marker, post.Status == ListingStatus.Open, Embed: built, TextFallback: fallback); + var fallback = $"**{embed.Title}**\n{embed.Description}\n{embed.Footer?.Text}\n||{marker}||"; + return new RecruitmentMessage($"||{marker}||", marker, true, Embed: embed, TextFallback: fallback, + AcknowledgementRequired: needsAck, ReaderControls: true); + } + + public static string Details(Listing post) + { + var facts = post.Facts; + return "**Listing details**\n" + + $"Account created: {(facts.AccountCreatedAt is { } created ? Timestamp(created) : "unavailable")}\n" + + $"Joined this server: {(facts.JoinedAt is { } joined ? Timestamp(joined) : "unavailable")}\n" + + $"Profile image: {facts.HasCustomAvatar switch { true => "custom global or server avatar", false => "default Discord avatar", _ => "unavailable" }}\n" + + $"Server roles (excluding everyone): {facts.MemberRoleCount?.ToString() ?? "unavailable"}. Roles and avatars do not establish trust.\n" + + $"Compensation: {Safe(facts.Payment)}" + (facts.PaymentEvidence.Length > 0 ? $" · “{Safe(facts.PaymentEvidence, 370)}”" : "") + "\n" + + $"Work samples: {Safe(facts.Portfolio)}. Links and attachments are not verified.\n{Safe(facts.TagAdvice)}\n" + + "Observations use the starter post and available membership data. Missing data is not evidence against the author. Reports go privately to moderators; the bot cannot inspect other people's DMs."; } private static string Age(TimeSpan age) { var days = Math.Max(0, (int)age.TotalDays); if (days >= 365) return $"{days / 365} {(days / 365 == 1 ? "year" : "years")}"; - return $"{days} {(days == 1 ? "day" : "days")}"; + return days == 0 ? "under 1 day" : $"{days} {(days == 1 ? "day" : "days")}"; } public static RecruitmentMessage Staff(RecruitmentState state, Listing post, RecruitmentSettings settings) { - var author = state.Authors.GetValueOrDefault(post.OwnerId); - var next = author?.LastAcceptedCreation?.AddDays(settings.CooldownDays); - var text = $"**Recruitment · {post.Status}** · {Safe(post.Title)}\n" + - $"Author `{post.OwnerId}` · thread `{post.Id}` · forum `{post.ForumId}`\n" + - $"https://discord.com/channels/{state.GuildId}/{post.Id}\n" + - $"Created {Timestamp(post.CreatedAt)} · acknowledged {(post.AcknowledgedAt.HasValue ? "yes" : "no")} · accepted {(post.AcceptedAt.HasValue ? "yes" : "no")}\n" + - $"Next creation: {(next.HasValue ? Timestamp(next.Value) : "no accepted cooldown")}\n" + - $"Outcome: {Safe(post.LastOutcome)}\n"; - if (post.PendingAction is { } action) - text += $"Pending {action.Kind}: {Safe(action.Reason)} · actor `{action.ActorId}` · action `{action.Id}`\n"; + var text = $"**Recruitment · {post.Status}** · {Safe(post.Title, 100)}\n" + + $"<#{post.Id}> · author `{post.OwnerId}` · {(post.AcceptedAt.HasValue ? "acknowledged" : "unaccepted")}\n" + + $"{Safe(post.LastOutcome, 260)}\n"; + if (post.PendingAction is { } action) text += $"Pending {action.Kind}: {Safe(action.Reason)}\n"; if (post.Hold is not null) text += $"Hold: {Safe(post.Hold)}\n"; - if (post.TimeoutAlertCount >= 3) text += $"**Moderator attention: {post.TimeoutAlertCount} consecutive confirmed Guidelines timeouts.**\n"; - foreach (var audit in post.Audit.TakeLast(3)) text += $"{audit.At:yyyy-MM-dd HH:mm} · `{audit.Actor}` · {Safe(audit.Reason, 180)}\n"; + if (post.TimeoutAlertCount >= 3) text += $"Moderator attention: {post.TimeoutAlertCount} confirmed Guidelines timeouts.\n"; + if (post.Reports.Any(r => r.ResolvedAt is null)) text += "Private reports awaiting review.\n"; + if (post.MaterialChanges > post.ReviewedMaterialChanges) text += "Material starter changes awaiting review.\n"; + if (post.Audit.LastOrDefault() is { } audit) text += $"Latest action · `{audit.Actor}` · {Safe(audit.Reason)}\n"; + text += $"Details: `/recruitment lookup {post.Id}`\n"; var marker = $"recruitment:{post.Id}:staff"; if (text.Length > 1900) text = text[..1880] + "\n… more in stored audit\n"; return new RecruitmentMessage(text + $"||{marker}||", marker, false); diff --git a/DiscordBot/Services/Recruitment/RecruitmentReports.cs b/DiscordBot/Services/Recruitment/RecruitmentReports.cs new file mode 100644 index 00000000..b3572072 --- /dev/null +++ b/DiscordBot/Services/Recruitment/RecruitmentReports.cs @@ -0,0 +1,80 @@ +namespace DiscordBot.Services.Recruitment; + +public sealed partial class RecruitmentEngine +{ + private async Task ReaderListingAsync(string guild, string thread, string reader, CancellationToken ct) => + State is not null && guild == State.GuildId && State.Listings.TryGetValue(thread, out var post) && + post.Status is not ListingStatus.Reference && await _discord.IsMemberAsync(guild, reader, ct) ? post : null; + + public async Task DetailsAsync(string guild, string thread, string reader, CancellationToken ct) + { + var post = await ReaderListingAsync(guild, thread, reader, ct); + return post is null ? "Listing details are unavailable in this server." : RecruitmentPresentation.Details(post); + } + + public async Task<(string Message, bool Open, bool Removable)> OwnerDetailsAsync(string guild, string thread, string owner, CancellationToken ct) + { + var post = Owner(guild, thread, owner); + if (post is null) return ("This listing does not belong to you or recruitment is unavailable.", false, false); + return ($"**Your listing · {post.Status}**\n" + await EligibilityAsync(guild, owner, ct) + + "\nClosing preserves the discussion. Removal permanently deletes it.", post.Status == ListingStatus.Open, + post.Status is ListingStatus.Open or ListingStatus.Closed); + } + + public async Task ReportAsync(string guild, string thread, string reporter, string concern, CancellationToken ct) + { + var post = await ReaderListingAsync(guild, thread, reporter, ct); + if (post is null) return "This listing is unavailable or you are not a current server member."; + if (post.OwnerId == reporter) return "Use your listing controls or contact a moderator about your own listing."; + if (!_ready || !_feedReady || !_gatewayReady() || _feedGeneration != _evidenceGeneration()) + return "Private reporting is temporarily unavailable. Please contact a moderator directly."; + concern = concern.Trim(); + if (concern.Length is < 10 or > 1000) return "Describe the concern in 10–1,000 characters, including relevant context."; + if (post.Reports.Any(r => r.ReporterId == reporter)) return "You have already reported this listing. Contact a moderator with additional evidence."; + if (post.Reports.Count >= 20) return "This listing already has reports awaiting or recorded by moderators. Contact a moderator with additional evidence."; + var history = RecruitmentPolicy.Author(State!, reporter, Now); + history.RecentReports.RemoveAll(d => d <= Now.AddDays(-1)); + if (history.RecentReports.Count >= 3) return "You have reached the limit of three listing reports in 24 hours. Contact a moderator for urgent concerns."; + var firstOpen = post.Reports.All(r => r.ResolvedAt.HasValue); + post.Reports.Add(new RecruitmentReport { ReporterId = reporter, At = Now, Concern = RecruitmentPresentation.Safe(concern, 1000) }); + history.RecentReports.Add(Now); + if (firstOpen) QueueAttention(post, "A private reader report needs review"); + post.UpdatedAt = Now; post.PublicationPending = true; Save(); + // The durable report is accepted even if notification delivery needs a retry. + await ProcessSafelyAsync(post, ct); + return "Your report has been saved for moderators. It is not shown to the listing author or other readers. Keep relevant messages and links; the bot cannot read other people's DMs."; + } + + private void QueueAttention(Listing post, string reason) + { + // Bound retained receipts. During prolonged feed failure, coalesce rather than grow without limit. + if (post.Attention.Count >= 8) + { + var completed = post.Attention.FirstOrDefault(n => n.MessageId is not null && !n.SendPending); + if (completed is null) return; // Existing pending notices and the overview still link this listing. + post.Attention.Remove(completed); + } + post.Attention.Add(new AttentionNotice { At = Now, Reason = reason }); + } + + private bool AttentionDue(Listing post) => post.Attention.Any(n => (n.MessageId is null || n.SendPending) && (n.Fault is null || n.Fault.IsDue(Now))); + + private async Task PublishAttentionAsync(Listing post, CancellationToken ct) + { + foreach (var notice in post.Attention.Where(n => (n.MessageId is null || n.SendPending) && (n.Fault is null || n.Fault.IsDue(Now))).Take(3)) + { + try + { + var marker = $"recruitment:{post.Id}:attention:{notice.Id}"; + var message = new RecruitmentMessage($"**Recruitment review** · {notice.Reason}\n<#{post.Id}> · author `{post.OwnerId}`\nUse `/recruitment lookup {post.Id}` for evidence and review actions.\n||{marker}||", marker, false); + var delivery = await _discord.UpsertAsync(_settings.FeedChannelId.ToString(), notice.MessageId, notice.SendPending, + notice.At, message, () => { GuardFeedWrite(); notice.SendPending = true; Save(); }, ct); + notice.MessageId = delivery.Id; notice.SendPending = false; notice.Fault = null; Save(); + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not System.IO.IOException) + { + notice.Fault = RecruitmentFault.From(ex, Now, notice.Fault); Save(); + } + } + } +} diff --git a/DiscordBot/Services/Recruitment/RecruitmentService.cs b/DiscordBot/Services/Recruitment/RecruitmentService.cs index 94086d93..3c13fabc 100644 --- a/DiscordBot/Services/Recruitment/RecruitmentService.cs +++ b/DiscordBot/Services/Recruitment/RecruitmentService.cs @@ -119,8 +119,12 @@ private void PublishHealth() forum.Fault is null ? ExtendedLogSeverity.Info : ExtendedLogSeverity.Warning); } if (before.Status != next.Status) - LoggingService.LogToConsole("Recruitment: " + next.Status, + { + if (next.RoutinePendingIdentity || before.RoutinePendingIdentity && next.Status == "Operating") + LoggingService.DebugLog("Recruitment: " + next.Status, ExtendedLogSeverity.Debug); + else LoggingService.LogToConsole("Recruitment: " + next.Status, next.InventoryComplete ? ExtendedLogSeverity.Info : ExtendedLogSeverity.Warning); + } } private void ArmTimer() @@ -189,7 +193,8 @@ private bool Enqueue(string key, Func run, WorkKind kin private Task MessageUpdated(Cacheable before, SocketMessage after, ISocketMessageChannel channel) { if (channel is SocketThreadChannel thread && IsRecruitment(thread) && after.Id == thread.Id && - (!before.HasValue || before.Value.Content != after.Content)) NotifyFactsChanged(thread.Id.ToString()); + (!before.HasValue || before.Value.Content != after.Content || + !before.Value.Attachments.Select(a => a.Id).SequenceEqual(after.Attachments.Select(a => a.Id)))) NotifyFactsChanged(thread.Id.ToString()); return Task.CompletedTask; } @@ -216,17 +221,6 @@ internal void NotifyThreadDeletion(string id) Enqueue("deleted:" + id, ct => _engine.ThreadDeletedAsync(id, ct), thread: id); } private bool IsRecruitment(SocketThreadChannel thread) => thread.Guild.Id == _settings.GuildId && _settings.Recruitment.Forums.Contains(thread.ParentChannel.Id); - private Task MessageReceived(SocketMessage message) - { - if (message.Channel.Id == _settings.Recruitment.FeedChannelId) - { - var overview = message.Author.Id == _client?.CurrentUser.Id && message.Content.Contains("recruitment:overview:"); - if (overview) return Task.CompletedTask; - Enqueue("feed-activity", _ => { _engine.FeedActivity(message.Id.ToString(), false); return Task.CompletedTask; }); - return Task.CompletedTask; - } - return Task.CompletedTask; - } private Task MessageDeleted(Cacheable message, Cacheable channel) => MessagesDeleted(new[] { message }, channel); private Task MessagesDeleted(IReadOnlyCollection> messages, Cacheable channel) @@ -282,9 +276,16 @@ private Task ChannelDestroyed(SocketChannel channel) private Task RoleUpdated(SocketRole before, SocketRole after) { if (after.Guild.Id == _settings.GuildId && !before.Permissions.Equals(after.Permissions)) ScheduleRecovery(); return Task.CompletedTask; } private Task RoleDeleted(SocketRole role) { if (role.Guild.Id == _settings.GuildId) ScheduleRecovery(); return Task.CompletedTask; } + private Task UserUpdated(SocketUser before, SocketUser after) + { + if (before.AvatarId != after.AvatarId && Volatile.Read(ref _listingOwners).TryGetValue(after.Id.ToString(), out var threads)) + foreach (var id in threads) NotifyFactsChanged(id); + return Task.CompletedTask; + } + private Task MemberUpdated(Cacheable before, SocketGuildUser after) { - if (after.Guild.Id != _settings.GuildId || before.HasValue && before.Value.Roles.Select(r => r.Id).SequenceEqual(after.Roles.Select(r => r.Id))) return Task.CompletedTask; + if (after.Guild.Id != _settings.GuildId || before.HasValue && before.Value.Roles.Select(r => r.Id).SequenceEqual(after.Roles.Select(r => r.Id)) && before.Value.DisplayAvatarId == after.DisplayAvatarId) return Task.CompletedTask; if (after.Id == _client?.CurrentUser.Id) ScheduleRecovery(); else if (Volatile.Read(ref _listingOwners).TryGetValue(after.Id.ToString(), out var threads)) foreach (var id in threads) NotifyFactsChanged(id); @@ -339,20 +340,20 @@ private void Subscribe(bool subscribe) if (subscribe) { _client.ThreadCreated += ThreadCreated; _client.ThreadUpdated += ThreadUpdated; _client.ThreadDeleted += ThreadDeleted; - _client.MessageReceived += MessageReceived; _client.MessageDeleted += MessageDeleted; _client.MessagesBulkDeleted += MessagesDeleted; + _client.MessageDeleted += MessageDeleted; _client.MessagesBulkDeleted += MessagesDeleted; _client.MessageUpdated += MessageUpdated; _client.Disconnected += Disconnected; _client.Ready += Ready; _client.GuildAvailable += GuildAvailable; _client.GuildUnavailable += GuildUnavailable; _client.ChannelUpdated += ChannelUpdated; _client.ChannelDestroyed += ChannelDestroyed; - _client.RoleUpdated += RoleUpdated; _client.RoleDeleted += RoleDeleted; _client.GuildMemberUpdated += MemberUpdated; + _client.RoleUpdated += RoleUpdated; _client.RoleDeleted += RoleDeleted; _client.GuildMemberUpdated += MemberUpdated; _client.UserUpdated += UserUpdated; } else { _client.ThreadCreated -= ThreadCreated; _client.ThreadUpdated -= ThreadUpdated; _client.ThreadDeleted -= ThreadDeleted; - _client.MessageReceived -= MessageReceived; _client.MessageDeleted -= MessageDeleted; _client.MessagesBulkDeleted -= MessagesDeleted; + _client.MessageDeleted -= MessageDeleted; _client.MessagesBulkDeleted -= MessagesDeleted; _client.MessageUpdated -= MessageUpdated; _client.Disconnected -= Disconnected; _client.Ready -= Ready; _client.GuildAvailable -= GuildAvailable; _client.GuildUnavailable -= GuildUnavailable; _client.ChannelUpdated -= ChannelUpdated; _client.ChannelDestroyed -= ChannelDestroyed; - _client.RoleUpdated -= RoleUpdated; _client.RoleDeleted -= RoleDeleted; _client.GuildMemberUpdated -= MemberUpdated; + _client.RoleUpdated -= RoleUpdated; _client.RoleDeleted -= RoleDeleted; _client.GuildMemberUpdated -= MemberUpdated; _client.UserUpdated -= UserUpdated; } } public async Task StopAsync(CancellationToken ct) diff --git a/DiscordBot/Services/Recruitment/RecruitmentState.cs b/DiscordBot/Services/Recruitment/RecruitmentState.cs index b62de9ff..1b651ec5 100644 --- a/DiscordBot/Services/Recruitment/RecruitmentState.cs +++ b/DiscordBot/Services/Recruitment/RecruitmentState.cs @@ -121,8 +121,36 @@ public sealed class Listing public RecruitmentAction? PendingAction { get; set; } public List Audit { get; set; } = new(); public InitialFacts Facts { get; set; } = new(); + public StarterVersion? OriginalStarter { get; set; } + public StarterVersion? LatestStarter { get; set; } + public List EarlierMaterialVersions { get; set; } = new(); + public int MaterialChanges { get; set; } + public int ReviewedMaterialChanges { get; set; } + public List Reports { get; set; } = new(); + public List Attention { get; set; } = new(); } +public sealed class RecruitmentReport +{ + public string ReporterId { get; set; } = ""; + public DateTimeOffset At { get; set; } + public string Concern { get; set; } = ""; + public DateTimeOffset? ResolvedAt { get; set; } +} + +public sealed class AttentionNotice +{ + public string Id { get; set; } = Guid.NewGuid().ToString("N"); + public DateTimeOffset At { get; set; } + public string Reason { get; set; } = ""; + public string? MessageId { get; set; } + public bool SendPending { get; set; } + public RecruitmentFault? Fault { get; set; } +} + +public sealed record StarterVersion(DateTimeOffset ObservedAt, string Content, bool Truncated, string Fingerprint, + string Payment, string PaymentEvidence, List Links, List Attachments, string? TermsFingerprint = null); + public sealed record RecruitmentAudit(DateTimeOffset At, string Actor, string Reason); /// Advisory member and starter-message facts, refreshed on edits and bounded recovery checks. @@ -136,6 +164,9 @@ public sealed class InitialFacts public bool PriorAcceptedListing { get; set; } public int? MemberRoleCount { get; set; } public string Portfolio { get; set; } = "unknown"; + public bool? HasCustomAvatar { get; set; } + public string PaymentEvidence { get; set; } = ""; + public List Observations { get; set; } = new(); } /// Cross-forum cooldown and timeout history that survives removal of individual listings. @@ -148,6 +179,7 @@ public sealed class AuthorHistory public int ConsecutiveTimeouts { get; set; } public bool TimeoutAlerted { get; set; } public List RecentCreations { get; set; } = new(); + public List RecentReports { get; set; } = new(); public DateTimeOffset? AttemptPausedUntil { get; set; } } diff --git a/DiscordBot/Services/Recruitment/RecruitmentStateStore.cs b/DiscordBot/Services/Recruitment/RecruitmentStateStore.cs index aa443d41..fd3e6b3e 100644 --- a/DiscordBot/Services/Recruitment/RecruitmentStateStore.cs +++ b/DiscordBot/Services/Recruitment/RecruitmentStateStore.cs @@ -60,6 +60,7 @@ public void Save() Validate(State, State.GuildId, State.Forums.ToDictionary(x => x.Key, x => x.Value.ForumId)); var snapshot = JsonConvert.SerializeObject(State, Json); if (snapshot == _lastSnapshot) return; + if (Encoding.UTF8.GetByteCount(snapshot) > 32 * 1024 * 1024) throw new InvalidDataException("Recruitment state exceeds 32 MiB; operator review required."); WriteDurably(_path + ".tmp", snapshot); if (File.Exists(_path)) { @@ -119,16 +120,31 @@ state.Forums is null || state.Listings is null || state.Authors is null || state if (p is null || id != p.Id || !Id(id) || !Id(p.OwnerId) || !forums.Values.Contains(p.ForumId) || p.CreatedAt == default || p.CreatedAt.Offset != TimeSpan.Zero || p.UpdatedAt == default || !Enum.IsDefined(p.Status) || p.ValidCodes is null || p.Facts is null || p.Audit is null || - !state.Authors.ContainsKey(p.OwnerId) || + !state.Authors.ContainsKey(p.OwnerId) || p.Facts.Payment is null || p.Facts.Portfolio is null || + p.Facts.Observations is null || p.Facts.Observations.Count > 3 || + p.Facts.Observations.Any(o => o is null || o.Code is null || o.Description is null || o.Evidence is null || o.Evidence.Length > 180) || + p.Facts.PaymentEvidence is null || p.Facts.PaymentEvidence.Length > 370 || + p.EarlierMaterialVersions is null || p.EarlierMaterialVersions.Count > 3 || + p.MaterialChanges < 0 || p.ReviewedMaterialChanges < 0 || p.ReviewedMaterialChanges > p.MaterialChanges || + !VersionValid(p.OriginalStarter) || !VersionValid(p.LatestStarter) || p.EarlierMaterialVersions.Any(v => v is null || !VersionValid(v)) || + p.Reports is null || p.Reports.Count > 20 || p.Reports.Any(r => r is null || !Id(r.ReporterId) || r.At == default || r.Concern is null || r.Concern.Length > 1000) || + p.Attention is null || p.Attention.Count > 8 || p.Attention.Any(n => n is null || !Guid.TryParseExact(n.Id, "N", out _) || n.At == default || n.Reason is null || n.Reason.Length > 300 || n.MessageId is not null && !Id(n.MessageId)) || (p.AcceptedAt.HasValue && !p.AcknowledgedAt.HasValue) || (p.Deadline.HasValue && !p.PromptDeliveredAt.HasValue) || (p.PendingAction is not null && !Enum.IsDefined(p.PendingAction.Kind))) throw new InvalidDataException("Recruitment listing state is invalid."); foreach (var (id, a) in state.Authors) if (!Id(id) || a is null || a.ConsecutiveTimeouts < 0 || a.UpdatedAt == default || - a.RecentCreations is null || a.RecentCreations.Count > 21 || a.RecentCreations.Any(d => d == default)) + a.RecentCreations is null || a.RecentCreations.Count > 21 || a.RecentCreations.Any(d => d == default) || a.RecentReports is null || a.RecentReports.Count > 3 || a.RecentReports.Any(d => d == default)) throw new InvalidDataException("Recruitment author history is invalid."); } + private static bool VersionValid(StarterVersion? version) => version is null || + version.ObservedAt != default && version.Content is { Length: <= 2000 } && version.Fingerprint is { Length: 64 } && + (version.TermsFingerprint is null || version.TermsFingerprint.Length == 64) && + version.Payment is not null && version.PaymentEvidence is { Length: <= 370 } && version.Links is { Count: <= 8 } && + version.Links.All(l => l is { Length: <= 300 }) && version.Attachments is { Count: <= 10 } && + version.Attachments.All(a => a is not null && a.Name is { Length: <= 120 } && (a.ContentType is null || a.ContentType.Length <= 80)); + public void Dispose() => _lease.Dispose(); } diff --git a/DiscordBot/Settings/Settings.example.json b/DiscordBot/Settings/Settings.example.json index 2557cee5..9c554e70 100644 --- a/DiscordBot/Settings/Settings.example.json +++ b/DiscordBot/Settings/Settings.example.json @@ -45,7 +45,9 @@ }, "AcknowledgementMinutes": 30, "CooldownDays": 30, - "UnansweredDays": 30, + "AttemptLimit": 2, + "AttemptWindowMinutes": 60, + "AttemptPauseMinutes": 60, "PostRetentionMonths": 12, "AuthorRetentionMonths": 24 }, diff --git a/docs/recruitment.md b/docs/recruitment.md index dfd8d2aa..7ec2e1a4 100644 --- a/docs/recruitment.md +++ b/docs/recruitment.md @@ -6,13 +6,14 @@ Recruitment is disabled in the example, development and production settings. It - Each member has **one listing total** across paid recruiting, paid for hire, hobby recruiting and hobby for hire. An older pending attempt reserves the slot; changing forums does not bypass it. - Posts by bots, configured moderators and administrators are excluded from member recruitment. Previously managed staff posts become references and lose their bot advisory; accepted history is preserved. -- The bot posts one advisory embed with owner controls. It shows account age at posting, join date, server-role count (excluding `@everyone`), payment detection and, in for-hire forums, portfolio/work-sample links. Accounts under 30 days old, joins less than 7 days before posting, missing roles and missing/uncertain details are marked for attention. Blue means no listed check needs attention; it is not a trust rating. -- Paid posts need a concrete rate or budget to clear the payment flag. Hobby posts can state unpaid collaboration; revenue share is identified as non-guaranteed payment. Portfolio detection recognizes work-host links or a labelled external link; it never fetches links or verifies authorship. Starter edits and member-role changes refresh the checks. Members should clarify scope, deliverables, timing and payment publicly before moving to DMs. +- The bot maintains one neutral reader card: account age and time in the server at posting, plus relevant content observations. Every listing uses the same colour; there is no score, clearance or endorsement. **Details** privately shows dates, role count, the effective global/server avatar, payment evidence and work-sample information. Missing data is distinct from an adverse observation. +- Amounts need compensation context; unrelated prices and applicant fees do not establish pay. Mixed paid/unpaid/revenue-share terms remain visible. For-hire samples can be labelled work links or attached image/video/audio files; negated claims and arbitrary contact links do not clear that check. Detection never fetches URLs, verifies authorship or executes attachments. +- Advisory observations cover DM-only terms, applicant fees and urgent payment requests, with short source excerpts. A normal “DM to apply” contact method does not trigger this check. English keyword heuristics can miss context or other languages; these observations never remove a post. Starter/attachment edits and role/avatar changes refresh the same card. Clarify scope, work samples and payment publicly before moving to DMs. - Unavailable facts remain explicit. Community activity is no longer queried or displayed. Discord's documented [member](https://docs.discord.com/developers/resources/guild#guild-member-object) and [user](https://docs.discord.com/developers/resources/user#user-object) structures do not expose server-profile privacy, so no privacy flag is inferred. Embeds have a text fallback without Embed Links; updating an old advisory removes its obsolete banner attachment. -- The owner enters the five-character code from the forum's native Guidelines. The 30-minute window starts after usable controls are delivered. A correct code accepts an eligible listing immediately. An acknowledged blocked attempt can become accepted if its blocker clears during the window. -- At the deadline, an unacknowledged attempt is removed. An acknowledged attempt still blocked by another listing/cooldown is locked and archived. Pins and explicit exemptions protect against automatic actions. -- Only **accepted** listings start a cooldown, measured from the thread's original creation time. Closing/deleting on day 2 leaves 28 days; doing so on day 40 adds no wait. An open listing still occupies the slot after day 30, including when Discord naturally archives it. Removing an unaccepted attempt creates no cooldown. -- The owner can close the listing or confirm removal through an expiring, single-use control. Accepted cooldowns survive removal. The `Closed` tag also requests lock-and-archive. Existing unrelated applied tags are preserved if there is no room for another tag. +- The owner enters the five-character code from the forum's native Guidelines. The 30-minute window starts after usable controls are delivered. A correct code accepts an eligible listing immediately. Valid submissions received within the window remain timely even if the worker processes them later. +- At the deadline, an unacknowledged attempt is removed. Known duplicate, cooldown and posting-pause conflicts are locked and archived promptly without asking for a code. Closed attempts do not automatically reopen when restrictions expire. Pins and explicit exemptions protect against automatic actions. +- Only **accepted** listings start a cooldown, measured from the thread's original creation time. Closing/deleting on day 2 leaves 28 days; doing so on day 40 adds no wait. An open listing still occupies the slot after day 30, including when Discord naturally archives it. Removing an unaccepted attempt creates no accepted-listing cooldown, but still counts toward the attempt budget. +- **Your listing** opens private owner controls. The owner can close the listing or confirm removal through an expiring, single-use control. Accepted cooldowns survive removal. The `Closed` tag also requests lock-and-archive. Existing unrelated applied tags are preserved if there is no room for another tag. - Accepted listings remain indefinitely until the owner or a moderator closes/removes them. Owners can edit or bump the existing thread to preserve its conversation history. Natural auto-archive does not free the slot. Legacy `UnansweredDays` and reply-history fields are accepted when loading old configuration/state but no longer control expiry, and pending automatic unanswered closures are cancelled. - Public replies and owner editing remain available while a listing is open. There is no short edit lock, reply deletion, or parent permission rewrite. Configured recruitment forums and their threads do not earn new XP, including while recruitment is disabled; existing totals are unchanged. @@ -32,6 +33,9 @@ Set these fields under `Recruitment` in the existing settings file: }, "AcknowledgementMinutes": 30, "CooldownDays": 30, + "AttemptLimit": 2, + "AttemptWindowMinutes": 60, + "AttemptPauseMinutes": 60, "PostRetentionMonths": 12, "AuthorRetentionMonths": 24 } @@ -55,7 +59,7 @@ The persisted code rotates on Monday at 00:00 UTC. Publication is verified per f After setup and complete discovery, the bot reuses a discovered pinned staff reference or creates **Read first · Recruitment information** in each forum. The short introduction directs readers to staff guidance below and the native Guidelines; it does not repeat the rotating code. The reference uses the existing Closed tag, is pinned, locked and active (archiving would clear its pin), and is excluded from member challenges and capacity. Creation intent and the returned thread ID are saved before later writes. Interrupted creates recover by a bot-owned marker; incomplete inventory prevents duplicate creation. A deleted reference is replaced after discovery, and status links to each reference or shows its recovery fault. -Forum posts inherit parent permissions, so a single reference cannot have admin-only permission overwrites. Administrators and moderators with **Manage Threads** can reopen it, add or edit their own messages, then close it again. The bot preserves those messages and rechecks the pin/lock on startup, recovery or an explicit repair/reconcile. It does not immediately close a thread while staff are editing. [Discord thread permissions](https://docs.discord.com/developers/topics/threads) +Forum posts inherit parent permissions, so a single reference cannot have admin-only permission overwrites. Administrators and moderators with **Manage Threads** can unlock it if needed, add or edit their own messages, then relock it while leaving it active. The bot preserves those messages and rechecks the pin/lock on startup, recovery or an explicit repair/reconcile. It does not immediately relock a thread while staff are editing. [Discord thread permissions](https://docs.discord.com/developers/topics/threads) `/recruitment manage` → **Designate reference post** still excludes an existing member post from challenges and capacity and removes its advisory. An accepted cooldown remains until explicitly waived. Pinning an ordinary member listing alone does not free its slot. @@ -66,20 +70,30 @@ All commands are ephemeral and check the configured guild and the caller's curre | Command | Purpose | | --- | --- | | `/recruitment status` / `help` | Forum/reference links, setup or reference faults, counts, inventory coverage, next work and recovery-job status. Reads a detached snapshot, including during startup or a delayed Discord request. | -| `/recruitment lookup id` | Retained thread/author history and creation cooldown. | +| `/recruitment lookup id` | Retained history, bounded starter versions, private reports, audit reasons and same-server text/link matches. Matches are review hints; shared projects may be legitimate. | | `/recruitment preview [forum]` | Proposed Guidelines with a masked code, and managed tag names. | | `/recruitment repair reason [forum]` | Queue publication/verification of all forums or one selected forum after previewing it. | | `/recruitment reconcile reason` | Retry inventory, message delivery and pending actions. | -| `/recruitment manage operation thread-id reason` | Exempt/unexempt, reset a pending acknowledgement, reset timeout streak, designate reference, close/reopen, or review uncertain evidence. | +| `/recruitment manage operation thread-id reason` | Exempt/unexempt, reset a pending acknowledgement, reset timeout streak or posting attempts, resolve reports, mark material changes reviewed, designate reference, close/reopen, or review uncertain evidence. | | `/recruitment waive-cooldown author reason` | Clear the author's creation cooldown; never grants a second slot. | **Confirm unavailable thread is absent** releases a held slot only when Discord availability is uncertain; it does not infer a deletion actor or change the cooldown. Reopening checks capacity. A Closed tag or explicit owner/moderator close still applies. Repair and reconciliation return a short job ID promptly. The worker rechecks moderator authorization when the job runs; `/recruitment status` shows its latest result. Status/help use the gateway's current member/role cache and never wait behind mutation work. Missing authorization evidence denies access. -The feed also contains one current **Recruitment status** overview, separate from listing audit entries. New feed messages schedule a replacement after 10 seconds of quiet, at most once every five minutes. Continuous activity cannot postpone an eligible replacement by more than 60 seconds. Content-only changes coalesce for five seconds, with at least 30 seconds between edits. The bot confirms and saves a replacement before deleting its own prior overview. Lost responses are recovered by a unique marker; failed cleanup retains the authoritative new message and retries separately. The overview's own traffic does not trigger another move, and a quiet feed generates no dashboard traffic. A cosmetic failure does not block listing acceptance. +The feed contains one fixed **Recruitment status** overview, edited in place. Staff may pin it manually; the bot does not need Manage Messages. Updates coalesce for five seconds with at least 30 seconds between edits. Missing-message recovery and unfinished replacement/cleanup receipts from older versions remain supported. Ordinary feed traffic does not move it. Cosmetic delivery failures do not block acceptance. -The staff feed keeps one entry per attempt, updated with identity, eligibility, pending action, outcome and recent audit reasons. The third consecutive confirmed Guidelines timeout marks that attempt for moderator attention; later timeouts do not repeat the threshold alert until a real acknowledgement resets the streak. Wrong guesses are rate-limited and do not increment it. An independently missing thread or an ambiguous delete response is not counted as a confirmed bot timeout. +The overview links up to five listings needing report review, material-edit review, recovery or repeated-attempt attention. It also shows observed creations, accepted listings, closed unaccepted attempts, timeouts and mean prompt-to-accept delay for listings created in the past 24 hours. These are retained-record aggregates, not a complete event-analytics system. Use moderator review and recorded reasons to assess false positives before tightening thresholds. + +The staff feed keeps one concise entry per attempt, with full details in lookup. A new unresolved report, first unreviewed material edit, newly triggered posting pause or third consecutive confirmed Guidelines timeout creates a separate review notice. Further timeouts do not repeat that threshold notice until a real acknowledgement or moderator reset clears the streak. Reports/material edits are grouped until reviewed. Delivery receipts recover ambiguous sends instead of duplicating notices. Wrong guesses are rate-limited and do not increment timeouts; independently missing threads or ambiguous delete responses do not invent timeout evidence. Routine queued identities log only in Debug builds; actual inventory/permission/recovery faults still warn. + +### Private reports and evidence + +**Report concern** opens a private form tied to the listing. Current members can submit 10–1,000 characters of context; self-reports, duplicate reporters on a listing, and more than three reports per person in 24 hours are rejected. A listing retains at most 20 reports. Additional/urgent evidence can go directly to moderators. Reports use the existing validated staff feed and local state; they never change public observations or expose report counts. The bot cannot inspect other people's DMs. + +`resolve-reports` records a moderator's reason and resolves the listing's open reports. `review-changes` acknowledges outstanding changes to payment terms, links or attachments. Neither action endorses the author. First-observed/latest starter copies and up to three previous material versions are retained, each capped at 2,000 characters with truncation recorded. Up to eight specific URL paths and ten attachment metadata records are kept per version. “Original” means first observed, not guaranteed creation-time content. An unavailable starter does not erase earlier evidence. Per-listing notice receipts are bounded to eight and coalesce during prolonged delivery failures. + +Reports and versions follow listing retention; active listings retain their bounded evidence. Access to the persistent volume/backups should remain restricted to operators. The bot adds no external classifier, link crawler, private-conversation scraper or public reputation database. ## Persistence and recovery @@ -96,17 +110,21 @@ For storage recovery, stop the bot, preserve the damaged files, restore a valida The worker saves intent and staff evidence before destructive calls, then rechecks current identity and protection. Interrupted message sends recover bot-owned messages by marker before retrying. Recovery scans are bounded; a scan that cannot establish completeness holds instead of duplicating a message. An interrupted delete that finds an absent thread records an unknown actor. Archived-message updates save a restoration intent, temporarily unarchive when necessary, and restore archiving after updating the bot's own advisory. [Discord thread rules](https://docs.discord.com/developers/topics/threads) -Recruitment uses a single state writer and a bounded, coalesced event queue. Relevant thread/starter edits, member-role changes, deleted bot controls (including bulk deletion), permissions and readiness events drive targeted work. Deadlines and cooldown expiry have scheduled wake-ups. Ordinary acknowledgements do not run forum setup or enumerate all forums. A safety reconciliation runs every 15–18 minutes; incomplete inventory continues in bounded batches. Active-thread enumeration uses one guild request per discovery pass; newly found identities fetch their missing facts separately. The transport observes all identities before the SDK filters the response by forum. Member/starter facts and successful message renders are cached; targeted events or a bounded audit refresh them. Reference provisioning shares discovery instead of enumerating forums separately. Only work still waiting in the queue fences automatic actions; the event being processed does not cancel itself. State changes remain durable before external effects, and destructive actions retain fresh identity/protection checks. +Recruitment uses a single state writer and a bounded, coalesced event queue. Relevant thread/starter edits, member-role/avatar changes, deleted bot controls (including bulk deletion), permissions and readiness events drive targeted work. Acknowledgement deadlines, recovery and pending notifications have scheduled wake-ups. Ordinary acknowledgements do not run forum setup or enumerate all forums. A safety reconciliation runs every 15–18 minutes; incomplete inventory continues in bounded batches. Active-thread enumeration uses one guild request per discovery pass; newly found identities fetch their missing facts separately. The transport observes all identities before the SDK filters the response by forum. Member/starter facts and successful message renders are cached; targeted events or a bounded audit refresh them. Reference provisioning shares discovery instead of enumerating forums separately. Only work still waiting in the queue fences automatic actions; the event being processed does not cancel itself. State changes remain durable before external effects, and destructive actions retain fresh identity/protection checks. Confirmed thread-deletion events release listing capacity and clear stale holds even while setup is unavailable. Bounded safety audits also probe permanently held records for deletions missed during downtime, without blindly retrying their writes. Invalid setup, permission and ownership failures otherwise hold until a relevant invalidation or explicit recovery. Transient failures back off with jitter. Recruitment hands actual HTTP 429 retry deadlines back to the scheduler, with conservative channel/guild scope and global handling, and retains the SDK's shared request queue. Known SDK bucket exhaustion also fails promptly rather than sleeping through minutes. Repeated identical fault logs are suppressed; status shows the scope and retry eligibility. -Startup, disconnects, lost queue events and delivery failures invalidate inventory evidence and restore full usable windows for pending attempts. Timely queued owner submissions and reply facts are processed before automatic actions; a valid acknowledgement is persisted using the Discord interaction timestamp before overdue timeout processing. Archive discovery resumes bounded pages across sweeps, with at most 50 new identities and 10 retained records processed per audit pass; scheduled listing work handles up to 20 due records at a time; incomplete inventory holds acceptance/actions. Retained known records are reconciled directly; terminal details expire after 12 months and inactive author aggregates after 24 months, while open/reference/held/action dependencies remain. The discovery floor prevents expired detail from being recreated by old archive scans. +Startup, disconnects, lost queue events and delivery failures invalidate inventory evidence and restore full usable windows for pending attempts. Timely queued owner submissions and queued thread facts are processed before automatic actions; a valid acknowledgement is persisted using the Discord interaction timestamp before overdue timeout processing. Archive discovery resumes bounded pages across sweeps, with at most 50 new identities and 10 retained records processed per audit pass; scheduled listing work handles up to 20 due records at a time; incomplete inventory holds acceptance/actions. Retained known records are reconciled directly; terminal details expire after 12 months and inactive author aggregates after 24 months, while open/reference/held/action dependencies remain. The discovery floor prevents expired detail from being recreated by old archive scans. ## Validation before deployment -The automated tests cover policy, persistence faults, legacy v2 loading, staged setup/rotation, actual installed-SDK payloads, synthetic 235/537-second throttles, idle request budgets, targeted processing, queue loss, dashboard cooldown/recovery, owner/staff authorization, interaction registration staff exclusion, current-content embeds, portfolio detection, pinned-reference recovery and actual SDK publication payloads. They do not call live Discord. +The offline tests cover policy, acknowledgement timestamps through the real queue, creation limits/deletion/restarts, persistence faults and older v2 defaults, installed-SDK payloads, rate-limit recovery, fixed-overview recovery, privacy/authorization/report limits, bounded evidence, neutral reader presentation, content counterexamples and effective avatars. They do not call live Discord. + +Before enabling in production, exercise staging permissions and feed privacy; Guidelines/tag repair; acknowledgement and duplicate/cooldown/pause flows; closed controls; private reports and review notices; starter/attachment/profile edits; reference editing/relocking; restart recovery; and mobile readability. Confirm old accepted threads remain open and existing closures stay closed. Verify the mounted persistent directory, backup and graceful shutdown. + +Consider **10 minutes of native forum creation slowmode** as a pilot, configured by staff. Discord's `rate_limit_per_user` for forum creation is separate from `default_thread_rate_limit_per_user` for replies; keep public clarification practical. Review native AutoMod spam/mention/keyword rules alongside the bot. These server settings are not changed by this implementation. [Discord forum fields](https://docs.discord.com/developers/topics/threads), [AutoMod FAQ](https://support.discord.com/hc/en-us/articles/4421269296535-AutoMod-FAQ) -Before opening posting access, exercise the new forums in staging: effective permissions and feed privacy, native Guidelines/tag repair, acknowledgement and immediate acceptance, duplicate/cooldown behavior, removal confirmation, pinned/reference protection, indefinite listing lifetime and Closed-tag closure, restart recovery, reference editing/relocking, starter/role corrections and mobile embed readability. Check initially empty forums, an unrelated moderated tag, restart between setup stages, the overview replacement cooldown and a quiet interval without repeated channel reads. Verify the mounted persistent directory and graceful shutdown. No live bot login, guild command registration, staging mutation, deployment or remote push is part of local implementation validation. +No live bot login, command registration, staging mutation, deployment or remote push is part of local validation. ### Creation attempts diff --git a/k8s/dev/bot-config.yaml b/k8s/dev/bot-config.yaml index 8123df36..dc4b0a81 100644 --- a/k8s/dev/bot-config.yaml +++ b/k8s/dev/bot-config.yaml @@ -62,7 +62,9 @@ data: }, "AcknowledgementMinutes": 30, "CooldownDays": 30, - "UnansweredDays": 30, + "AttemptLimit": 2, + "AttemptWindowMinutes": 60, + "AttemptPauseMinutes": 60, "PostRetentionMonths": 12, "AuthorRetentionMonths": 24 }, diff --git a/k8s/prod/bot-config.yaml b/k8s/prod/bot-config.yaml index e32dda31..938afff4 100644 --- a/k8s/prod/bot-config.yaml +++ b/k8s/prod/bot-config.yaml @@ -62,7 +62,9 @@ data: }, "AcknowledgementMinutes": 30, "CooldownDays": 30, - "UnansweredDays": 30, + "AttemptLimit": 2, + "AttemptWindowMinutes": 60, + "AttemptPauseMinutes": 60, "PostRetentionMonths": 12, "AuthorRetentionMonths": 24 }, From d00f5a395c380f52b0bed1e8a22da67938b76148 Mon Sep 17 00:00:00 2001 From: James Kellie Date: Thu, 15 Oct 2026 17:27:51 +1000 Subject: [PATCH 15/31] Count recruitment attempts deleted before the first read --- .../Recruitment/RecruitmentAttemptTests.cs | 48 +++++++++++++++++++ .../Recruitment/RecruitmentTestRig.cs | 2 + .../Recruitment/DiscordRecruitmentAdapter.cs | 7 +++ .../Recruitment/IRecruitmentDiscord.cs | 3 ++ .../Recruitment/RecruitmentAttempts.cs | 22 +++++++++ .../Recruitment/RecruitmentService.cs | 4 +- docs/recruitment.md | 2 +- 7 files changed, 86 insertions(+), 2 deletions(-) diff --git a/DiscordBot.Tests/Recruitment/RecruitmentAttemptTests.cs b/DiscordBot.Tests/Recruitment/RecruitmentAttemptTests.cs index 64906d6f..7e876af7 100644 --- a/DiscordBot.Tests/Recruitment/RecruitmentAttemptTests.cs +++ b/DiscordBot.Tests/Recruitment/RecruitmentAttemptTests.cs @@ -4,6 +4,54 @@ namespace DiscordBot.Tests.Recruitment; public class RecruitmentAttemptTests { + [Fact] + public async Task RapidCreateDeleteCountsThroughServiceQueueAndSurvivesRestart() + { + using var rig = new RecruitmentRig(); + var service = new RecruitmentService(rig.Settings, rig.Discord, rig.Clock); + try + { + await service.StartAsync(); + for (var i = 0; i < 3; i++) + service.NotifyCreation(new ThreadCreation((100 + i).ToString(), (11 + i).ToString(), "200", "Brief attempt", rig.Clock.Now)); + service.NotifyCreation(new ThreadCreation("102", "13", "200", "Duplicate gateway event", rig.Clock.Now)); + await service.RunAsync((engine, _) => + { + Assert.Equal(3, engine.State!.Authors["200"].RecentCreations.Count); + Assert.Equal(rig.Clock.Now.AddHours(1), engine.State.Authors["200"].AttemptPausedUntil); + Assert.All(engine.State.Listings.Values, p => Assert.Equal(ListingStatus.Deleted, p.Status)); + Assert.Equal(0, engine.State.Authors["200"].ConsecutiveTimeouts); + return Task.FromResult(true); + }); + } + finally { await service.StopAsync(default); } + await rig.Start(); + Assert.Equal(3, rig.State.Authors["200"].RecentCreations.Count); + Assert.NotNull(rig.State.Authors["200"].AttemptPausedUntil); + Assert.Empty(rig.Discord.Actions); + } + + [Theory] + [InlineData("900", false, 0)] + [InlineData("999", false, 0)] + [InlineData("200", true, 0)] + [InlineData("200", false, 90)] + public async Task AbsentCreationDoesNotCountStaffBotsUnknownMembersOrOldAccessEvents(string owner, bool missing, int ageMinutes) + { + using var rig = new RecruitmentRig(); await rig.Start(); + if (missing) rig.Discord.MissingMembers.Add(owner); + await rig.Engine.ThreadCreatedAsync(new ThreadCreation("100", "11", owner, "Already absent", rig.Clock.Now.AddMinutes(-ageMinutes)), default); + Assert.Empty(rig.State.Listings); Assert.Empty(rig.State.Authors); + } + + [Fact] + public async Task ExistingThreadCreationEventDoesNotDoubleCountDiscovery() + { + using var rig = new RecruitmentRig(); await rig.Start(); var post = await rig.Post(); + await rig.Engine.ThreadCreatedAsync(new ThreadCreation(post.Id, post.ForumId, post.OwnerId, post.Title, post.CreatedAt), default); + Assert.Single(rig.State.Authors[post.OwnerId].RecentCreations); + } + [Fact] public async Task DeletedCreationsCountAcrossForumsAndRestartWithoutExtendingPause() { diff --git a/DiscordBot.Tests/Recruitment/RecruitmentTestRig.cs b/DiscordBot.Tests/Recruitment/RecruitmentTestRig.cs index af3d2eb1..99299fdf 100644 --- a/DiscordBot.Tests/Recruitment/RecruitmentTestRig.cs +++ b/DiscordBot.Tests/Recruitment/RecruitmentTestRig.cs @@ -136,6 +136,8 @@ public async Task ValidateAccessAsync(CancellationToken ct) public Task ValidateFeedAsync(CancellationToken ct) => FailFeed ? Task.FromException(new InvalidOperationException("Feed privacy invalid")) : Task.FromResult(FeedEmbeds); public Task LatestFeedMessageAsync(CancellationToken ct) => Task.FromResult(Messages.Where(m => m.Key.StartsWith("99:")).Select(m => m.Value.Id).OrderByDescending(ulong.Parse).FirstOrDefault()); public HashSet MissingMembers { get; } = new(); + public Task GetAuthorAsync(string userId, CancellationToken ct) => Task.FromResult( + MissingMembers.Contains(userId) ? null : new RecruitmentAuthor(userId == "999", userId == "900")); public Task IsMemberAsync(string guildId, string userId, CancellationToken ct) => Task.FromResult(guildId == "1" && !MissingMembers.Contains(userId)); public Task IsModeratorAsync(string guildId, string userId, CancellationToken ct) => Task.FromResult(guildId == "1" && userId == "900"); public bool IsExpectedForumUpdate(ForumSnapshot snapshot) => Forums.GetValueOrDefault(snapshot.Id) == snapshot; diff --git a/DiscordBot/Services/Recruitment/DiscordRecruitmentAdapter.cs b/DiscordBot/Services/Recruitment/DiscordRecruitmentAdapter.cs index 8b78ff89..d7ba16d4 100644 --- a/DiscordBot/Services/Recruitment/DiscordRecruitmentAdapter.cs +++ b/DiscordBot/Services/Recruitment/DiscordRecruitmentAdapter.cs @@ -80,6 +80,13 @@ public async Task IsMemberAsync(string guildId, string userId, Cancellatio catch (HttpException ex) when (ex.DiscordCode == DiscordErrorCode.UnknownMember) { return null; } } + public async Task GetAuthorAsync(string userId, CancellationToken ct) + { + var user = await MemberAsync(ulong.Parse(userId), ct); + return user is null ? null : new RecruitmentAuthor(user.IsBot, + user.GuildPermissions.Administrator || user.RoleIds.Contains(_settings.Roles.Moderator)); + } + public async Task IsModeratorAsync(string guildId, string userId, CancellationToken ct) { if (guildId != _settings.GuildId.ToString() || !ulong.TryParse(userId, out var id)) return false; diff --git a/DiscordBot/Services/Recruitment/IRecruitmentDiscord.cs b/DiscordBot/Services/Recruitment/IRecruitmentDiscord.cs index 2f5adfa2..5db9bc6b 100644 --- a/DiscordBot/Services/Recruitment/IRecruitmentDiscord.cs +++ b/DiscordBot/Services/Recruitment/IRecruitmentDiscord.cs @@ -2,6 +2,8 @@ namespace DiscordBot.Services.Recruitment; public sealed record LiveTag(string Id, string Name, bool Moderated, IEmote? Emoji = null); public sealed record ForumSnapshot(string Id, string Topic, bool RequireTag, IReadOnlyList Tags, ChannelFlags Flags = ChannelFlags.None); +public sealed record ThreadCreation(string Id, string ForumId, string OwnerId, string Title, DateTimeOffset CreatedAt); +public sealed record RecruitmentAuthor(bool IsBot, bool IsStaff); public sealed record ThreadSnapshot(string Id, string ForumId, string OwnerId, string Title, DateTimeOffset CreatedAt, bool OwnerIsBot, bool Pinned, bool Archived, bool Locked, IReadOnlyList Tags, string? Starter, DateTimeOffset? AccountCreatedAt, DateTimeOffset? JoinedAt, bool OwnerIsStaff = false, int? MemberRoleCount = null, bool? HasCustomAvatar = null, IReadOnlyList? Attachments = null); @@ -20,6 +22,7 @@ public interface IRecruitmentDiscord Task ValidateAccessAsync(CancellationToken ct); Task ValidateFeedAsync(CancellationToken ct); Task IsMemberAsync(string guildId, string userId, CancellationToken ct); + Task GetAuthorAsync(string userId, CancellationToken ct); Task IsModeratorAsync(string guildId, string userId, CancellationToken ct); bool IsExpectedForumUpdate(ForumSnapshot snapshot); Task GetForumAsync(string forumId, CancellationToken ct); diff --git a/DiscordBot/Services/Recruitment/RecruitmentAttempts.cs b/DiscordBot/Services/Recruitment/RecruitmentAttempts.cs index daaadbca..8ea5337d 100644 --- a/DiscordBot/Services/Recruitment/RecruitmentAttempts.cs +++ b/DiscordBot/Services/Recruitment/RecruitmentAttempts.cs @@ -2,6 +2,28 @@ namespace DiscordBot.Services.Recruitment; public sealed partial class RecruitmentEngine { + public async Task ThreadCreatedAsync(ThreadCreation creation, CancellationToken ct) + { + if (State is null || !_ready || !State.Forums.Values.Any(f => f.ForumId == creation.ForumId)) return; + await ThreadChangedAsync(creation.Id, ct); + if (State.Listings.ContainsKey(creation.Id) || creation.CreatedAt <= Now.AddMinutes(-_settings.AttemptWindowMinutes)) return; + // THREAD_CREATE can also announce access to an old thread. Only recent, confirmed-absent + // creations with a known member identity count here; successful discovery already counted itself. + if (await _discord.GetThreadAsync(creation.Id, ct, refresh: true) is not null) return; + var author = await _discord.GetAuthorAsync(creation.OwnerId, ct); + if (author is null || author.IsBot || author.IsStaff) return; + var post = new Listing + { + Id = creation.Id, ForumId = creation.ForumId, OwnerId = creation.OwnerId, + Title = RecruitmentPresentation.Safe(creation.Title), CreatedAt = creation.CreatedAt.ToUniversalTime(), UpdatedAt = Now, + Status = ListingStatus.Deleted, LastOutcome = "Thread absent before first read; creation observed, deletion actor unknown", + PublicationPending = true + }; + State.Listings[post.Id] = post; + RecordCreation(post); Save(); + await ProcessSafelyAsync(post, ct); + } + // Called once per newly retained thread identity, regardless of later deletion or acknowledgement. private void RecordCreation(Listing post) { diff --git a/DiscordBot/Services/Recruitment/RecruitmentService.cs b/DiscordBot/Services/Recruitment/RecruitmentService.cs index 3c13fabc..82d41239 100644 --- a/DiscordBot/Services/Recruitment/RecruitmentService.cs +++ b/DiscordBot/Services/Recruitment/RecruitmentService.cs @@ -188,6 +188,7 @@ private bool Enqueue(string key, Func run, WorkKind kin } internal void NotifyThread(string id) => Enqueue("thread:" + id, ct => _engine.ThreadChangedAsync(id, ct), thread: id); + internal void NotifyCreation(ThreadCreation thread) => Enqueue("created:" + thread.Id, ct => _engine.ThreadCreatedAsync(thread, ct), thread: thread.Id); internal void NotifyFactsChanged(string id) => Enqueue("facts:" + id, ct => _engine.FactsChangedAsync(id, ct), thread: id); private Task MessageUpdated(Cacheable before, SocketMessage after, ISocketMessageChannel channel) @@ -200,7 +201,8 @@ private Task MessageUpdated(Cacheable before, SocketMessage aft private Task ThreadCreated(SocketThreadChannel thread) { - if (IsRecruitment(thread)) NotifyThread(thread.Id.ToString()); + if (IsRecruitment(thread)) NotifyCreation(new ThreadCreation(thread.Id.ToString(), thread.ParentChannel.Id.ToString(), + ((IThreadChannel)thread).OwnerId.ToString(), thread.Name, thread.CreatedAt)); return Task.CompletedTask; } private Task ThreadUpdated(Cacheable before, SocketThreadChannel after) diff --git a/docs/recruitment.md b/docs/recruitment.md index 7ec2e1a4..0ea4ccd1 100644 --- a/docs/recruitment.md +++ b/docs/recruitment.md @@ -128,6 +128,6 @@ No live bot login, command registration, staging mutation, deployment or remote ### Creation attempts -Every distinct recently created member listing counts across the four forums, including attempts later deleted. Defaults: `AttemptLimit: 2`, `AttemptWindowMinutes: 60`, `AttemptPauseMinutes: 60`. The third creation starts a pause; further creations during the pause do not extend it. Discovery retries and old historical listings do not consume attempts. Earlier eligible pending listings can still be acknowledged. Known slot/cooldown/pause conflicts are closed promptly, with their reason preserved. +Every distinct recently observed member listing counts across the four forums, including attempts later deleted. A creation event retains the owner identity even when the thread disappears before its first read; confirmed absent attempts count after checking that the author is a current, non-exempt member. Missing creation events or unavailable membership cannot establish an attempt, which is another reason to use native creation slowmode. Defaults: `AttemptLimit: 2`, `AttemptWindowMinutes: 60`, `AttemptPauseMinutes: 60`. The third creation starts a pause; further creations during the pause do not extend it. Discovery retries and old historical listings do not consume attempts. Earlier eligible pending listings can still be acknowledged. Known slot/cooldown/pause conflicts are closed promptly, with their reason preserved. Members can use `/recruitment eligibility` for private slot, cooldown and pause information. `/recruitment manage reset-attempts ` clears that author's attempt budget; it does not reopen closed attempts, waive the accepted-listing cooldown or grant a second slot. From 0ed901638b92b9ea444acbc8759f69f7eae8bc23 Mon Sep 17 00:00:00 2001 From: James Kellie Date: Sat, 17 Oct 2026 13:45:54 +1000 Subject: [PATCH 16/31] Clarify recruitment eligibility and guidance --- .../Recruitment/RecruitmentAttemptTests.cs | 72 ++++++++++++++++++- .../Recruitment/RecruitmentOverviewTests.cs | 2 +- .../Recruitment/RecruitmentRecoveryTests.cs | 5 +- .../Recruitment/RecruitmentReferenceTests.cs | 20 ++++++ .../Recruitment/RecruitmentTestRig.cs | 2 +- .../recruitment/guidelines/hobby-for-hire.md | 2 +- .../guidelines/hobby-recruiting.md | 2 +- .../recruitment/guidelines/paid-for-hire.md | 2 +- .../recruitment/guidelines/paid-recruiting.md | 2 +- .../Modules/Recruitment/RecruitmentModule.cs | 2 - .../DiscordRecruitmentReferences.cs | 5 +- .../Recruitment/RecruitmentAttempts.cs | 22 +++--- .../Services/Recruitment/RecruitmentHealth.cs | 2 +- .../Recruitment/RecruitmentOverview.cs | 2 +- docs/recruitment.md | 8 +-- 15 files changed, 122 insertions(+), 28 deletions(-) diff --git a/DiscordBot.Tests/Recruitment/RecruitmentAttemptTests.cs b/DiscordBot.Tests/Recruitment/RecruitmentAttemptTests.cs index 7e876af7..af7d382f 100644 --- a/DiscordBot.Tests/Recruitment/RecruitmentAttemptTests.cs +++ b/DiscordBot.Tests/Recruitment/RecruitmentAttemptTests.cs @@ -122,6 +122,76 @@ public async Task PrivateEligibilityChecksMembershipAndExplainsExistingListing() Assert.DoesNotContain("100", await rig.Engine.EligibilityAsync("2", "200", default)); rig.Discord.MissingMembers.Add("200"); Assert.Contains("current members", await rig.Engine.EligibilityAsync("1", "200", default)); - Assert.Contains("no recorded listing restrictions", await rig.Engine.EligibilityAsync("1", "201", default)); + Assert.Contains("No open listing is recorded", await rig.Engine.EligibilityAsync("1", "201", default)); } + [Fact] + public async Task NewAuthorSeesOneSlotAndConfiguredAntiSpamRule() + { + using var rig = new RecruitmentRig(); await rig.Start(); + var text = await rig.Engine.EligibilityAsync("1", "200", default); + Assert.StartsWith("No open listing is recorded.", text); + Assert.Contains("one open listing across all four", text); + Assert.Contains("30-day cooldown from creation", text); + Assert.Contains("attempt 3 within 60 minutes", text); + Assert.Contains("deleted attempts count", text); + Assert.DoesNotContain("Up to", text); + } + + [Fact] + public async Task EligibilityCopyUsesConfiguredDurationsAndAttemptThreshold() + { + using var rig = new RecruitmentRig(); + rig.Settings.Recruitment.CooldownDays = 14; + rig.Settings.Recruitment.AttemptLimit = 4; + rig.Settings.Recruitment.AttemptWindowMinutes = 20; + rig.Settings.Recruitment.AttemptPauseMinutes = 45; + await rig.Start(); + var text = await rig.Engine.EligibilityAsync("1", "200", default); + Assert.Contains("14-day cooldown from creation", text); + Assert.Contains("attempt 5 within 20 minutes triggers a 45-minute pause", text); + } + + [Fact] + public async Task PendingAndAcceptedListingsOccupySlotEvenAfterCooldownExpires() + { + using var rig = new RecruitmentRig(); await rig.Start(); await rig.Post(); + Assert.StartsWith("Your pending listing occupies your slot: <#100>", await rig.Engine.EligibilityAsync("1", "200", default)); + await rig.Ack(); rig.Clock.Advance(TimeSpan.FromDays(31)); + var text = await rig.Engine.EligibilityAsync("1", "200", default); + Assert.StartsWith("Your open listing occupies your slot: <#100>", text); + Assert.DoesNotContain("cooldown ends", text); + Assert.DoesNotContain("No open listing", text); + } + + [Fact] + public async Task ClosedListingStillShowsRemainingAcceptedCooldownFirst() + { + using var rig = new RecruitmentRig(); await rig.Start(); await rig.Post(); await rig.Ack(); await rig.Staff("close"); + var text = await rig.Engine.EligibilityAsync("1", "200", default); + Assert.StartsWith("Your accepted-listing cooldown ends", text); + Assert.Contains("No open listing is recorded", text); + Assert.DoesNotContain("occupies your slot", text); + } + + [Fact] + public async Task DeletedAttemptsShowActivePauseWithoutClaimingCapacity() + { + using var rig = new RecruitmentRig(); await rig.Start(); + for (var i = 0; i < 3; i++) + await rig.Engine.ThreadCreatedAsync(new ThreadCreation((100 + i).ToString(), "11", "200", "Deleted attempt", rig.Clock.Now), default); + var text = await rig.Engine.EligibilityAsync("1", "200", default); + Assert.StartsWith("Posting attempts are paused until", text); + Assert.Contains("Recent attempts: 3", text); + Assert.Contains("deleted attempts count", text); + } + + [Fact] + public async Task IncompleteInventoryDoesNotClaimAnEmptySlot() + { + using var rig = new RecruitmentRig(); rig.Discord.InventoryComplete = false; await rig.Start(); + var text = await rig.Engine.EligibilityAsync("1", "200", default); + Assert.StartsWith("Eligibility is being checked; the one-listing rule still applies.", text); + Assert.DoesNotContain("No open listing", text); + } + } diff --git a/DiscordBot.Tests/Recruitment/RecruitmentOverviewTests.cs b/DiscordBot.Tests/Recruitment/RecruitmentOverviewTests.cs index 5c95a15c..35cc06b0 100644 --- a/DiscordBot.Tests/Recruitment/RecruitmentOverviewTests.cs +++ b/DiscordBot.Tests/Recruitment/RecruitmentOverviewTests.cs @@ -58,7 +58,7 @@ public async Task MissingEmbedPermissionFallsBackToTextAndUnsafeFeedPublishesNot using var rig = new RecruitmentRig(); rig.Discord.FeedEmbeds = false; await rig.Start(); await rig.Engine.RefreshOverviewAsync(default); var message = Assert.Single(rig.Discord.Messages.Values).Message; - Assert.Null(message.Embed); Assert.Contains("Recruitment status", message.Content); + Assert.Null(message.Embed); Assert.Contains("Recruitment Status", message.Content); rig.Discord.FailFeed = true; rig.Engine.InvalidateAccess(); await rig.Sweep(); var sends = rig.Discord.Sends; rig.Clock.Advance(TimeSpan.FromMinutes(6)); diff --git a/DiscordBot.Tests/Recruitment/RecruitmentRecoveryTests.cs b/DiscordBot.Tests/Recruitment/RecruitmentRecoveryTests.cs index f40988e3..004ebde8 100644 --- a/DiscordBot.Tests/Recruitment/RecruitmentRecoveryTests.cs +++ b/DiscordBot.Tests/Recruitment/RecruitmentRecoveryTests.cs @@ -17,7 +17,10 @@ public async Task InteractionModuleRegistersWithoutConnectingOrStartingTheFeatur var service = new RecruitmentService(client, new BotSettings()); using var provider = new ServiceCollection().AddSingleton(service).BuildServiceProvider(); var module = await interactions.AddModuleAsync(provider); - Assert.Equal(9, module.SlashCommands.Count); + Assert.Equal(8, module.SlashCommands.Count); + Assert.Contains(module.SlashCommands, c => c.Name == "status"); + Assert.Contains(module.SlashCommands, c => c.Name == "eligibility"); + Assert.DoesNotContain(module.SlashCommands, c => c.Name == "help"); Assert.Equal(7, module.ComponentCommands.Count); Assert.Equal(2, module.ModalCommands.Count); } diff --git a/DiscordBot.Tests/Recruitment/RecruitmentReferenceTests.cs b/DiscordBot.Tests/Recruitment/RecruitmentReferenceTests.cs index 4b00bbbb..0500bbcd 100644 --- a/DiscordBot.Tests/Recruitment/RecruitmentReferenceTests.cs +++ b/DiscordBot.Tests/Recruitment/RecruitmentReferenceTests.cs @@ -14,6 +14,7 @@ public async Task StartupCreatesOneLockedPinnedReferencePerForumAndRestartReuses { var reference = rig.Discord.Threads[id!]; Assert.True(reference.Pinned && reference.Locked && !reference.Archived); + Assert.Equal("Read First · Recruitment Information", reference.Title); Assert.Contains("information below", reference.Starter); Assert.DoesNotContain(rig.State.RotationCode, reference.Starter); } @@ -34,6 +35,7 @@ public async Task ExistingPinnedStaffReferenceIsReusedWithoutReplacingItsContent Assert.Equal("100", rig.State.Forums["paid-recruiting"].Reference.ThreadId); Assert.False(rig.State.Forums["paid-recruiting"].Reference.BotOwned); Assert.Equal("Moderator-maintained rules", rig.Discord.Threads["100"].Starter); + Assert.Equal("Information", rig.Discord.Threads["100"].Title); Assert.Equal(3, rig.Discord.ReferenceCreates); Assert.Empty(rig.State.Listings); } @@ -126,4 +128,22 @@ public async Task MissingBotStarterDoesNotAuthorizeDuplicateReferenceAfterLostRe Assert.Equal(reference.Id, rig.State.Forums["paid-recruiting"].Reference.ThreadId); Assert.Equal(4, rig.Discord.ReferenceCreates); } + [Fact] + public async Task ReconciliationPreservesCustomizedBotReferenceTitleAndContent() + { + using var rig = new RecruitmentRig(); await rig.Start(); + var id = rig.State.Forums["paid-recruiting"].Reference.ThreadId!; + var reference = rig.Discord.Threads[id]; + rig.Discord.Threads[id] = reference with { Title = "Staff customized guidance", Starter = reference.Starter + "\nStaff notes" }; + rig.Discord.Replies.Add(id); + await rig.Restart(); await rig.Restart(); + var retained = rig.Discord.Threads[id]; + Assert.Equal("Staff customized guidance", retained.Title); + Assert.Equal(reference.Starter + "\nStaff notes", retained.Starter); + Assert.Equal(reference.Tags, retained.Tags); + Assert.Contains(id, rig.Discord.Replies); + Assert.True(retained.Pinned && retained.Locked && !retained.Archived); + Assert.Equal(4, rig.Discord.ReferenceCreates); + } + } diff --git a/DiscordBot.Tests/Recruitment/RecruitmentTestRig.cs b/DiscordBot.Tests/Recruitment/RecruitmentTestRig.cs index 99299fdf..694a26ce 100644 --- a/DiscordBot.Tests/Recruitment/RecruitmentTestRig.cs +++ b/DiscordBot.Tests/Recruitment/RecruitmentTestRig.cs @@ -164,7 +164,7 @@ public Task CreateReferenceAsync(string forum, string closedTagId, strin { ReferenceCreates++; var id = (++_nextId).ToString(); - Threads[id] = new ThreadSnapshot(id, forum, "999", "Read first · Recruitment information", DateTimeOffset.UtcNow, + Threads[id] = new ThreadSnapshot(id, forum, "999", DiscordRecruitmentAdapter.ReferenceTitle, DateTimeOffset.UtcNow, true, false, false, false, new[] { closedTagId }, DiscordRecruitmentAdapter.ReferenceIntroduction(marker), null, null); if (LoseNextReferenceResponse) { LoseNextReferenceResponse = false; throw new TimeoutException("Reference response lost"); } return Task.FromResult(id); diff --git a/DiscordBot/Assets/recruitment/guidelines/hobby-for-hire.md b/DiscordBot/Assets/recruitment/guidelines/hobby-for-hire.md index 73f217d0..360d0549 100644 --- a/DiscordBot/Assets/recruitment/guidelines/hobby-for-hire.md +++ b/DiscordBot/Assets/recruitment/guidelines/hobby-for-hire.md @@ -2,7 +2,7 @@ State **skills**, **original work samples**, **availability**, **project interests**, **collaboration expectations**, and **contact**. Explain **unpaid or revenue-share** terms. No vague “DM for details” posts or misleading portfolios. -Read the pinned rules; use accurate tags. Verify claims and agree terms in writing. +Read pinned rules; tag accurately. Verify claims. First contact and terms stay here; DMs only for private information. **One open listing across all four recruitment forums.** Combine hiring and service offers in a for-hire listing. diff --git a/DiscordBot/Assets/recruitment/guidelines/hobby-recruiting.md b/DiscordBot/Assets/recruitment/guidelines/hobby-recruiting.md index b10de7fe..dc39f45a 100644 --- a/DiscordBot/Assets/recruitment/guidelines/hobby-recruiting.md +++ b/DiscordBot/Assets/recruitment/guidelines/hobby-recruiting.md @@ -2,7 +2,7 @@ State the **project and progress**, **roles/skills needed**, **tasks**, **time commitment**, **contact**, and **unpaid or revenue-share terms**. Show existing work where possible. Do not portray potential revenue as guaranteed pay. -Read the pinned rules; use accurate tags. Verify claims and agree terms in writing. +Read pinned rules; tag accurately. Verify claims. First contact and terms stay here; DMs only for private information. **One open listing across all four recruitment forums.** Combine hiring and service offers in a for-hire listing. diff --git a/DiscordBot/Assets/recruitment/guidelines/paid-for-hire.md b/DiscordBot/Assets/recruitment/guidelines/paid-for-hire.md index bc17ca31..c372e5bf 100644 --- a/DiscordBot/Assets/recruitment/guidelines/paid-for-hire.md +++ b/DiscordBot/Assets/recruitment/guidelines/paid-for-hire.md @@ -2,7 +2,7 @@ State **services/deliverables**, **proof of work** (portfolio or samples), **availability**, **rates and currency**, and **contact method**. Vague “DM for details” posts or false claims may be removed. -Read the pinned rules and use accurate tags. Verify claims; agree scope, deadlines, fees and payment terms in writing. +Read pinned rules; tag accurately. Verify claims. First contact and terms stay here; DMs only for private information. **One open listing total across all four recruitment forums.** Combine hiring and service offers here. diff --git a/DiscordBot/Assets/recruitment/guidelines/paid-recruiting.md b/DiscordBot/Assets/recruitment/guidelines/paid-recruiting.md index 83def895..0ae179b8 100644 --- a/DiscordBot/Assets/recruitment/guidelines/paid-recruiting.md +++ b/DiscordBot/Assets/recruitment/guidelines/paid-recruiting.md @@ -2,7 +2,7 @@ Describe **work/deliverables**, **skills**, **budget/rate and currency**, **payment schedule**, **timeline**, and **contact**. Name the project/organisation. No applicant fees, misleading pay claims, or “DM for details” listings. -Read the pinned rules; use accurate tags. Verify claims and agree terms in writing. +Read pinned rules; tag accurately. Verify claims. First contact and terms stay here; DMs only for private information. **One open listing across all four recruitment forums.** Combine hiring and service offers in a for-hire listing. diff --git a/DiscordBot/Modules/Recruitment/RecruitmentModule.cs b/DiscordBot/Modules/Recruitment/RecruitmentModule.cs index 6b2ff8d8..3c663e1d 100644 --- a/DiscordBot/Modules/Recruitment/RecruitmentModule.cs +++ b/DiscordBot/Modules/Recruitment/RecruitmentModule.cs @@ -27,8 +27,6 @@ public async Task Status() { await FollowupAsync("Moderator access is required in the configured server.", ephemeral: true); return; } await FollowupAsync(embed: Recruitment.Health.Embed(), ephemeral: true, allowedMentions: AllowedMentions.None); } - [SlashCommand("help", "Show recruitment status and moderator commands")] - public Task Help() => Status(); [SlashCommand("lookup", "Look up a managed thread or author ID")] public Task Lookup(string id) => Staff("lookup", id); [SlashCommand("preview", "Preview managed forum setup")] diff --git a/DiscordBot/Services/Recruitment/DiscordRecruitmentReferences.cs b/DiscordBot/Services/Recruitment/DiscordRecruitmentReferences.cs index 8ed87785..2781a809 100644 --- a/DiscordBot/Services/Recruitment/DiscordRecruitmentReferences.cs +++ b/DiscordBot/Services/Recruitment/DiscordRecruitmentReferences.cs @@ -5,18 +5,19 @@ namespace DiscordBot.Services.Recruitment; public sealed partial class DiscordRecruitmentAdapter { + internal const string ReferenceTitle = "Read First · Recruitment Information"; public async Task CreateReferenceAsync(string forumId, string closedTagId, string marker, CancellationToken ct) { var forum = _forums.TryGetValue(forumId, out var cached) ? cached.Channel : await ForumAsync(forumId, ct); var tags = forum.Tags.Where(t => t.Id.ToString() == closedTagId).ToArray(); if (tags.Length != 1) throw new InvalidOperationException("Reference post needs a verified Closed tag."); - var thread = await forum.CreatePostAsync("Read first · Recruitment information", ThreadArchiveDuration.OneWeek, + var thread = await forum.CreatePostAsync(ReferenceTitle, ThreadArchiveDuration.OneWeek, text: ReferenceIntroduction(marker), tags: tags, allowedMentions: AllowedMentions.None, options: Options(ct)); return thread.Id.ToString(); } internal static string ReferenceIntroduction(string marker) => - "Read the guidance below and this forum's Guidelines before posting. Clarify scope, timing and payment in the listing before moving to DMs.\n\n" + + "Read the guidance below and this forum's Guidelines before posting. Keep first contact, scope, timing and payment in the listing; use DMs only for private information.\n\n" + "Moderators and administrators maintain the information below.\n" + $"||{marker}||"; public async Task SecureReferenceAsync(string forumId, string threadId, bool botOwned, string marker, CancellationToken ct) diff --git a/DiscordBot/Services/Recruitment/RecruitmentAttempts.cs b/DiscordBot/Services/Recruitment/RecruitmentAttempts.cs index 8ea5337d..f2639152 100644 --- a/DiscordBot/Services/Recruitment/RecruitmentAttempts.cs +++ b/DiscordBot/Services/Recruitment/RecruitmentAttempts.cs @@ -59,22 +59,24 @@ public async Task EligibilityAsync(string guild, string owner, Cancellat if (State is null || guild != State.GuildId || !await _discord.IsMemberAsync(guild, owner, ct)) return "This control is only available to current members of the configured server."; var lines = new List(); - foreach (var post in State.Listings.Values.Where(p => p.OwnerId == owner && p.Status == ListingStatus.Open).Take(10)) - lines.Add($"Your listing: <#{post.Id}>. Update or bump this thread to keep its conversation history. Close it when finished; auto-archive does not free your slot."); + var open = State.Listings.Values.Where(p => p.OwnerId == owner && p.Status == ListingStatus.Open).Take(10).ToArray(); + foreach (var post in open) + lines.Add($"Your {(post.AcceptedAt.HasValue ? "open" : "pending")} listing occupies your slot: <#{post.Id}>. Update or bump this thread to keep its conversation history. Close it when finished; auto-archive does not free your slot."); if (State.Authors.TryGetValue(owner, out var author)) { if (author.LastAcceptedCreation?.AddDays(_settings.CooldownDays) is { } next && next > Now) - lines.Add($"A replacement can be accepted after {RecruitmentPresentation.Timestamp(next)}. Closing or deleting does not reset this cooldown."); + lines.Add($"Your accepted-listing cooldown ends {RecruitmentPresentation.Timestamp(next)}. Closing or deleting does not reset this cooldown."); if (author.AttemptPausedUntil > Now) lines.Add($"Posting attempts are paused until {RecruitmentPresentation.Timestamp(author.AttemptPausedUntil.Value)}. A moderator can review a mistake."); - else - { - var used = author.AttemptPausedUntil.HasValue ? 0 : author.RecentCreations.Count(d => d > Now.AddMinutes(-_settings.AttemptWindowMinutes)); - lines.Add($"Recent creations: {used}/{_settings.AttemptLimit} in {_settings.AttemptWindowMinutes} minutes. Another attempt over this limit starts a {_settings.AttemptPauseMinutes}-minute pause, including deleted attempts."); - } } - else lines.Add($"You have no recorded listing restrictions. Up to {_settings.AttemptLimit} creations in {_settings.AttemptWindowMinutes} minutes; further attempts start a {_settings.AttemptPauseMinutes}-minute pause."); - if (!EvidenceCurrent) lines.Add("Inventory recovery is in progress; eligibility will be checked again when you post."); + if (!EvidenceCurrent) + lines.Add("Eligibility is being checked; the one-listing rule still applies."); + else if (open.Length == 0) + lines.Add("No open listing is recorded."); + lines.Add($"You may have **one open listing across all four recruitment forums**. Pending listings reserve that slot. An accepted listing starts a **{_settings.CooldownDays}-day cooldown from creation**."); + var used = author is null || author.AttemptPausedUntil.HasValue && author.AttemptPausedUntil <= Now + ? 0 : author.RecentCreations.Count(d => d > Now.AddMinutes(-_settings.AttemptWindowMinutes)); + lines.Add($"Anti-spam: creation attempt {_settings.AttemptLimit + 1} within {_settings.AttemptWindowMinutes} minutes triggers a {_settings.AttemptPauseMinutes}-minute pause; deleted attempts count. Recent attempts: {used}."); return string.Join("\n", lines); } } diff --git a/DiscordBot/Services/Recruitment/RecruitmentHealth.cs b/DiscordBot/Services/Recruitment/RecruitmentHealth.cs index 5d8cd581..8c87eaf6 100644 --- a/DiscordBot/Services/Recruitment/RecruitmentHealth.cs +++ b/DiscordBot/Services/Recruitment/RecruitmentHealth.cs @@ -12,7 +12,7 @@ public sealed record RecruitmentHealth(string Status, string Work, DateTimeOffse public Embed Embed(bool includeFreshness = true) { - var embed = new EmbedBuilder().WithTitle("Recruitment status").WithColor(InventoryComplete && Status == "Operating" ? Color.Green : Color.Orange) + var embed = new EmbedBuilder().WithTitle("Recruitment Status").WithColor(InventoryComplete && Status == "Operating" ? Color.Green : Color.Orange) .WithDescription(Text()); if (includeFreshness) embed.WithFooter($"Snapshot captured {ObservedAt:u}"); return embed.Build(); diff --git a/DiscordBot/Services/Recruitment/RecruitmentOverview.cs b/DiscordBot/Services/Recruitment/RecruitmentOverview.cs index c6e0aba5..7357f131 100644 --- a/DiscordBot/Services/Recruitment/RecruitmentOverview.cs +++ b/DiscordBot/Services/Recruitment/RecruitmentOverview.cs @@ -56,7 +56,7 @@ public async Task RefreshOverviewAsync(CancellationToken ct) receipt.SendStartedAt = Now; } var marker = receipt.PendingMarker ?? throw new InvalidOperationException("Overview delivery marker is missing; reconcile required."); - var message = new RecruitmentMessage(_feedEmbeds ? $"-# {marker}" : "**Recruitment status**\n" + render[..Math.Min(1800, render.Length)] + $"\n-# {marker}", + var message = new RecruitmentMessage(_feedEmbeds ? $"-# {marker}" : "**Recruitment Status**\n" + render[..Math.Min(1800, render.Length)] + $"\n-# {marker}", marker, false, Embed: _feedEmbeds ? health.Embed(includeFreshness: false) : null); var delivery = await _discord.UpsertAsync(_settings.FeedChannelId.ToString(), replace ? null : receipt.MessageId, receipt.SendPending, receipt.SendStartedAt ?? Now, message, () => { GuardFeedWrite(); receipt.SendPending = true; Save(); }, ct); diff --git a/docs/recruitment.md b/docs/recruitment.md index 0ea4ccd1..41e4e676 100644 --- a/docs/recruitment.md +++ b/docs/recruitment.md @@ -57,7 +57,7 @@ Discord.Net 3.20.1 drops tag moderation during forum serialization and can send The persisted code rotates on Monday at 00:00 UTC. Publication is verified per forum; an interrupted rotation retries the same code. A challenge accepts its issued code and codes subsequently confirmed visible during its window. Codes are not included in advisories, staff entries, previews or diagnostic messages. -After setup and complete discovery, the bot reuses a discovered pinned staff reference or creates **Read first · Recruitment information** in each forum. The short introduction directs readers to staff guidance below and the native Guidelines; it does not repeat the rotating code. The reference uses the existing Closed tag, is pinned, locked and active (archiving would clear its pin), and is excluded from member challenges and capacity. Creation intent and the returned thread ID are saved before later writes. Interrupted creates recover by a bot-owned marker; incomplete inventory prevents duplicate creation. A deleted reference is replaced after discovery, and status links to each reference or shows its recovery fault. +After setup and complete discovery, the bot reuses a discovered pinned staff reference or creates **Read First · Recruitment Information** in each forum. Existing staff and customized reference titles are preserved. The short introduction directs readers to staff guidance below and the native Guidelines; it does not repeat the rotating code. The reference uses the existing Closed tag, is pinned, locked and active (archiving would clear its pin), and is excluded from member challenges and capacity. Creation intent and the returned thread ID are saved before later writes. Interrupted creates recover by a bot-owned marker; incomplete inventory prevents duplicate creation. A deleted reference is replaced after discovery, and status links to each reference or shows its recovery fault. Forum posts inherit parent permissions, so a single reference cannot have admin-only permission overwrites. Administrators and moderators with **Manage Threads** can unlock it if needed, add or edit their own messages, then relock it while leaving it active. The bot preserves those messages and rechecks the pin/lock on startup, recovery or an explicit repair/reconcile. It does not immediately relock a thread while staff are editing. [Discord thread permissions](https://docs.discord.com/developers/topics/threads) @@ -69,7 +69,7 @@ All commands are ephemeral and check the configured guild and the caller's curre | Command | Purpose | | --- | --- | -| `/recruitment status` / `help` | Forum/reference links, setup or reference faults, counts, inventory coverage, next work and recovery-job status. Reads a detached snapshot, including during startup or a delayed Discord request. | +| `/recruitment status` | Forum/reference links, setup or reference faults, counts, inventory coverage, next work and recovery-job status. Reads a detached snapshot, including during startup or a delayed Discord request. | | `/recruitment lookup id` | Retained history, bounded starter versions, private reports, audit reasons and same-server text/link matches. Matches are review hints; shared projects may be legitimate. | | `/recruitment preview [forum]` | Proposed Guidelines with a masked code, and managed tag names. | | `/recruitment repair reason [forum]` | Queue publication/verification of all forums or one selected forum after previewing it. | @@ -79,9 +79,9 @@ All commands are ephemeral and check the configured guild and the caller's curre **Confirm unavailable thread is absent** releases a held slot only when Discord availability is uncertain; it does not infer a deletion actor or change the cooldown. Reopening checks capacity. A Closed tag or explicit owner/moderator close still applies. -Repair and reconciliation return a short job ID promptly. The worker rechecks moderator authorization when the job runs; `/recruitment status` shows its latest result. Status/help use the gateway's current member/role cache and never wait behind mutation work. Missing authorization evidence denies access. +Repair and reconciliation return a short job ID promptly. The worker rechecks moderator authorization when the job runs; `/recruitment status` shows its latest result. Status uses the gateway's current member/role cache and never waits behind mutation work. Missing authorization evidence denies access. -The feed contains one fixed **Recruitment status** overview, edited in place. Staff may pin it manually; the bot does not need Manage Messages. Updates coalesce for five seconds with at least 30 seconds between edits. Missing-message recovery and unfinished replacement/cleanup receipts from older versions remain supported. Ordinary feed traffic does not move it. Cosmetic delivery failures do not block acceptance. +The feed contains one fixed **Recruitment Status** overview, edited in place. Staff may pin it manually; the bot does not need Manage Messages. Updates coalesce for five seconds with at least 30 seconds between edits. Missing-message recovery and unfinished replacement/cleanup receipts from older versions remain supported. Ordinary feed traffic does not move it. Cosmetic delivery failures do not block acceptance. The overview links up to five listings needing report review, material-edit review, recovery or repeated-attempt attention. It also shows observed creations, accepted listings, closed unaccepted attempts, timeouts and mean prompt-to-accept delay for listings created in the past 24 hours. These are retained-record aggregates, not a complete event-analytics system. Use moderator review and recorded reasons to assess false positives before tightening thresholds. From 9edb1df2758e604f63498d0265d882ba8d797f64 Mon Sep 17 00:00:00 2001 From: James Kellie Date: Sat, 17 Oct 2026 13:52:19 +1000 Subject: [PATCH 17/31] Add recruitment observations and author identity --- .../Recruitment/RecruitmentContentTests.cs | 10 +- .../Recruitment/RecruitmentEvidenceTests.cs | 17 +++ .../RecruitmentPresentationTests.cs | 14 +- .../RecruitmentPublicationTests.cs | 21 ++- .../Recruitment/RecruitmentSignalTests.cs | 131 +++++++++++++++++ .../Recruitment/DiscordRecruitmentAdapter.cs | 6 +- .../Recruitment/IRecruitmentDiscord.cs | 3 +- .../Recruitment/RecruitmentContent.cs | 136 +++++++++++++----- .../Recruitment/RecruitmentEvidence.cs | 8 ++ .../Recruitment/RecruitmentPresentation.cs | 50 ++----- .../Recruitment/RecruitmentService.cs | 4 +- .../Recruitment/RecruitmentSignals.cs | 83 +++++++++++ .../Services/Recruitment/RecruitmentState.cs | 7 + .../Recruitment/RecruitmentStateStore.cs | 12 +- 14 files changed, 411 insertions(+), 91 deletions(-) create mode 100644 DiscordBot.Tests/Recruitment/RecruitmentSignalTests.cs create mode 100644 DiscordBot/Services/Recruitment/RecruitmentSignals.cs diff --git a/DiscordBot.Tests/Recruitment/RecruitmentContentTests.cs b/DiscordBot.Tests/Recruitment/RecruitmentContentTests.cs index e96e3791..3ef18b42 100644 --- a/DiscordBot.Tests/Recruitment/RecruitmentContentTests.cs +++ b/DiscordBot.Tests/Recruitment/RecruitmentContentTests.cs @@ -47,12 +47,20 @@ public void BehavioralObservationsRetainAnExcerpt(string content, string code) } [Theory] - [InlineData("Budget USD 500. Deliver in two weeks. DM to apply with your portfolio.")] + [InlineData("No application fees or deposits required.")] [InlineData("Do not DM for payment details; terms must stay public.")] [InlineData("Never send money immediately to someone you do not know.")] public void OrdinaryContactAndExplicitSafetyAdviceAreNotPressure(string content) => Assert.Empty(RecruitmentContent.Observations(content)); + [Fact] + public void DmToApplyIsPublicFirstClarificationWithoutInventingPaymentPressure() + { + const string text = "Budget USD 500. Deliver in two weeks. DM to apply with your portfolio."; + Assert.Empty(RecruitmentContent.Observations(text)); + Assert.NotEmpty(RecruitmentContent.DmRequest(text)!); + } + [Fact] public void HostRootsAndOverlongContentDoNotCreateBroadContactMatches() { diff --git a/DiscordBot.Tests/Recruitment/RecruitmentEvidenceTests.cs b/DiscordBot.Tests/Recruitment/RecruitmentEvidenceTests.cs index 179510e4..3c75cfe3 100644 --- a/DiscordBot.Tests/Recruitment/RecruitmentEvidenceTests.cs +++ b/DiscordBot.Tests/Recruitment/RecruitmentEvidenceTests.cs @@ -4,6 +4,23 @@ namespace DiscordBot.Tests.Recruitment; public class RecruitmentEvidenceTests { + [Fact] + public async Task IdentityOnlyRefreshUpdatesSameMessageWithoutInventoriesOrEnforcementAndSurvivesRestart() + { + using var rig = new RecruitmentRig(); await rig.Start(); var post = await rig.Post(); await rig.Ack(); + var id = post.PublicMessageId; var accepted = post.AcceptedAt; var inventories = rig.Discord.Inventories; + rig.Discord.Threads[post.Id] = rig.Discord.Threads[post.Id] with { ServerNickname = "New @nickname", GlobalName = "Global", Username = "member", AvatarUrl = "https://cdn.discordapp.com/avatars/200/hash.png", AvatarIdentity = "hash" }; + await rig.Engine.FactsChangedAsync(post.Id, default); + Assert.Equal("New nickname", post.Facts.ServerNickname); + Assert.Contains("New nickname", rig.Discord.Messages["100:recruitment:100:public"].Message.TextFallback); + Assert.Equal(id, post.PublicMessageId); Assert.Equal(inventories, rig.Discord.Inventories); + Assert.Equal(accepted, post.AcceptedAt); Assert.Empty(rig.Discord.Actions); + await rig.Restart(); + var facts = rig.State.Listings[post.Id].Facts; + Assert.Equal("member", facts.Username); Assert.Equal("hash", facts.AvatarIdentity); Assert.Equal(9, facts.Checks.Count); + Assert.Equal("https://cdn.discordapp.com/avatars/200/hash.png", facts.AvatarUrl); + } + [Fact] public async Task TermsChangesBeyondDisplayedExcerptAreStillRecordedAndBounded() { diff --git a/DiscordBot.Tests/Recruitment/RecruitmentPresentationTests.cs b/DiscordBot.Tests/Recruitment/RecruitmentPresentationTests.cs index 58ba1e08..e830116f 100644 --- a/DiscordBot.Tests/Recruitment/RecruitmentPresentationTests.cs +++ b/DiscordBot.Tests/Recruitment/RecruitmentPresentationTests.cs @@ -45,10 +45,10 @@ public async Task ForHireEmbedHighlightsMissingEvidenceWithoutImplyingEndorsemen var message = RecruitmentPresentation.Public(rig.State, post, rig.Settings.Recruitment, rig.Clock.Now); var embed = Assert.IsType(message.Embed); Assert.Equal(new Discord.Color(0x747F8D), embed.Color); - Assert.Contains("account age: **2 days**", embed.Description); + Assert.Contains("Account Age", embed.Description); Assert.Contains("Clarify scope", embed.Description); - Assert.Contains("No portfolio link", embed.Description); - Assert.DoesNotContain("roles", embed.Description); + Assert.Contains("No Samples Found", embed.Description); + Assert.Contains("Server Roles", embed.Description); Assert.True(message.AcknowledgementRequired); Assert.DoesNotContain("Initial snapshot", message.TextFallback); Assert.DoesNotContain("Community activity", message.TextFallback); @@ -66,7 +66,7 @@ public async Task HobbyRecruitingDoesNotDemandPaymentOrAForHirePortfolio() Assert.DoesNotContain(embed.Fields, f => f.Name.StartsWith("⚠") || f.Name.Contains("Portfolio")); Assert.Equal(new Discord.Color(0x747F8D), embed.Color); post.Facts.Payment = RecruitmentContent.Payment("Revenue share"); - Assert.Contains("guaranteed payment is unclear", RecruitmentPresentation.Public(rig.State, post, rig.Settings.Recruitment, rig.Clock.Now).Embed!.Description); + Assert.Contains("Not Applicable", RecruitmentPresentation.Public(rig.State, post, rig.Settings.Recruitment, rig.Clock.Now).Embed!.Description); } [Fact] @@ -79,14 +79,14 @@ public async Task StarterAndRoleCorrectionsRefreshEmbedFieldsWithoutScanningForu var message = rig.Discord.Messages["100:recruitment:100:public"].Message; Assert.DoesNotContain("No portfolio", message.Embed!.Description); Assert.False(message.AcknowledgementRequired); Assert.True(message.ReaderControls); - Assert.Contains("3.", RecruitmentPresentation.Details(post)); + Assert.Contains("3 Roles", RecruitmentPresentation.Details(post)); Assert.Equal(messageId, post.PublicMessageId); Assert.Equal(inventories, rig.Discord.Inventories); var writes = rig.Discord.Upserts; await rig.Engine.FactsChangedAsync(post.Id, default); Assert.Equal(writes, rig.Discord.Upserts); rig.Discord.Threads[post.Id] = rig.Discord.Threads[post.Id] with { MemberRoleCount = 0 }; await rig.Engine.FactsChangedAsync(post.Id, default); - Assert.Contains("0.", RecruitmentPresentation.Details(post)); - Assert.DoesNotContain("roles", rig.Discord.Messages["100:recruitment:100:public"].Message.Embed!.Description); + Assert.Contains("0 Roles", RecruitmentPresentation.Details(post)); + Assert.Contains("0 Roles", rig.Discord.Messages["100:recruitment:100:public"].Message.Embed!.Description); } } diff --git a/DiscordBot.Tests/Recruitment/RecruitmentPublicationTests.cs b/DiscordBot.Tests/Recruitment/RecruitmentPublicationTests.cs index fd382d7e..b6593ca7 100644 --- a/DiscordBot.Tests/Recruitment/RecruitmentPublicationTests.cs +++ b/DiscordBot.Tests/Recruitment/RecruitmentPublicationTests.cs @@ -27,6 +27,18 @@ public async Task InstalledSdkSnapshotsStaffBotsAndRoles(string role, bool bot, Assert.Equal(roleCount, thread.MemberRoleCount); } + [Theory] + [InlineData("Server", "Global", "Server")] + [InlineData(null, "Global", "Global")] + [InlineData(null, null, "member")] + public async Task InstalledSdkCapturesIdentityFallbacks(string? nickname, string? globalName, string expected) + { + var transport = new PublicationTransport { Nickname = nickname, GlobalName = globalName }; + using var client = await Client(transport); + var thread = await new DiscordRecruitmentAdapter(client, Settings()).GetThreadAsync("100", default); + Assert.Equal(expected, thread!.ServerNickname ?? thread.GlobalName ?? thread.Username); + } + [Fact] public async Task InstalledSdkCreatesAndPinsReferenceWithoutChangingOtherFlagsOrMessages() { @@ -81,6 +93,9 @@ public async Task InstalledSdkUsesEffectiveAvatarAndMediaAttachments(string? glo using var client = await Client(transport); var thread = await new DiscordRecruitmentAdapter(client, Settings()).GetThreadAsync("100", default); Assert.Equal(custom, thread!.HasCustomAvatar); + Assert.Equal("member", thread.Username); Assert.Equal("Artist", thread.GlobalName); Assert.Equal("Server Artist", thread.ServerNickname); + Assert.NotNull(thread.AvatarUrl); + Assert.Equal(serverAvatar ?? globalAvatar, thread.AvatarIdentity); Assert.Equal("media sample attached", RecruitmentContent.Portfolio(thread.Starter, thread.Attachments)); } @@ -133,6 +148,8 @@ private static async Task Client(PublicationTransport trans private sealed class PublicationTransport : IRestClient { + public string? Nickname { get; init; } = "Server Artist"; + public string? GlobalName { get; init; } = "Artist"; public string Role { get; init; } = "52"; public bool OwnerBot { get; init; } public bool MissingMember { get; init; } @@ -159,8 +176,8 @@ private Task Respond(string method, string endpoint, JObject? patc if (endpoint == "users/@me") body = """{"id":"999","username":"offline","discriminator":"0","bot":true}"""; else if (endpoint == "oauth2/applications/@me") body = """{"id":"999","name":"offline","description":"","bot_public":true,"bot_require_code_grant":false,"verify_key":"00"}"""; else if (endpoint.StartsWith("guilds/1/members/200")) body = new JObject { - ["user"] = new JObject { ["id"] = "200", ["username"] = "member", ["discriminator"] = "0", ["bot"] = OwnerBot, ["avatar"] = GlobalAvatar }, - ["avatar"] = ServerAvatar, ["roles"] = new JArray(Role), ["joined_at"] = "2020-01-01T00:00:00Z" }.ToString(); + ["user"] = new JObject { ["id"] = "200", ["username"] = "member", ["discriminator"] = "0", ["bot"] = OwnerBot, ["avatar"] = GlobalAvatar, ["global_name"] = GlobalName }, + ["nick"] = Nickname, ["avatar"] = ServerAvatar, ["roles"] = new JArray(Role), ["joined_at"] = "2020-01-01T00:00:00Z" }.ToString(); else if (endpoint.StartsWith("guilds/1")) body = """{"id":"1","name":"Offline","owner_id":"900","roles":[{"id":"1","name":"@everyone","permissions":"0"},{"id":"50","name":"Moderator","permissions":"0"},{"id":"51","name":"Admin","permissions":"8"},{"id":"52","name":"Artist","permissions":"0"}],"emojis":[],"features":[],"stickers":[],"afk_timeout":300,"preferred_locale":"en-US"}"""; else if (endpoint == "channels/11") body = """{"id":"11","type":15,"guild_id":"1","name":"forum","flags":16,"available_tags":[{"id":"71","name":"Closed","moderated":false}]}"""; else if (endpoint == "channels/11/threads" && method == "POST") diff --git a/DiscordBot.Tests/Recruitment/RecruitmentSignalTests.cs b/DiscordBot.Tests/Recruitment/RecruitmentSignalTests.cs new file mode 100644 index 00000000..93184206 --- /dev/null +++ b/DiscordBot.Tests/Recruitment/RecruitmentSignalTests.cs @@ -0,0 +1,131 @@ +using DiscordBot.Services.Recruitment; + +namespace DiscordBot.Tests.Recruitment; + +public class RecruitmentSignalTests +{ + private static ThreadSnapshot Snapshot(string? text = "Budget $500. Project lasts 2 weeks.") => new("100", "12", "200", "Role", + DateTimeOffset.Parse("2026-02-28T12:00:00Z"), false, false, false, false, Array.Empty(), text, + DateTimeOffset.Parse("2024-02-29T12:00:00Z"), DateTimeOffset.Parse("2025-02-28T12:00:00Z"), + MemberRoleCount: 1, HasCustomAvatar: true); + private static RecruitmentObservation Check(ThreadSnapshot live, string id, string purpose = "paid-for-hire") => + RecruitmentSignals.Evaluate(live, purpose).Single(c => c.Id == id); + + [Fact] + public void StableNineChecksUseUtcCalendarAnniversariesIncludingLeapDates() + { + var live = Snapshot(); var checks = RecruitmentSignals.Evaluate(live, "paid-for-hire"); + Assert.Equal(9, checks.Count); Assert.Equal(9, checks.Select(c => c.Id).Distinct().Count()); + Assert.Equal(ObservationSeverity.Green, Check(live, "account-age").Severity); + Assert.Equal(ObservationSeverity.Green, Check(live, "join-date").Severity); + Assert.Equal(ObservationSeverity.Yellow, Check(live with { CreatedAt = live.CreatedAt.AddDays(-1) }, "account-age").Severity); + Assert.Equal(ObservationSeverity.Yellow, Check(live with { CreatedAt = live.CreatedAt.AddDays(-1) }, "join-date").Severity); + Assert.Equal(ObservationSeverity.Yellow, Check(live with { AccountCreatedAt = live.CreatedAt.AddMonths(-6) }, "account-age").Severity); + Assert.Equal(ObservationSeverity.Red, Check(live with { AccountCreatedAt = live.CreatedAt.AddMonths(-6).AddTicks(1) }, "account-age").Severity); + Assert.Equal(ObservationSeverity.Yellow, Check(live with { JoinedAt = live.CreatedAt.AddMonths(-2) }, "join-date").Severity); + Assert.Equal(ObservationSeverity.Red, Check(live with { JoinedAt = live.CreatedAt.AddMonths(-2).AddTicks(1) }, "join-date").Severity); + Assert.Equal(ObservationSeverity.Yellow, Check(live with { CreatedAt = live.CreatedAt.AddTicks(-1) }, "account-age").Severity); + Assert.Contains("Rejoined", Check(live with { JoinedAt = live.CreatedAt.AddHours(1) }, "join-date").Value); + Assert.Equal(ObservationApplicability.Unavailable, Check(live with { AccountCreatedAt = live.CreatedAt.AddHours(1) }, "account-age").Applicability); + Assert.Contains("Rejoined", Check(live with { JoinedAt = live.CreatedAt.AddDays(1) }, "join-date").Value); + Assert.Equal(ObservationApplicability.Unavailable, Check(live with { AccountCreatedAt = live.CreatedAt.AddDays(1) }, "account-age").Applicability); + } + + [Theory] + [InlineData(null, ObservationApplicability.Unavailable, ObservationSeverity.Grey)] + [InlineData("", ObservationApplicability.Available, ObservationSeverity.Red)] + [InlineData("Budget $500", ObservationApplicability.Available, ObservationSeverity.Green)] + [InlineData("Payment will be discussed", ObservationApplicability.Available, ObservationSeverity.Yellow)] + [InlineData("Paid in exposure", ObservationApplicability.Available, ObservationSeverity.Red)] + [InlineData("Budget $500. Never pay an application fee", ObservationApplicability.Available, ObservationSeverity.Green)] + [InlineData("Exposure only", ObservationApplicability.Available, ObservationSeverity.Red)] + [InlineData("Revenue share only", ObservationApplicability.Available, ObservationSeverity.Red)] + [InlineData("Budget $500 plus revenue share", ObservationApplicability.Available, ObservationSeverity.Yellow)] + [InlineData("We do not pay", ObservationApplicability.Available, ObservationSeverity.Red)] + [InlineData("We do not pay $50/hour", ObservationApplicability.Available, ObservationSeverity.Red)] + [InlineData("Not an unpaid role. Budget $500", ObservationApplicability.Available, ObservationSeverity.Green)] + [InlineData("Exposure to Unity required. Budget $500", ObservationApplicability.Available, ObservationSeverity.Green)] + [InlineData("Paid work plus revenue share", ObservationApplicability.Available, ObservationSeverity.Yellow)] + [InlineData("Application fee $500", ObservationApplicability.Available, ObservationSeverity.Red)] + [InlineData("Our game sells for $500", ObservationApplicability.Available, ObservationSeverity.Red)] + public void CompensationIsContextual(string? text, ObservationApplicability applicability, ObservationSeverity severity) + { var c = Check(Snapshot(text), "compensation"); Assert.Equal(applicability, c.Applicability); Assert.Equal(severity, c.Severity); } + + [Theory] + [InlineData("Project lasts 2 weeks", true)] + [InlineData("Duration: 2 weeks", true)] + [InlineData("3-month project", true)] + [InlineData("Deliver in two weeks", true)] + [InlineData("Ongoing work", true)] + [InlineData("Available for long-term contracts", true)] + [InlineData("Available from 2026-10-20", true)] + [InlineData("I am 2 years old on this server", false)] + [InlineData("Rate $50 per month", false)] + [InlineData("We launch soon", false)] + [InlineData("No ongoing work", false)] + [InlineData("Not available for long-term contracts", false)] + [InlineData("My birthday is 2026-10-20", false)] + public void DurationNeedsEngagementContext(string text, bool found) => + Assert.Equal(found, Check(Snapshot(text), "duration").Severity == ObservationSeverity.Green); + + [Theory] + [InlineData("DM to apply", true)] + [InlineData("DM me", true)] + [InlineData("Not a scam: DM to apply", true)] + [InlineData("Contact via DMs", true)] + [InlineData("Do not DM me; apply here", false)] + [InlineData("Never DM for payment terms", false)] + [InlineData("Do not apply via DMs", false)] + [InlineData("DM only for private information", false)] + [InlineData("Warning: scammers say 'DM to apply'", false)] + public void DmFirstKeepsNegationAndPrivateInformationExceptions(string text, bool adverse) => + Assert.Equal(adverse ? ObservationSeverity.Red : ObservationSeverity.Green, Check(Snapshot(text), "dm-requests").Severity); + + [Theory] + [InlineData(119, ObservationSeverity.Red)] + [InlineData(120, ObservationSeverity.Yellow)] + [InlineData(299, ObservationSeverity.Yellow)] + [InlineData(300, ObservationSeverity.Green)] + public void NormalizedBodyThresholds(int count, ObservationSeverity severity) => Assert.Equal(severity, Check(Snapshot(new string('a', count)), "post-detail").Severity); + + [Fact] + public void DecorationAndUrlsCannotManufactureDetail() + { + Assert.Equal(3, RecruitmentContent.NormalizedLength("**a** b https://example.org/" + new string('x', 900))); + Assert.Equal(0, RecruitmentContent.NormalizedLength("https://example.org/" + new string('x', 2000))); + Assert.Equal(0, RecruitmentContent.NormalizedLength(new string('-', 400))); + Assert.Equal(0, RecruitmentContent.NormalizedLength(string.Concat(Enumerable.Repeat("- - - \n", 100)))); + Assert.Equal(ObservationSeverity.Yellow, Check(Snapshot("DM to apply " + new string('x', 300)), "dm-requests").Severity); + } + + [Theory] + [InlineData("paid-recruiting", ObservationApplicability.NotApplicable, ObservationApplicability.Available)] + [InlineData("paid-for-hire", ObservationApplicability.Available, ObservationApplicability.Available)] + [InlineData("hobby-recruiting", ObservationApplicability.NotApplicable, ObservationApplicability.NotApplicable)] + [InlineData("hobby-for-hire", ObservationApplicability.Available, ObservationApplicability.NotApplicable)] + public void ForumApplicability(string purpose, ObservationApplicability portfolio, ObservationApplicability compensation) + { Assert.Equal(portfolio, Check(Snapshot(), "portfolio", purpose).Applicability); Assert.Equal(compensation, Check(Snapshot(), "compensation", purpose).Applicability); } + + [Fact] + public void PortfolioUsesInjectedHostsAndDistinguishesAttachedFromSupportedSamples() + { + var live = Snapshot("Portfolio https://artist.example/works"); + Assert.Equal(ObservationSeverity.Yellow, Check(live, "portfolio").Severity); + Assert.Equal(ObservationSeverity.Green, RecruitmentSignals.Evaluate(live, "paid-for-hire", new HashSet { "artist.example" }).Single(c => c.Id == "portfolio").Severity); + Assert.Equal(ObservationSeverity.Yellow, Check(Snapshot("") with { Attachments = new[] { new StarterAttachment("1", "demo", "image/png") } }, "portfolio").Severity); + Assert.Equal(ObservationSeverity.Red, Check(Snapshot("Not my work https://artstation.com/artist"), "portfolio").Severity); + Assert.Equal(ObservationSeverity.Yellow, Check(Snapshot("https://artist.example"), "portfolio").Severity); + Assert.Equal(ObservationSeverity.Red, Check(Snapshot("https://artist.example/contact"), "portfolio").Severity); + Assert.Equal(ObservationSeverity.Red, Check(Snapshot("https://discord.gg/team"), "portfolio").Severity); + Assert.Equal(ObservationSeverity.Yellow, Check(Snapshot(""), "portfolio", "hobby-for-hire").Severity); + } + + [Fact] + public void UnknownMemberIsDistinctFromNoRolesOrDefaultAvatar() + { + Assert.Equal(ObservationSeverity.Red, Check(Snapshot() with { MemberRoleCount = 0 }, "server-roles").Severity); + Assert.Equal(ObservationSeverity.Grey, Check(Snapshot() with { MemberRoleCount = null }, "server-roles").Severity); + Assert.Equal(ObservationSeverity.Yellow, Check(Snapshot() with { HasCustomAvatar = false }, "profile-image").Severity); + Assert.Equal(ObservationSeverity.Grey, Check(Snapshot() with { HasCustomAvatar = null }, "profile-image").Severity); + } +} diff --git a/DiscordBot/Services/Recruitment/DiscordRecruitmentAdapter.cs b/DiscordBot/Services/Recruitment/DiscordRecruitmentAdapter.cs index d7ba16d4..6cf2a087 100644 --- a/DiscordBot/Services/Recruitment/DiscordRecruitmentAdapter.cs +++ b/DiscordBot/Services/Recruitment/DiscordRecruitmentAdapter.cs @@ -224,7 +224,11 @@ private async Task SnapshotAsync(RestThreadChannel thread, Cance Array.Empty(), starter?.Content, SnowflakeUtils.FromSnowflake(ownerId), user?.JoinedAt, user is not null && (user.GuildPermissions.Administrator || user.RoleIds.Contains(_settings.Roles.Moderator)), user?.RoleIds.Count(r => r != _settings.GuildId), user is null ? null : !string.IsNullOrEmpty(user.DisplayAvatarId), - starter?.Attachments.Take(10).Select(a => new StarterAttachment(a.Id.ToString(), RecruitmentPresentation.Safe(a.Filename, 120), a.ContentType?[..Math.Min(a.ContentType.Length, 80)])).ToArray()); + starter?.Attachments.Take(10).Select(a => new StarterAttachment(a.Id.ToString(), RecruitmentPresentation.Safe(a.Filename, 120), a.ContentType?[..Math.Min(a.ContentType.Length, 80)])).ToArray(), + RecruitmentSignals.SafeIdentity(user?.Nickname), RecruitmentSignals.SafeIdentity(user?.GlobalName), + RecruitmentSignals.SafeIdentity(user?.Username ?? starter?.Author.Username), + RecruitmentSignals.SafeAvatarUrl(user?.GetDisplayAvatarUrl(ImageFormat.Png, 128)), + RecruitmentSignals.SafeIdentity(user?.DisplayAvatarId, 200)); // Missing starters remain retryable. Bound cached initial facts independently of retained history. if (starter is not null) { diff --git a/DiscordBot/Services/Recruitment/IRecruitmentDiscord.cs b/DiscordBot/Services/Recruitment/IRecruitmentDiscord.cs index 5db9bc6b..b9535623 100644 --- a/DiscordBot/Services/Recruitment/IRecruitmentDiscord.cs +++ b/DiscordBot/Services/Recruitment/IRecruitmentDiscord.cs @@ -6,7 +6,8 @@ public sealed record ThreadCreation(string Id, string ForumId, string OwnerId, s public sealed record RecruitmentAuthor(bool IsBot, bool IsStaff); public sealed record ThreadSnapshot(string Id, string ForumId, string OwnerId, string Title, DateTimeOffset CreatedAt, bool OwnerIsBot, bool Pinned, bool Archived, bool Locked, IReadOnlyList Tags, string? Starter, - DateTimeOffset? AccountCreatedAt, DateTimeOffset? JoinedAt, bool OwnerIsStaff = false, int? MemberRoleCount = null, bool? HasCustomAvatar = null, IReadOnlyList? Attachments = null); + DateTimeOffset? AccountCreatedAt, DateTimeOffset? JoinedAt, bool OwnerIsStaff = false, int? MemberRoleCount = null, bool? HasCustomAvatar = null, IReadOnlyList? Attachments = null, + string? ServerNickname = null, string? GlobalName = null, string? Username = null, string? AvatarUrl = null, string? AvatarIdentity = null); public sealed record ArchiveCursor(DateTimeOffset Since, DateTimeOffset? Before = null, DateTimeOffset? StartedAt = null); public sealed record ThreadInventory(IReadOnlyList Threads, bool Complete, IReadOnlyDictionary? Cursors = null); public sealed record RecruitmentMessage(string Content, string Marker, bool OwnerControls, bool RestoreArchive = false, Embed? Embed = null, string? TextFallback = null, bool AcknowledgementRequired = false, bool ReaderControls = false) diff --git a/DiscordBot/Services/Recruitment/RecruitmentContent.cs b/DiscordBot/Services/Recruitment/RecruitmentContent.cs index 1fa75612..188eb6d7 100644 --- a/DiscordBot/Services/Recruitment/RecruitmentContent.cs +++ b/DiscordBot/Services/Recruitment/RecruitmentContent.cs @@ -3,7 +3,10 @@ namespace DiscordBot.Services.Recruitment; public sealed record ContentObservation(string Code, string Description, string Evidence); -public sealed record PaymentObservation(string Summary, string Evidence); +public enum CompensationKind { Unavailable, Missing, MonetaryPromise, Rate, Mixed, Unpaid, RevenueShare } +public sealed record PaymentObservation(string Summary, string Evidence, CompensationKind Kind = CompensationKind.Missing); +public sealed record PortfolioCandidate(string Url, string Host, bool Specific, bool Supported, bool WorkContext, bool Negated); +public sealed record PortfolioEvidence(bool Available, bool MediaSample, IReadOnlyList Candidates); public sealed record StarterAttachment(string Id, string Name, string? ContentType); public static partial class RecruitmentContent @@ -15,56 +18,95 @@ public static partial class RecruitmentContent // An amount alone is not compensation. Labels and units provide context, never verification. public static PaymentObservation ReadPayment(string? content) { - if (content is null) return new("unknown", ""); + if (content is null) return new("unknown", "", CompensationKind.Unavailable); var text = Bounded(content); try { var sentences = Sentences().Split(text); var rate = sentences.FirstOrDefault(s => Concrete().IsMatch(s) && - (PaymentLabel().IsMatch(s) || RateUnit().IsMatch(s)) && !OtherPrice().IsMatch(s) && !ApplicantFee().IsMatch(s)); - var unpaid = sentences.FirstOrDefault(s => Unpaid().IsMatch(s) && !(ApplicantFee().IsMatch(s) && NoFee().IsMatch(s))); - var share = sentences.FirstOrDefault(s => RevenueShare().IsMatch(s)); - if (rate is not null && (unpaid is not null || share is not null)) - return new("mixed compensation terms; clarify what is guaranteed", Excerpt(rate) + " / " + Excerpt(unpaid ?? share!)); - if (unpaid is not null) return new("unpaid collaboration stated", Excerpt(unpaid)); - if (share is not null) return new("revenue-share stated", Excerpt(share)); - if (rate is not null) return new("concrete rate or budget stated", Excerpt(rate)); + (PaymentLabel().IsMatch(s) || RateUnit().IsMatch(s)) && !OtherPrice().IsMatch(s) && !ApplicantFee().IsMatch(s) && !NegatedMoney().IsMatch(s)); + var unpaid = sentences.FirstOrDefault(s => Unpaid().IsMatch(s) && !NegatedUnpaid().IsMatch(s) && !(ApplicantFee().IsMatch(s) && NoFee().IsMatch(s))); + var share = sentences.FirstOrDefault(s => RevenueShare().IsMatch(s) && !NegatedShare().IsMatch(s)); + var promise = sentences.FirstOrDefault(s => MonetaryPromise().IsMatch(s) && !NegatedMoney().IsMatch(s) && !ApplicantFee().IsMatch(s) && (!Unpaid().IsMatch(s) || NegatedUnpaid().IsMatch(s))); + if ((rate is not null || promise is not null) && (unpaid is not null || share is not null)) + return new("mixed compensation terms; clarify what is guaranteed", Excerpt(rate ?? promise!) + " / " + Excerpt(unpaid ?? share!), CompensationKind.Mixed); + if (sentences.Any(s => NegatedMoney().IsMatch(s) && !(ApplicantFee().IsMatch(s) && NoFee().IsMatch(s)))) return new("no monetary payment stated", Excerpt(sentences.First(s => NegatedMoney().IsMatch(s) && !(ApplicantFee().IsMatch(s) && NoFee().IsMatch(s)))), CompensationKind.Unpaid); + if (unpaid is not null) return new("unpaid collaboration stated", Excerpt(unpaid), CompensationKind.Unpaid); + if (share is not null) return new("revenue-share stated", Excerpt(share), CompensationKind.RevenueShare); + if (rate is not null) return new("concrete rate or budget stated", Excerpt(rate), CompensationKind.Rate); var amount = sentences.FirstOrDefault(s => Concrete().IsMatch(s)); if (amount is not null) return new("amount mentioned; compensation unclear", Excerpt(amount)); var vague = sentences.FirstOrDefault(s => PaymentLabel().IsMatch(s)); - return vague is not null ? new("ambiguous / discuss payment terms", Excerpt(vague)) : new("no concrete rate or budget found", ""); + return vague is not null ? new("ambiguous / discuss payment terms", Excerpt(vague), CompensationKind.MonetaryPromise) : new("no concrete rate or budget found", ""); } - catch (RegexMatchTimeoutException) { return new("unknown", ""); } + catch (RegexMatchTimeoutException) { return new("unknown", "", CompensationKind.Unavailable); } } - // A labelled reference or media attachment is a sample supplied, not proof of authorship. - public static string Portfolio(string? content, IReadOnlyList? attachments = null) + public static readonly IReadOnlySet SeedPortfolioHosts = new HashSet(StringComparer.OrdinalIgnoreCase) + { "github.com", "gitlab.com", "artstation.com", "behance.net", "dribbble.com", "itch.io", "vimeo.com", "youtube.com", "youtu.be", "deviantart.com" }; + + public static PortfolioEvidence ReadPortfolio(string? content, IReadOnlyList? attachments = null, + IReadOnlySet? supportedHosts = null) { - if (attachments?.Any(a => a.ContentType?.StartsWith("image/", StringComparison.OrdinalIgnoreCase) == true || + var media = attachments?.Any(a => a.ContentType?.StartsWith("image/", StringComparison.OrdinalIgnoreCase) == true || a.ContentType?.StartsWith("video/", StringComparison.OrdinalIgnoreCase) == true || - a.ContentType?.StartsWith("audio/", StringComparison.OrdinalIgnoreCase) == true) == true) - return "media sample attached"; - if (content is null) return "unknown"; + a.ContentType?.StartsWith("audio/", StringComparison.OrdinalIgnoreCase) == true) == true; + var candidates = new List(); + if (content is null) return new(false, media, candidates); var text = Bounded(content); - var external = false; try { foreach (Match match in Links().Matches(text)) { if (!TryLink(match.Value, out var uri) || Domain(uri!.IdnHost, "discord.com") || Domain(uri.IdnHost, "discord.gg") || Domain(uri.IdnHost, "discordapp.com")) continue; - external = true; - var workHost = new[] { "github.com", "gitlab.com", "artstation.com", "behance.net", "dribbble.com", "itch.io", "vimeo.com", "youtube.com", "youtu.be", "deviantart.com" } - .Any(domain => Domain(uri.IdnHost, domain)); - var lineStart = text.LastIndexOf('\n', Math.Max(0, match.Index - 1)) + 1; - var lineEnd = text.IndexOf('\n', match.Index); - if (lineEnd < 0) lineEnd = text.Length; - var line = text[lineStart..lineEnd]; - if (!NoWork().IsMatch(line) && (workHost && uri.AbsolutePath.Length > 1 || WorkLabel().IsMatch(line))) - return "work-sample link detected"; + var start = text.LastIndexOf('\n', Math.Max(0, match.Index - 1)) + 1; + var end = text.IndexOf('\n', match.Index); if (end < 0) end = text.Length; + var line = text[start..end]; + var contact = Regex.IsMatch(uri.AbsolutePath, @"^/(?:contact|login|signin|invite|join)/?$", RegexOptions.IgnoreCase, TimeSpan.FromMilliseconds(100)); + candidates.Add(new(uri.AbsoluteUri[..Math.Min(uri.AbsoluteUri.Length, 300)], uri.IdnHost, + uri.AbsolutePath.Trim('/').Length > 0 && !contact, + (supportedHosts ?? SeedPortfolioHosts).Any(d => Domain(uri.IdnHost, d)), WorkLabel().IsMatch(line), NoWork().IsMatch(line))); + if (candidates.Count == 8) break; } - return external ? "external link found; purpose unclear" : "no work-sample link found"; + return new(true, media, candidates); + } + catch (RegexMatchTimeoutException) { return new(false, media, candidates); } + } + + public static string Portfolio(string? content, IReadOnlyList? attachments = null) + { + var facts = ReadPortfolio(content, attachments); + if (facts.MediaSample) return "media sample attached"; + if (!facts.Available) return "unknown"; + if (facts.Candidates.Any(c => !c.Negated && c.Specific && (c.Supported || c.WorkContext))) return "work-sample link detected"; + return facts.Candidates.Count > 0 ? "external link found; purpose unclear" : "no work-sample link found"; + } + + public static string? DmRequest(string? content) + { + if (content is null) return null; + try { return Sentences().Split(Bounded(content)).FirstOrDefault(s => DmFirst().IsMatch(s) && !NoPrivateTerms().IsMatch(s) && !PrivateOnly().IsMatch(s) && !SafetyQuote().IsMatch(s)) is { } s ? Excerpt(s) : ""; } + catch (RegexMatchTimeoutException) { return null; } + } + + public static string? Duration(string? content, bool forHire) + { + if (content is null) return null; + try { return Sentences().Split(Bounded(content)).FirstOrDefault(s => (ProjectDuration().IsMatch(s) || forHire && Availability().IsMatch(s)) && !NoDuration().IsMatch(s)) is { } s ? Excerpt(s) : ""; } + catch (RegexMatchTimeoutException) { return null; } + } + + public static int? NormalizedLength(string? content) + { + if (content is null) return null; + try + { + var text = Links().Replace(Bounded(content), " "); + text = Regex.Replace(text, @"^[ \t+*=\-]{3,}$|^[ \t]*[-+][ \t]+", " ", RegexOptions.Multiline, TimeSpan.FromMilliseconds(100)); + text = Regex.Replace(text, @"<[^>]{0,200}>|[*_~`#>\[\]()|\\]", " ", RegexOptions.None, TimeSpan.FromMilliseconds(100)); + return string.Join(' ', text.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries)).Length; } - catch (RegexMatchTimeoutException) { return "unknown"; } + catch (RegexMatchTimeoutException) { return null; } } public static List Observations(string? content) @@ -79,11 +121,13 @@ void Add(string code, string description, Func match) var evidence = sentences.FirstOrDefault(match); if (evidence is not null) observations.Add(new(code, description, Excerpt(evidence))); } - Add("private-terms", "Post directs readers to private messages for terms", s => PrivateTerms().IsMatch(s) && !NoPrivateTerms().IsMatch(s)); - Add("applicant-fee", "Post mentions an applicant payment or fee", s => ApplicantFee().IsMatch(s) && !NoFee().IsMatch(s)); - Add("payment-pressure", "Post combines payment with urgency", s => Urgency().IsMatch(s) && PaymentAction().IsMatch(s) && !NoPressure().IsMatch(s)); + Add("private-terms", "Post directs readers to private messages for terms", s => PrivateTerms().IsMatch(s) && !NoPrivateTerms().IsMatch(s) && !SafetyQuote().IsMatch(s)); + Add("applicant-fee", "Post mentions an applicant payment or fee", s => ApplicantFee().IsMatch(s) && !NoFee().IsMatch(s) && !SafetyQuote().IsMatch(s)); + Add("payment-pressure", "Post combines payment with urgency", s => Urgency().IsMatch(s) && PaymentAction().IsMatch(s) && !NoPressure().IsMatch(s) && !SafetyQuote().IsMatch(s)); } catch (RegexMatchTimeoutException) { /* Observations are optional, never enforcement evidence. */ } + var payment = ReadPayment(content); + if (payment.Kind == CompensationKind.Mixed) observations.Add(new("mixed-compensation", "Mixed compensation terms; clarify what is guaranteed", payment.Evidence[..Math.Min(payment.Evidence.Length, 180)])); return observations; } @@ -124,7 +168,7 @@ public static List ContactLinks(string? content) private static partial Regex OtherPrice(); [GeneratedRegex(@"\b(?:rev(?:enue)?[ -]?share|royalt(?:y|ies)|profit[ -]?share)\b", RegexOptions.IgnoreCase, 100)] private static partial Regex RevenueShare(); - [GeneratedRegex(@"\b(?:unpaid|volunteer|not paid|no (?:pay|payment|compensation))\b", RegexOptions.IgnoreCase, 100)] + [GeneratedRegex(@"\b(?:unpaid|volunteer|not paid|no (?:pay|payment|compensation)|(?:paid? (?:in|with) exposure|for exposure|exposure (?:only|instead of (?:pay|payment))|compensation.{0,15}exposure))\b", RegexOptions.IgnoreCase, 100)] private static partial Regex Unpaid(); [GeneratedRegex(@"https?://[^\s<>\[\]()]+", RegexOptions.IgnoreCase, 100)] private static partial Regex Links(); @@ -132,9 +176,9 @@ public static List ContactLinks(string? content) private static partial Regex WorkLabel(); [GeneratedRegex(@"\b(?:no|without|don't have|do not have|not (?:my|our|a))\b.{0,35}\b(?:portfolio|samples?|work|demo|showreel)\b|\b(?:portfolio|samples?|work|demo|showreel)\b.{0,25}\b(?:unavailable|not available|coming soon)\b", RegexOptions.IgnoreCase, 100)] private static partial Regex NoWork(); - [GeneratedRegex(@"\b(?:dm|pm|private messages?)\b.{0,35}\b(?:rates?|budget|pay|payment|terms|details)\b|\b(?:rates?|budget|payment|terms|details)\b.{0,35}\b(?:in|via|by|only)\s+(?:dms?|pms?|private messages?)\b", RegexOptions.IgnoreCase, 100)] + [GeneratedRegex(@"\b(?:dms?|pms?|private messages?)\b.{0,35}\b(?:rates?|budget|pay|payment|terms|details)\b|\b(?:rates?|budget|payment|terms|details)\b.{0,35}\b(?:in|via|by|only)\s+(?:dms?|pms?|private messages?)\b", RegexOptions.IgnoreCase, 100)] private static partial Regex PrivateTerms(); - [GeneratedRegex(@"\b(?:no|never|don't|do not)\b.{0,20}\b(?:dm|pm|private messages?)\b", RegexOptions.IgnoreCase, 100)] + [GeneratedRegex(@"\b(?:no|never|don't|do not)\b.{0,20}\b(?:dms?|pms?|private messages?)\b", RegexOptions.IgnoreCase, 100)] private static partial Regex NoPrivateTerms(); [GeneratedRegex(@"\b(?:application|applicant|registration|onboarding|training|verification)\s+(?:fees?|deposit|payment)\b|\bpay\b.{0,30}\b(?:to apply|to (?:start|join)|before (?:starting|joining))\b", RegexOptions.IgnoreCase, 100)] private static partial Regex ApplicantFee(); @@ -146,6 +190,26 @@ public static List ContactLinks(string? content) private static partial Regex PaymentAction(); [GeneratedRegex(@"\b(?:never|don't|do not|avoid)\b.{0,40}\b(?:pay|send|transfer|deposit)\b", RegexOptions.IgnoreCase, 100)] private static partial Regex NoPressure(); + [GeneratedRegex(@"\b(?:do not|don't|never|cannot|can't|won't|will not|no)\s+(?:offer\s+)?(?:pay|payment|budget|salary|compensation)\b|\bnot paid\b", RegexOptions.IgnoreCase, 100)] + private static partial Regex NegatedMoney(); + [GeneratedRegex(@"\b(?:paid|pay|paying|payment|salary|wages|monetary compensation|guaranteed compensation)\b", RegexOptions.IgnoreCase, 100)] + private static partial Regex MonetaryPromise(); + [GeneratedRegex(@"\b(?:not (?:an? )?unpaid|not (?:an? )?volunteer|no unpaid|no volunteer|not (?:for |paid in )?exposure|no exposure|never unpaid)\b", RegexOptions.IgnoreCase, 100)] + private static partial Regex NegatedUnpaid(); + [GeneratedRegex(@"\b(?:no|not|without|never)\s+(?:a )?(?:rev(?:enue)?[ -]?share|royalt(?:y|ies)|profit[ -]?share)\b", RegexOptions.IgnoreCase, 100)] + private static partial Regex NegatedShare(); + [GeneratedRegex(@"\b(?:dms?|pms?|message me privately|private message)\b.{0,45}\b(?:apply|first|me|us|rates?|terms|details|interested|portfolio|contact)\b|\b(?:apply|contact|applications?)\b.{0,25}\b(?:dm|pm|dms|pms|private messages?)\b", RegexOptions.IgnoreCase, 100)] + private static partial Regex DmFirst(); + [GeneratedRegex(@"\b(?:only|solely)\b.{0,35}\b(?:private|personal|sensitive|confidential)\b|\b(?:dms?|pms?|private messages?)\b.{0,35}\b(?:private|personal|sensitive|confidential)\s+(?:information|details|data)\b", RegexOptions.IgnoreCase, 100)] + private static partial Regex PrivateOnly(); + [GeneratedRegex(@"\b(?:beware|red flag|example of (?:a scam|unsafe)|warning|safety advice|scammers? (?:say|ask|request)|avoid (?:posts|people|anyone|requests))\b", RegexOptions.IgnoreCase, 100)] + private static partial Regex SafetyQuote(); + [GeneratedRegex(@"\b(?:duration|project length)\s*[:=]\s*(?:\d+|one|two|three|four|six|twelve)\s+(?:days?|weeks?|months?|years?)\b|\b(?:\d+|one|two|three|four|six|twelve)[ -](?:day|week|month|year)\s+(?:project|contract|engagement)\b|\b(?:project|contract|engagement|work|role|job)\s+(?:(?:will |is |expected to )?(?:last|run|take|for|length|duration)(?:s)?[: ]*|duration[: ]+)(?:about |approximately )?(?:\d+|one|two|three|four|six|twelve)\s*(?:days?|weeks?|months?|years?)\b|\b(?:deliver|complete|finish|completion)\b.{0,20}\b(?:in|within)\s+(?:\d+|one|two|three|four|six|twelve)\s+(?:days?|weeks?|months?)\b|\b(?:ongoing|permanent|long[ -]term)\s+(?:work|role|position|contract|project|engagement)\b|\b(?:work|role|position|contract|project)\s+is\s+(?:ongoing|permanent)\b", RegexOptions.IgnoreCase, 100)] + private static partial Regex ProjectDuration(); + [GeneratedRegex(@"\b(?:not|no|never|unavailable|not available)\b.{0,20}\b(?:ongoing|permanent|available|long[ -]term|short[ -]term)\b|\b(?:duration|project length)\s*[:=]\s*(?:unknown|unclear|not)\b", RegexOptions.IgnoreCase, 100)] + private static partial Regex NoDuration(); + [GeneratedRegex(@"\bavailable\s+(?:for\s+(?:ongoing|short[ -]term|long[ -]term|permanent)\s+(?:work|contracts?|projects?|engagements?)|(?:from|starting|until)\s+(?:\d{4}-\d{2}-\d{2}|(?:January|February|March|April|May|June|July|August|September|October|November|December)\b))", RegexOptions.IgnoreCase, 100)] + private static partial Regex Availability(); [GeneratedRegex(@"(?<=[.!?])\s+|[\r\n;]+", RegexOptions.None, 100)] private static partial Regex Sentences(); } diff --git a/DiscordBot/Services/Recruitment/RecruitmentEvidence.cs b/DiscordBot/Services/Recruitment/RecruitmentEvidence.cs index c9fda600..3b39bc4c 100644 --- a/DiscordBot/Services/Recruitment/RecruitmentEvidence.cs +++ b/DiscordBot/Services/Recruitment/RecruitmentEvidence.cs @@ -8,6 +8,14 @@ public sealed partial class RecruitmentEngine private void RefreshFacts(Listing post, ThreadSnapshot live) { var payment = RecruitmentContent.ReadPayment(live.Starter); + var purpose = State!.Forums.First(f => f.Value.ForumId == post.ForumId).Key; + post.Facts.Checks = RecruitmentSignals.Evaluate(live, purpose); + post.Facts.PortfolioCandidates = RecruitmentContent.ReadPortfolio(live.Starter, live.Attachments); + post.Facts.ServerNickname = RecruitmentSignals.SafeIdentity(live.ServerNickname); + post.Facts.GlobalName = RecruitmentSignals.SafeIdentity(live.GlobalName); + post.Facts.Username = RecruitmentSignals.SafeIdentity(live.Username); + post.Facts.AvatarUrl = RecruitmentSignals.SafeAvatarUrl(live.AvatarUrl); + post.Facts.AvatarIdentity = RecruitmentSignals.SafeIdentity(live.AvatarIdentity, 200); post.Facts.AccountCreatedAt = live.AccountCreatedAt; post.Facts.JoinedAt = live.JoinedAt; post.Facts.MemberRoleCount = live.MemberRoleCount; post.Facts.HasCustomAvatar = live.HasCustomAvatar; post.Facts.Payment = payment.Summary; post.Facts.PaymentEvidence = payment.Evidence; diff --git a/DiscordBot/Services/Recruitment/RecruitmentPresentation.cs b/DiscordBot/Services/Recruitment/RecruitmentPresentation.cs index 066050b2..a7c0041b 100644 --- a/DiscordBot/Services/Recruitment/RecruitmentPresentation.cs +++ b/DiscordBot/Services/Recruitment/RecruitmentPresentation.cs @@ -10,31 +10,13 @@ public static class RecruitmentPresentation public static RecruitmentMessage Public(RecruitmentState state, Listing post, RecruitmentSettings settings, DateTimeOffset now) { - var purpose = state.Forums.First(f => f.Value.ForumId == post.ForumId).Key; var lines = new List(); - var accountAge = post.Facts.AccountCreatedAt is { } created ? Age(post.CreatedAt - created) : "unavailable"; - var membership = post.Facts.JoinedAt is { } joined ? joined > post.CreatedAt ? "joined/rejoined after posting" : Age(post.CreatedAt - joined) : "unavailable"; - lines.Add($"At posting · account age: **{accountAge}** · in this server: **{membership}**."); - var notes = new List(); - if (post.Facts.Payment == "unknown") notes.Add("Starter text is unavailable; payment details could not be checked."); - else if (purpose.StartsWith("paid-") && post.Facts.Payment != "concrete rate or budget stated" || - post.Facts.Payment.StartsWith("mixed") || post.Facts.Payment == "revenue-share stated") - { - var summary = post.Facts.Payment switch - { - "unpaid collaboration stated" => "Unpaid terms appear in a paid forum", - "revenue-share stated" => "Revenue share is stated; guaranteed payment is unclear", - "mixed compensation terms; clarify what is guaranteed" => "Mixed compensation terms; clarify what is guaranteed", - _ => "A clear compensation rate or budget was not found" - }; - notes.Add(summary + (post.Facts.PaymentEvidence.Length > 0 ? $": “{Safe(post.Facts.PaymentEvidence, 110)}”." : ".")); - } - if (purpose.EndsWith("for-hire") && post.Facts.Portfolio is not ("work-sample link detected" or "media sample attached")) - notes.Add(post.Facts.Portfolio == "unknown" ? "Work-sample information is unavailable." : - post.Facts.Portfolio == "external link found; purpose unclear" ? "A link is supplied; its purpose as a work sample is unclear." : "No portfolio link or attached media sample was found."); - notes.AddRange(post.Facts.Observations.Take(3).Select(o => $"{o.Description}: “{Safe(o.Evidence, 110)}”.")); - if (notes.Count > 0) lines.Add("\n**Things to check**\n" + string.Join("\n", notes.Select(n => "• " + n))); + lines.Add(string.Join("\n", post.Facts.Checks.Select(c => $"**{c.Label}:** {Safe(c.Value, 60)} ({c.Applicability switch { ObservationApplicability.Unavailable => "unavailable", ObservationApplicability.NotApplicable => "not applicable", _ => c.Severity.ToString().ToLowerInvariant() }})"))); + var notes = post.Facts.Observations.Select(o => $"• {o.Description}: “{Safe(o.Evidence, 70)}”.").ToList(); + if (notes.Count > 0) lines.Add("\n**Things to check**\n" + string.Join("\n", notes)); lines.Add("\nClarify scope, work samples and payment terms here before moving to DMs."); + var identity = post.Facts.ServerNickname ?? post.Facts.GlobalName ?? post.Facts.Username ?? "Unavailable"; + lines.Add($"Author: {Safe(identity, 100)} · {Safe(post.Facts.Username ?? "username unavailable", 100)} · {post.OwnerId}"); var needsAck = post.Status == ListingStatus.Open && !post.AcceptedAt.HasValue && !post.AcknowledgedAt.HasValue; if (needsAck) lines.Add("\n**Author:** Read this forum's Guidelines and enter its code with **Acknowledge**." + @@ -43,7 +25,8 @@ public static RecruitmentMessage Public(RecruitmentState state, Listing post, Re if (post.Hold is not null || post.AvailabilityUnknown) lines.Add("\nBot processing is paused; listing details may be incomplete."); var embed = new EmbedBuilder().WithTitle(post.Status == ListingStatus.Closed ? "Recruitment · closed" : "Recruitment · reader notes") .WithDescription(string.Join("\n", lines)).WithColor(new Color(0x747F8D)) - .WithFooter("Automated observations; identity and work are unverified.").Build(); + .WithFooter("Automated observations; identity and work are unverified.") + .WithThumbnailUrl(post.Facts.AvatarUrl).Build(); var marker = $"recruitment:{post.Id}:public"; var fallback = $"**{embed.Title}**\n{embed.Description}\n{embed.Footer?.Text}\n||{marker}||"; return new RecruitmentMessage($"||{marker}||", marker, true, Embed: embed, TextFallback: fallback, @@ -52,22 +35,9 @@ public static RecruitmentMessage Public(RecruitmentState state, Listing post, Re public static string Details(Listing post) { - var facts = post.Facts; - return "**Listing details**\n" + - $"Account created: {(facts.AccountCreatedAt is { } created ? Timestamp(created) : "unavailable")}\n" + - $"Joined this server: {(facts.JoinedAt is { } joined ? Timestamp(joined) : "unavailable")}\n" + - $"Profile image: {facts.HasCustomAvatar switch { true => "custom global or server avatar", false => "default Discord avatar", _ => "unavailable" }}\n" + - $"Server roles (excluding everyone): {facts.MemberRoleCount?.ToString() ?? "unavailable"}. Roles and avatars do not establish trust.\n" + - $"Compensation: {Safe(facts.Payment)}" + (facts.PaymentEvidence.Length > 0 ? $" · “{Safe(facts.PaymentEvidence, 370)}”" : "") + "\n" + - $"Work samples: {Safe(facts.Portfolio)}. Links and attachments are not verified.\n{Safe(facts.TagAdvice)}\n" + - "Observations use the starter post and available membership data. Missing data is not evidence against the author. Reports go privately to moderators; the bot cannot inspect other people's DMs."; - } - - private static string Age(TimeSpan age) - { - var days = Math.Max(0, (int)age.TotalDays); - if (days >= 365) return $"{days / 365} {(days / 365 == 1 ? "year" : "years")}"; - return days == 0 ? "under 1 day" : $"{days} {(days == 1 ? "day" : "days")}"; + return "**Listing details**\n" + string.Join("\n", post.Facts.Checks.Select(c => + $"{c.Label}: {c.Value}. {c.Applicability} / {c.Severity}." + (string.IsNullOrEmpty(c.Evidence) ? "" : $" {Safe(c.Evidence, 370)}"))) + + "\nObservations use starter and membership data; unavailable data is not evidence against the author. Roles, avatars, links and length do not establish trust or authorship. Reports go privately to moderators."; } public static RecruitmentMessage Staff(RecruitmentState state, Listing post, RecruitmentSettings settings) diff --git a/DiscordBot/Services/Recruitment/RecruitmentService.cs b/DiscordBot/Services/Recruitment/RecruitmentService.cs index 82d41239..8ebfea5f 100644 --- a/DiscordBot/Services/Recruitment/RecruitmentService.cs +++ b/DiscordBot/Services/Recruitment/RecruitmentService.cs @@ -280,14 +280,14 @@ private Task RoleUpdated(SocketRole before, SocketRole after) private Task RoleDeleted(SocketRole role) { if (role.Guild.Id == _settings.GuildId) ScheduleRecovery(); return Task.CompletedTask; } private Task UserUpdated(SocketUser before, SocketUser after) { - if (before.AvatarId != after.AvatarId && Volatile.Read(ref _listingOwners).TryGetValue(after.Id.ToString(), out var threads)) + if ((before.AvatarId != after.AvatarId || before.Username != after.Username || before.GlobalName != after.GlobalName) && Volatile.Read(ref _listingOwners).TryGetValue(after.Id.ToString(), out var threads)) foreach (var id in threads) NotifyFactsChanged(id); return Task.CompletedTask; } private Task MemberUpdated(Cacheable before, SocketGuildUser after) { - if (after.Guild.Id != _settings.GuildId || before.HasValue && before.Value.Roles.Select(r => r.Id).SequenceEqual(after.Roles.Select(r => r.Id)) && before.Value.DisplayAvatarId == after.DisplayAvatarId) return Task.CompletedTask; + if (after.Guild.Id != _settings.GuildId || before.HasValue && before.Value.Roles.Select(r => r.Id).SequenceEqual(after.Roles.Select(r => r.Id)) && before.Value.DisplayAvatarId == after.DisplayAvatarId && before.Value.Nickname == after.Nickname && before.Value.GlobalName == after.GlobalName && before.Value.Username == after.Username) return Task.CompletedTask; if (after.Id == _client?.CurrentUser.Id) ScheduleRecovery(); else if (Volatile.Read(ref _listingOwners).TryGetValue(after.Id.ToString(), out var threads)) foreach (var id in threads) NotifyFactsChanged(id); diff --git a/DiscordBot/Services/Recruitment/RecruitmentSignals.cs b/DiscordBot/Services/Recruitment/RecruitmentSignals.cs new file mode 100644 index 00000000..9c05c408 --- /dev/null +++ b/DiscordBot/Services/Recruitment/RecruitmentSignals.cs @@ -0,0 +1,83 @@ +namespace DiscordBot.Services.Recruitment; + +public enum ObservationSeverity { Grey, Green, Yellow, Red } +public enum ObservationApplicability { Available, Unavailable, NotApplicable } +public sealed record RecruitmentObservation(string Id, string Label, ObservationSeverity Severity, + ObservationApplicability Applicability, string Value, string? Evidence = null); + +/// Factual advisory signals only. No result is an eligibility or enforcement input. +public static class RecruitmentSignals +{ + public static List Evaluate(ThreadSnapshot live, string purpose, IReadOnlySet? supportedHosts = null) + { + var paid = purpose.StartsWith("paid-", StringComparison.Ordinal); + var hire = purpose.EndsWith("for-hire", StringComparison.Ordinal); + var result = new List(); + void Add(string id, string label, ObservationSeverity color, string value, string? evidence = null, + ObservationApplicability applicability = ObservationApplicability.Available) => result.Add(new(id, label, color, applicability, value, evidence)); + void Unknown(string id, string label) => Add(id, label, ObservationSeverity.Grey, "Unavailable", applicability: ObservationApplicability.Unavailable); + void Na(string id, string label) => Add(id, label, ObservationSeverity.Grey, "Not Applicable", applicability: ObservationApplicability.NotApplicable); + void Age(string id, string label, DateTimeOffset? date, int highMonths, int lowMonths, bool join = false) + { + if (date is not { } start) { Unknown(id, label); return; } + var posting = live.CreatedAt.UtcDateTime; + var origin = start.UtcDateTime; + if (origin > posting) + { + if (join) Add(id, label, ObservationSeverity.Grey, "Rejoined after posting", applicability: ObservationApplicability.Unavailable); + else Unknown(id, label); + return; + } + var high = origin.AddMonths(highMonths); var low = origin.AddMonths(lowMonths); + Add(id, label, posting >= high ? ObservationSeverity.Green : posting >= low ? ObservationSeverity.Yellow : ObservationSeverity.Red, + posting >= high ? $"At least {highMonths / 12} year{(highMonths == 12 ? "" : "s")}" : posting >= low ? $"At least {lowMonths} months" : $"Under {lowMonths} months"); + } + Age("account-age", "Account Age", live.AccountCreatedAt, 24, 6); + Age("join-date", "Join Date", live.JoinedAt, 12, 2, true); + if (live.MemberRoleCount is not { } roles || roles < 0) Unknown("server-roles", "Server Roles"); + else Add("server-roles", "Server Roles", roles > 0 ? ObservationSeverity.Green : ObservationSeverity.Red, $"{roles} Role{(roles == 1 ? "" : "s")}"); + var payment = RecruitmentContent.ReadPayment(live.Starter); + var compensationRed = payment.Kind is CompensationKind.Missing or CompensationKind.Unpaid or CompensationKind.RevenueShare; + if (!paid) Na("compensation", "Compensation"); + else if (payment.Kind == CompensationKind.Unavailable) Unknown("compensation", "Compensation"); + else Add("compensation", "Compensation", payment.Kind == CompensationKind.Rate ? ObservationSeverity.Green : compensationRed ? ObservationSeverity.Red : ObservationSeverity.Yellow, + payment.Kind switch { CompensationKind.Rate => "Rate Stated", CompensationKind.Mixed => "Mixed Terms", CompensationKind.MonetaryPromise => "Rate Unclear", CompensationKind.Unpaid => "Unpaid / Exposure", CompensationKind.RevenueShare => "Revenue Share Only", _ => "No Monetary Pay Found" }, payment.Evidence); + var duration = RecruitmentContent.Duration(live.Starter, hire); + if (duration is null) Unknown("duration", "Duration"); + else Add("duration", "Duration", duration.Length > 0 ? ObservationSeverity.Green : paid && compensationRed ? ObservationSeverity.Red : ObservationSeverity.Yellow, + duration.Length > 0 ? "Window Stated" : "Timing Unclear", duration); + var portfolio = RecruitmentContent.ReadPortfolio(live.Starter, live.Attachments, supportedHosts); + if (!hire) Na("portfolio", "Portfolio"); + else if (!portfolio.Available) Unknown("portfolio", "Portfolio"); + else + { + var supported = portfolio.Candidates.FirstOrDefault(c => c.Specific && c.Supported && !c.Negated); + var any = portfolio.Candidates.Any(c => !c.Negated && !IsContactOnly(c.Url)); + Add("portfolio", "Portfolio", supported is not null ? ObservationSeverity.Green : any || portfolio.MediaSample || !paid ? ObservationSeverity.Yellow : ObservationSeverity.Red, + supported is not null ? "Platform Link" : portfolio.MediaSample ? "Samples Attached" : any ? "External Link" : "No Samples Found", supported?.Url); + } + var length = RecruitmentContent.NormalizedLength(live.Starter); + var dm = RecruitmentContent.DmRequest(live.Starter); + if (dm is null) Unknown("dm-requests", "DM Requests"); + else Add("dm-requests", "DM Requests", dm.Length == 0 ? ObservationSeverity.Green : length < 120 ? ObservationSeverity.Red : ObservationSeverity.Yellow, + dm.Length == 0 ? "No DM-first Request" : "DM-first Request", dm); + if (live.HasCustomAvatar is not { } avatar) Unknown("profile-image", "Profile Image"); + else Add("profile-image", "Profile Image", avatar ? ObservationSeverity.Green : ObservationSeverity.Yellow, avatar ? "Custom Avatar" : "Default Avatar"); + if (length is null) Unknown("post-detail", "Post Detail"); + else Add("post-detail", "Post Detail", length >= 300 ? ObservationSeverity.Green : length >= 120 ? ObservationSeverity.Yellow : ObservationSeverity.Red, + $"{length} Characters", "Normalized body length is a heuristic, not proof of completeness or quality."); + return result; + } + + private static bool IsContactOnly(string url) => Uri.TryCreate(url, UriKind.Absolute, out var uri) && + new[] { "/contact", "/login", "/signin", "/invite", "/join" }.Contains(uri.AbsolutePath.TrimEnd('/').ToLowerInvariant()); + + public static string? SafeIdentity(string? value, int max = 100) + { + if (string.IsNullOrWhiteSpace(value)) return null; + var safe = RecruitmentPresentation.Safe(value, max).Trim(); + return safe.Length == 0 ? null : safe; + } + public static string? SafeAvatarUrl(string? value) => Uri.TryCreate(value, UriKind.Absolute, out var uri) && uri.Scheme == "https" && + uri.Host is "cdn.discordapp.com" or "media.discordapp.net" && value.Length <= 500 ? value : null; +} diff --git a/DiscordBot/Services/Recruitment/RecruitmentState.cs b/DiscordBot/Services/Recruitment/RecruitmentState.cs index 1b651ec5..5d78086c 100644 --- a/DiscordBot/Services/Recruitment/RecruitmentState.cs +++ b/DiscordBot/Services/Recruitment/RecruitmentState.cs @@ -166,6 +166,13 @@ public sealed class InitialFacts public string Portfolio { get; set; } = "unknown"; public bool? HasCustomAvatar { get; set; } public string PaymentEvidence { get; set; } = ""; + public string? ServerNickname { get; set; } + public string? GlobalName { get; set; } + public string? Username { get; set; } + public string? AvatarUrl { get; set; } + public string? AvatarIdentity { get; set; } + public List Checks { get; set; } = new(); + public PortfolioEvidence PortfolioCandidates { get; set; } = new(false, false, Array.Empty()); public List Observations { get; set; } = new(); } diff --git a/DiscordBot/Services/Recruitment/RecruitmentStateStore.cs b/DiscordBot/Services/Recruitment/RecruitmentStateStore.cs index fd3e6b3e..1f582026 100644 --- a/DiscordBot/Services/Recruitment/RecruitmentStateStore.cs +++ b/DiscordBot/Services/Recruitment/RecruitmentStateStore.cs @@ -121,7 +121,7 @@ state.Forums is null || state.Listings is null || state.Authors is null || state p.CreatedAt == default || p.CreatedAt.Offset != TimeSpan.Zero || p.UpdatedAt == default || !Enum.IsDefined(p.Status) || p.ValidCodes is null || p.Facts is null || p.Audit is null || !state.Authors.ContainsKey(p.OwnerId) || p.Facts.Payment is null || p.Facts.Portfolio is null || - p.Facts.Observations is null || p.Facts.Observations.Count > 3 || + !FactsValid(p.Facts) || p.Facts.Observations is null || p.Facts.Observations.Count > 4 || p.Facts.Observations.Any(o => o is null || o.Code is null || o.Description is null || o.Evidence is null || o.Evidence.Length > 180) || p.Facts.PaymentEvidence is null || p.Facts.PaymentEvidence.Length > 370 || p.EarlierMaterialVersions is null || p.EarlierMaterialVersions.Count > 3 || @@ -139,6 +139,16 @@ p.EarlierMaterialVersions is null || p.EarlierMaterialVersions.Count > 3 || throw new InvalidDataException("Recruitment author history is invalid."); } + private static bool FactsValid(InitialFacts f) => + f.ServerNickname is null or { Length: <= 100 } && f.GlobalName is null or { Length: <= 100 } && + f.Username is null or { Length: <= 100 } && f.AvatarIdentity is null or { Length: <= 200 } && + (f.AvatarUrl is null || RecruitmentSignals.SafeAvatarUrl(f.AvatarUrl) == f.AvatarUrl) && + f.Checks is { Count: <= 9 } && f.Checks.Select(c => c?.Id).Distinct().Count() == f.Checks.Count && + f.Checks.All(c => c is not null && c.Id is { Length: <= 30 } && c.Label is { Length: <= 30 } && c.Value is { Length: <= 100 } && + c.Evidence is null or { Length: <= 370 } && Enum.IsDefined(c.Severity) && Enum.IsDefined(c.Applicability)) && + f.PortfolioCandidates is { Candidates.Count: <= 8 } && f.PortfolioCandidates.Candidates.All(c => + c is not null && c.Url is { Length: <= 300 } && c.Host is { Length: <= 253 }); + private static bool VersionValid(StarterVersion? version) => version is null || version.ObservedAt != default && version.Content is { Length: <= 2000 } && version.Fingerprint is { Length: 64 } && (version.TermsFingerprint is null || version.TermsFingerprint.Length == 64) && From 8a90fac1f609029e2c25ba685575044f3f8886d8 Mon Sep 17 00:00:00 2001 From: James Kellie Date: Sat, 17 Oct 2026 14:00:18 +1000 Subject: [PATCH 18/31] Manage portfolio sites and preserve private URL identities --- .../Recruitment/RecruitmentContentTests.cs | 6 +- .../Recruitment/RecruitmentPortfolioTests.cs | 310 + .../Recruitment/RecruitmentRecoveryTests.cs | 3 +- .../Recruitment/RecruitmentTestRig.cs | 2 + .../recruitment/public-suffix-icann.dat | 11245 ++++++++++++++++ DiscordBot/DiscordBot.csproj | 1 + .../Modules/Recruitment/RecruitmentModule.cs | 9 + .../Recruitment/DiscordRecruitmentAdapter.cs | 7 + .../Recruitment/IRecruitmentDiscord.cs | 1 + .../Recruitment/RecruitmentContent.cs | 25 +- .../Services/Recruitment/RecruitmentEngine.cs | 5 +- .../RecruitmentEngineScheduling.cs | 1 + .../Recruitment/RecruitmentEvidence.cs | 14 +- .../RecruitmentPortfolioRegistry.cs | 98 + .../Recruitment/RecruitmentPortfolioUrls.cs | 77 + .../Services/Recruitment/RecruitmentState.cs | 2 + .../Recruitment/RecruitmentStateStore.cs | 10 +- docs/recruitment.md | 27 +- 18 files changed, 11812 insertions(+), 31 deletions(-) create mode 100644 DiscordBot.Tests/Recruitment/RecruitmentPortfolioTests.cs create mode 100644 DiscordBot/Assets/recruitment/public-suffix-icann.dat create mode 100644 DiscordBot/Services/Recruitment/RecruitmentPortfolioRegistry.cs create mode 100644 DiscordBot/Services/Recruitment/RecruitmentPortfolioUrls.cs diff --git a/DiscordBot.Tests/Recruitment/RecruitmentContentTests.cs b/DiscordBot.Tests/Recruitment/RecruitmentContentTests.cs index 3ef18b42..24e77125 100644 --- a/DiscordBot.Tests/Recruitment/RecruitmentContentTests.cs +++ b/DiscordBot.Tests/Recruitment/RecruitmentContentTests.cs @@ -20,8 +20,8 @@ public void PaymentNeedsCompensationContextAndPreservesConflicts(string content, } [Theory] - [InlineData("No portfolio available; apply at https://example.org/contact", "external link found; purpose unclear")] - [InlineData("Portfolio coming soon: https://example.org/contact", "external link found; purpose unclear")] + [InlineData("No portfolio available; apply at https://example.org/contact", "no work-sample link found")] + [InlineData("Portfolio coming soon: https://example.org/contact", "no work-sample link found")] [InlineData("Not my work: https://artstation.com/artist", "external link found; purpose unclear")] [InlineData("My portfolio: https://example.org/work", "work-sample link detected")] public void NegatedWorkClaimsDoNotClearSampleCheck(string content, string expected) => Assert.Equal(expected, RecruitmentContent.Portfolio(content)); @@ -65,7 +65,7 @@ public void DmToApplyIsPublicFirstClarificationWithoutInventingPaymentPressure() public void HostRootsAndOverlongContentDoNotCreateBroadContactMatches() { Assert.Empty(RecruitmentContent.ContactLinks("https://github.com https://example.org/")); - Assert.Equal(new[] { "https://example.org/my-project" }, RecruitmentContent.ContactLinks("https://example.org/my-project?tracking=one https://example.org/my-project?tracking=two")); + Assert.Equal(new[] { "https://example.org/my-project" }, RecruitmentContent.ContactLinks("https://example.org/my-project?utm_source=one https://example.org/my-project?utm_source=two")); Assert.Empty(RecruitmentContent.Observations(new string('a', 8000) + " DM for rates")); } } diff --git a/DiscordBot.Tests/Recruitment/RecruitmentPortfolioTests.cs b/DiscordBot.Tests/Recruitment/RecruitmentPortfolioTests.cs new file mode 100644 index 00000000..286786ad --- /dev/null +++ b/DiscordBot.Tests/Recruitment/RecruitmentPortfolioTests.cs @@ -0,0 +1,310 @@ +using Discord; +using Discord.Interactions; +using Discord.WebSocket; +using DiscordBot.Modules.Recruitment; +using DiscordBot.Services.Recruitment; +using DiscordBot.Settings; +using Microsoft.Extensions.DependencyInjection; +using System.Reflection; + +namespace DiscordBot.Tests.Recruitment; + +public class RecruitmentPortfolioTests +{ + [Theory] + [InlineData("github.com", "github.com")] + [InlineData(" ARTIST.CO.UK ", "artist.co.uk")] + [InlineData("itch.io", "itch.io")] + [InlineData("creator.itch.io", "creator.itch.io")] + [InlineData("bar.foo.ck", "bar.foo.ck")] + [InlineData("www.ck", "www.ck")] + [InlineData("city.kawasaki.jp", "city.kawasaki.jp")] + [InlineData("bücher.de", "xn--bcher-kva.de")] + public void HostnamesNormalizeBelowActualIcannSuffixes(string input, string expected) + { + Assert.True(RecruitmentPortfolioDomains.TryNormalize(input, out var result)); Assert.Equal(expected, result); + } + + [Theory] + [InlineData("com")] + [InlineData("co.uk")] + [InlineData("com.au")] + [InlineData("k12.ak.us")] + [InlineData("foo.ck")] + [InlineData("foo.kawasaki.jp")] + [InlineData("https://github.com")] + [InlineData("github.com/foo")] + [InlineData("github.com?foo=1")] + [InlineData("user@github.com")] + [InlineData("github.com:443")] + [InlineData("*.github.com")] + [InlineData("127.0.0.1")] + [InlineData("[::1]")] + [InlineData("localhost")] + [InlineData("artist.local")] + [InlineData("artist.invalid")] + [InlineData("a..com")] + [InlineData("-a.com")] + [InlineData("a_.com")] + public void RejectsUrlsAndRegistryBoundaries(string input) => Assert.False(RecruitmentPortfolioDomains.TryNormalize(input, out _)); + + [Theory] + [InlineData("200", "1")] + [InlineData("900", "1")] + [InlineData("901", "2")] + public async Task EngineRequiresCurrentConfiguredGuildAdministrator(string actor, string guild) + { + using var rig = new RecruitmentRig(); await rig.Start(); + var before = rig.State.PortfolioSites.Version; + Assert.Contains("Administrator", await rig.Engine.PortfolioSitesAsync(guild, actor, "add", "example.org", "Reason", default)); + Assert.Contains("Administrator", await rig.Engine.PortfolioSitesAsync(guild, actor, "list", "", "", default)); + Assert.Equal(before, rig.State.PortfolioSites.Version); Assert.Empty(rig.State.PortfolioSites.Audit); + rig.Discord.Administrators.Remove("901"); + Assert.Contains("Administrator", await rig.Engine.PortfolioSitesAsync("1", "901", "add", "example.org", "Reason", default)); + } + + [Fact] + public async Task RegistryIsAuditedIdempotentPersistentAndDoesNotWriteSettings() + { + using var rig = new RecruitmentRig(); await rig.Start(); + var settingsPath = Path.Combine(rig.Root, "Settings.json"); File.WriteAllText(settingsPath, "settings sentinel"); + var original = rig.State.PortfolioSites.Version; + Assert.Contains("reason", await Sites(rig, "add", "example.org", " ")); + Assert.Contains("DNS hostname", await Sites(rig, "add", "https://example.org")); + Assert.Contains("added", await Sites(rig, "add", "Example.ORG")); + Assert.Contains("already configured", await Sites(rig, "add", "example.org")); + Assert.Equal(original + 1, rig.State.PortfolioSites.Version); + var audit = Assert.Single(rig.State.PortfolioSites.Audit); Assert.Equal("901", audit.Actor); Assert.Contains("add example.org", audit.Reason); + Assert.Contains("not endorsement", await Sites(rig, "list")); + rig.Discord.MissingMembers.Add("901"); + Assert.Contains("Administrator", await Sites(rig, "remove", "example.org")); rig.Discord.MissingMembers.Clear(); + await rig.Restart(); Assert.Contains("example.org", rig.State.PortfolioSites.Hosts); + Assert.Contains("removed", await Sites(rig, "remove", "github.com")); + await rig.Restart(); Assert.DoesNotContain("github.com", rig.State.PortfolioSites.Hosts); + foreach (var host in rig.State.PortfolioSites.Hosts.ToArray()) await Sites(rig, "remove", host); + await rig.Restart(); Assert.Empty(rig.State.PortfolioSites.Hosts); Assert.Contains("None configured", await Sites(rig, "list")); + Assert.Equal("settings sentinel", File.ReadAllText(settingsPath)); + } + + [Fact] + public async Task AbsentRegistryStartsFromSeedsOnceAndMalformedRegistryStopsLoading() + { + using var rig = new RecruitmentRig(); await rig.Start(); + var path = Path.Combine(rig.Root, "recruitment/recruitment-state.json"); + rig.Engine.Dispose(); + var json = Newtonsoft.Json.Linq.JObject.Parse(File.ReadAllText(path)); json.Remove("PortfolioSites"); File.WriteAllText(path, json.ToString()); + await rig.Restart(); + Assert.Equal(RecruitmentContent.SeedPortfolioHosts.Order(), rig.State.PortfolioSites.Hosts.Order()); + Assert.NotNull(Newtonsoft.Json.Linq.JObject.Parse(File.ReadAllText(path))["PortfolioSites"]); + await Sites(rig, "remove", "github.com"); await rig.Restart(); Assert.DoesNotContain("github.com", rig.State.PortfolioSites.Hosts); + rig.Engine.Dispose(); json = Newtonsoft.Json.Linq.JObject.Parse(File.ReadAllText(path)); + json["PortfolioSites"]!["Hosts"] = new Newtonsoft.Json.Linq.JArray("co.uk"); File.WriteAllText(path, json.ToString()); + await rig.Restart(); Assert.Contains("registry is invalid", rig.Engine.Status); Assert.Null(rig.Engine.State); + } + + [Fact] + public async Task HostAndAuditLimitsAreBounded() + { + using var rig = new RecruitmentRig(); await rig.Start(); + for (var i = rig.State.PortfolioSites.Hosts.Count; i < 100; i++) Assert.Contains("added", await Sites(rig, "add", $"site{i}.org")); + Assert.Contains("limit is 100", await Sites(rig, "add", "overflow.org")); + for (var i = 0; i < 10; i++) { await Sites(rig, "remove", "github.com"); await Sites(rig, "add", "github.com"); } + Assert.Equal(100, rig.State.PortfolioSites.Hosts.Count); Assert.Equal(100, rig.State.PortfolioSites.Audit.Count); + await rig.Restart(); Assert.Equal(100, rig.State.PortfolioSites.Audit.Count); + } + + [Theory] + [InlineData("https://github.com", false)] + [InlineData("https://www.artstation.com/", false)] + [InlineData("https://itch.io/", false)] + [InlineData("https://itch.io/explore", false)] + [InlineData("https://github.com/issues", false)] + [InlineData("https://github.com/settings/profile", false)] + [InlineData("https://youtube.com/feed/subscriptions", false)] + [InlineData("https://artist.itch.io", true)] + [InlineData("https://artist.artstation.com/", true)] + [InlineData("https://github.com/Artist/Work", true)] + [InlineData("https://gitlab.com/Artist/Work", true)] + [InlineData("https://behance.net/gallery/123/work", true)] + [InlineData("https://dribbble.com/artist", true)] + [InlineData("https://vimeo.com/123456", true)] + [InlineData("https://deviantart.com/artist", true)] + [InlineData("https://youtube.com/watch?v=AbCdEfGhI_1", true)] + [InlineData("https://youtube.com/watch?v=AbCdEfGhI_2", true)] + [InlineData("https://youtube.com/watch", false)] + [InlineData("https://github.com.evil.org/artist", false)] + [InlineData("https://evilgithub.com/artist", false)] + public void SpecificSupportedPlatformsRequireActualProfileOrWorkIdentity(string url, bool green) + { + var tile = Tile(url); + Assert.Equal(green ? ObservationSeverity.Green : ObservationSeverity.Yellow, tile.Severity); + } + + [Theory] + [InlineData("https://github.com/contact")] + [InlineData("https://discord.gg/project")] + [InlineData("https://discord.com/invite/project")] + [InlineData("https://t.me/artist")] + [InlineData("https://example.org/join")] + public void ContactOnlyAndInvitesAreAbsent(string url) + { + Assert.Empty(RecruitmentContent.ReadPortfolio(url).Candidates); Assert.Empty(RecruitmentContent.ContactLinks(url)); + Assert.Equal(ObservationSeverity.Red, Tile(url).Severity); + } + + [Fact] + public void RemovedEmptyAndSubdomainPoliciesAreRespectedWithoutClearingNegation() + { + Assert.Equal(ObservationSeverity.Yellow, Tile("https://github.com/artist", new HashSet()).Severity); + var hosts = new HashSet { "portfolio.example.org" }; + Assert.Equal(ObservationSeverity.Green, Tile("https://creator.portfolio.example.org/work", hosts).Severity); + Assert.Equal(ObservationSeverity.Yellow, Tile("https://evilportfolio.example.org/work", hosts).Severity); + Assert.Equal(ObservationSeverity.Red, Tile("No portfolio available: https://github.com/artist").Severity); + Assert.Equal(ObservationSeverity.Yellow, Tile("https://personal.example.org/").Severity); + } + + [Fact] + public void CanonicalIdentityPreservesVideoIdsAndPathCaseAndScrubsUnrecognizedQueries() + { + var links = RecruitmentContent.ContactLinks("https://YouTube.com:443/watch?v=AbCdEfGhI_1&utm_source=one#time https://youtu.be/AbCdEfGhI_1?si=tracking https://youtube.com/watch?v=AbCdEfGhI_2 https://example.org/Work https://example.org/work"); + Assert.Equal(new[] { "https://youtube.com/watch?v=AbCdEfGhI_1", "https://youtube.com/watch?v=AbCdEfGhI_2", "https://example.org/Work", "https://example.org/work" }, links); + Assert.NotEqual(Assert.Single(RecruitmentContent.ContactLinks("https://youtube.com/watch?v=AbCdEfGhI_1")), + Assert.Single(RecruitmentContent.ContactLinks("https://youtube.com:444/watch?v=AbCdEfGhI_1"))); + var unknown = Assert.Single(RecruitmentContent.ReadPortfolio("https://example.org/view?id=project&token=secret#part").Candidates); + Assert.Equal("https://example.org/view", unknown.Url); Assert.Null(unknown.ReuseUrl); + Assert.Empty(RecruitmentContent.ContactLinks("https://example.org/view?id=one https://example.org/view?id=two https://youtube.com/watch")); + Assert.Equal(new[] { "https://example.org/Work" }, RecruitmentContent.ContactLinks("https://EXAMPLE.org:443/Work?utm_medium=x#part")); + } + + [Fact] + public void CandidatesRemainBoundedAndDoNotTruncateIntoFalseIdentities() + { + var content = string.Join(' ', Enumerable.Range(0, 20).Select(i => $"https://example.org/work/{i}")); + Assert.Equal(8, RecruitmentContent.ReadPortfolio(content).Candidates.Count); Assert.Equal(8, RecruitmentContent.ContactLinks(content).Count); + Assert.Empty(RecruitmentContent.ContactLinks("https://example.org/" + new string('a', 310))); + Assert.Empty(RecruitmentContent.ContactLinks(new string('a', 8000) + " https://example.org/hidden")); + Assert.False(RecruitmentContent.ReadPortfolio(null).Available); + } + + [Fact] + public async Task RegistryChangesReclassifyInBoundedBatchesWithoutMaterialChangesOrInventory() + { + using var rig = new RecruitmentRig(); await rig.Start(); + for (var i = 0; i < 24; i++) + { + var id = (100 + i).ToString(); await rig.Post(id, (200 + i).ToString(), "12"); await rig.Ack(id, (200 + i).ToString()); + rig.Discord.Threads[id] = rig.Discord.Threads[id] with { Starter = "Portfolio: https://example.org/works" }; + await rig.Engine.FactsChangedAsync(id, default); + } + var inventories = rig.Discord.Inventories; var reads = rig.Discord.ThreadReads; + var material = rig.State.Listings.ToDictionary(p => p.Key, p => p.Value.MaterialChanges); + var versions = rig.State.Listings.ToDictionary(p => p.Key, p => p.Value.LatestStarter); + var notices = rig.State.Listings.ToDictionary(p => p.Key, p => p.Value.Attention.Count); + await Sites(rig, "add", "example.org"); + Assert.Equal(reads, rig.Discord.ThreadReads); + await rig.Engine.RunDueAsync(default); + Assert.Equal(20, rig.Discord.ThreadReads - reads); + Assert.Equal(20, rig.State.Listings.Values.Count(p => p.PortfolioRegistryVersion == rig.State.PortfolioSites.Version)); + await rig.Engine.RunDueAsync(default); + Assert.All(rig.State.Listings.Values, p => { + Assert.Equal(rig.State.PortfolioSites.Version, p.PortfolioRegistryVersion); + Assert.Equal(ObservationSeverity.Green, p.Facts.Checks.Single(c => c.Id == "portfolio").Severity); + Assert.Equal(material[p.Id], p.MaterialChanges); Assert.Equal(versions[p.Id], p.LatestStarter); Assert.Equal(notices[p.Id], p.Attention.Count); + }); + Assert.Equal(inventories, rig.Discord.Inventories); Assert.Empty(rig.Discord.Actions); + await Sites(rig, "remove", "example.org"); await rig.Restart(); + while (rig.State.Listings.Values.Any(p => p.PortfolioRegistryVersion != rig.State.PortfolioSites.Version)) await rig.Engine.RunDueAsync(default); + Assert.All(rig.State.Listings.Values, p => Assert.Equal(ObservationSeverity.Yellow, p.Facts.Checks.Single(c => c.Id == "portfolio").Severity)); + } + + [Fact] + public async Task ReuseHintsAreBoundedPrivateDifferentAuthorAndRetainedOnlyWithListingEvidence() + { + using var rig = new RecruitmentRig(); await rig.Start(); + for (var i = 0; i < 8; i++) + { + var id = (100 + i).ToString(); await rig.Post(id, (200 + i).ToString()); + rig.Discord.Threads[id] = rig.Discord.Threads[id] with { Starter = "Portfolio https://youtube.com/watch?v=AbCdEfGhI_1" }; + await rig.Engine.FactsChangedAsync(id, default); + } + var lookup = await rig.Staff("lookup"); var hint = lookup[lookup.IndexOf("Shared starter", StringComparison.Ordinal)..]; + Assert.Equal(5, hint.Split("(author").Length - 1); Assert.Contains("shared projects can be legitimate", hint); + Assert.DoesNotContain("Shared starter", await rig.Engine.DetailsAsync("1", "100", "200", default)); + Assert.DoesNotContain("Shared starter", rig.Discord.Messages["100:recruitment:100:public"].Message.TextFallback); + foreach (var post in rig.State.Listings.Values.Where(p => p.Id != "100")) + { post.Status = ListingStatus.Deleted; post.UpdatedAt = rig.Clock.Now.AddYears(-2); rig.Discord.Threads.Remove(post.Id); } + RecruitmentPolicy.Prune(rig.State, rig.Clock.Now, 12, 24, 30); + Assert.DoesNotContain("Shared starter", await rig.Staff("lookup")); + await rig.Post("108", "200"); + rig.Discord.Threads["108"] = rig.Discord.Threads["108"] with { Starter = "Portfolio https://youtube.com/watch?v=AbCdEfGhI_1" }; + await rig.Engine.FactsChangedAsync("108", default); + Assert.DoesNotContain("Shared starter", await rig.Staff("lookup")); + } + + [Fact] + public async Task QueryAndCaseDistinctUrlsNeverMatchButTrackingAndRetainedVersionsCanMatch() + { + using var rig = new RecruitmentRig(); await rig.Start(); await rig.Post(); await rig.Post("101", "201"); + async Task Set(string id, string url) { rig.Discord.Threads[id] = rig.Discord.Threads[id] with { Starter = "Portfolio " + url }; await rig.Engine.FactsChangedAsync(id, default); } + await Set("100", "https://youtube.com/watch?v=AbCdEfGhI_1"); await Set("101", "https://youtube.com/watch?v=AbCdEfGhI_2"); + Assert.DoesNotContain("Shared starter", await rig.Staff("lookup")); + await Set("100", "https://example.org/Work"); await Set("101", "https://example.org/work"); + Assert.DoesNotContain("Shared starter", await rig.Staff("lookup")); + await Set("100", "https://example.org/view?id=1&token=one"); await Set("101", "https://example.org/view?id=2&token=two"); + Assert.DoesNotContain("Shared starter", await rig.Staff("lookup")); + await Set("100", "https://example.org/Work?utm_source=one"); await Set("101", "https://example.org/Work#part"); + Assert.Contains("Shared starter", await rig.Staff("lookup")); + await rig.Ack(); await Set("100", "https://example.org/another"); + rig.Discord.Threads["100"] = rig.Discord.Threads["100"] with { Starter = null }; await rig.Engine.FactsChangedAsync("100", default); + Assert.Contains("Shared starter", await rig.Staff("lookup")); + await rig.Restart(); Assert.Contains("Shared starter", await rig.Staff("lookup")); + } + + [Fact] + public async Task CaseOnlyUrlEditCreatesDistinctVersionAndMaterialLinkChange() + { + using var rig = new RecruitmentRig(); await rig.Start(); await rig.Post(); + rig.Discord.Threads["100"] = rig.Discord.Threads["100"] with { Starter = "Portfolio https://example.org/Work" }; + await rig.Engine.FactsChangedAsync("100", default); await rig.Ack(); + rig.Discord.Threads["100"] = rig.Discord.Threads["100"] with { Starter = "Portfolio https://example.org/work" }; + await rig.Engine.FactsChangedAsync("100", default); + Assert.Equal(1, rig.State.Listings["100"].MaterialChanges); + Assert.Equal("https://example.org/work", Assert.Single(rig.State.Listings["100"].LatestStarter!.Links)); + } + + [Fact] + public async Task RegisteredAdministratorPreconditionDoesNotRestrictOtherCommands() + { + using var client = new DiscordSocketClient(); using var interactions = new InteractionService(client); + using var provider = new ServiceCollection().AddSingleton(new RecruitmentService(client, new BotSettings())).BuildServiceProvider(); + var module = await interactions.AddModuleAsync(provider); + var command = module.SlashCommands.Single(c => c.Name == "portfolio-sites"); + var requirement = Assert.Single(command.Preconditions.OfType()); + foreach (var permission in new[] { GuildPermission.SendMessages, GuildPermission.ManageMessages, GuildPermission.Administrator }) + { + var user = Proxy(m => m.Name == "get_GuildPermissions" ? new GuildPermissions((ulong)permission) : null); + var guild = Proxy(m => m.Name == "get_Id" ? 1UL : null); + var context = Proxy(m => m.Name switch { "get_User" => user, "get_Guild" => guild, _ => null }); + var result = await requirement.CheckRequirementsAsync(context, command, provider); + Assert.Equal(permission == GuildPermission.Administrator, result.IsSuccess); + } + Assert.Empty(module.Preconditions); Assert.Null(module.DefaultMemberPermissions); + Assert.Empty(module.SlashCommands.Single(c => c.Name == "eligibility").Preconditions); + Assert.Empty(module.SlashCommands.Single(c => c.Name == "status").Preconditions); + } + + private static Task Sites(RecruitmentRig rig, string operation, string domain = "", string reason = "Test change") => + rig.Engine.PortfolioSitesAsync("1", "901", operation, domain, reason, default); + private static RecruitmentObservation Tile(string content, IReadOnlySet? hosts = null) => RecruitmentSignals.Evaluate( + new ThreadSnapshot("100", "12", "200", "Listing", DateTimeOffset.UtcNow, false, false, false, false, Array.Empty(), content, null, null), + "paid-for-hire", hosts).Single(c => c.Id == "portfolio"); + private static T Proxy(Func implementation) where T : class + { + var proxy = DispatchProxy.Create(); ((InterfaceProxy)(object)proxy).Implementation = implementation; return proxy; + } + public class InterfaceProxy : DispatchProxy + { + public Func Implementation { get; set; } = null!; + protected override object? Invoke(MethodInfo? targetMethod, object?[]? args) => Implementation(targetMethod!); + } +} diff --git a/DiscordBot.Tests/Recruitment/RecruitmentRecoveryTests.cs b/DiscordBot.Tests/Recruitment/RecruitmentRecoveryTests.cs index 004ebde8..79504e83 100644 --- a/DiscordBot.Tests/Recruitment/RecruitmentRecoveryTests.cs +++ b/DiscordBot.Tests/Recruitment/RecruitmentRecoveryTests.cs @@ -17,7 +17,8 @@ public async Task InteractionModuleRegistersWithoutConnectingOrStartingTheFeatur var service = new RecruitmentService(client, new BotSettings()); using var provider = new ServiceCollection().AddSingleton(service).BuildServiceProvider(); var module = await interactions.AddModuleAsync(provider); - Assert.Equal(8, module.SlashCommands.Count); + Assert.Equal(9, module.SlashCommands.Count); + Assert.Contains(module.SlashCommands, c => c.Name == "portfolio-sites"); Assert.Contains(module.SlashCommands, c => c.Name == "status"); Assert.Contains(module.SlashCommands, c => c.Name == "eligibility"); Assert.DoesNotContain(module.SlashCommands, c => c.Name == "help"); diff --git a/DiscordBot.Tests/Recruitment/RecruitmentTestRig.cs b/DiscordBot.Tests/Recruitment/RecruitmentTestRig.cs index 694a26ce..8020cfcc 100644 --- a/DiscordBot.Tests/Recruitment/RecruitmentTestRig.cs +++ b/DiscordBot.Tests/Recruitment/RecruitmentTestRig.cs @@ -139,6 +139,8 @@ public async Task ValidateAccessAsync(CancellationToken ct) public Task GetAuthorAsync(string userId, CancellationToken ct) => Task.FromResult( MissingMembers.Contains(userId) ? null : new RecruitmentAuthor(userId == "999", userId == "900")); public Task IsMemberAsync(string guildId, string userId, CancellationToken ct) => Task.FromResult(guildId == "1" && !MissingMembers.Contains(userId)); + public HashSet Administrators { get; } = new() { "901" }; + public Task IsAdministratorAsync(string guildId, string userId, CancellationToken ct) => Task.FromResult(guildId == "1" && Administrators.Contains(userId) && !MissingMembers.Contains(userId)); public Task IsModeratorAsync(string guildId, string userId, CancellationToken ct) => Task.FromResult(guildId == "1" && userId == "900"); public bool IsExpectedForumUpdate(ForumSnapshot snapshot) => Forums.GetValueOrDefault(snapshot.Id) == snapshot; public Task GetForumAsync(string id, CancellationToken ct) { ForumReads++; return Task.FromResult(Forums[id]); } diff --git a/DiscordBot/Assets/recruitment/public-suffix-icann.dat b/DiscordBot/Assets/recruitment/public-suffix-icann.dat new file mode 100644 index 00000000..c514fec1 --- /dev/null +++ b/DiscordBot/Assets/recruitment/public-suffix-icann.dat @@ -0,0 +1,11245 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +// Please pull this list from, and only from https://publicsuffix.org/list/public_suffix_list.dat, +// rather than any other VCS sites. Pulling from any other URL is not guaranteed to be supported. + +// VERSION: 2026-10-07_07-28-19_UTC +// COMMIT: 3929462652695bad04f0a27afb600974014a3c8b + +// Instructions on pulling and using this list can be found at https://publicsuffix.org/list/. + +// ===BEGIN ICANN DOMAINS=== + +// ac : http://nic.ac/rules.htm +ac +com.ac +edu.ac +gov.ac +mil.ac +net.ac +org.ac + +// ad : https://www.iana.org/domains/root/db/ad.html +// Confirmed by Amadeu Abril i Abril (CORE) 2024-11-17 +ad + +// ae : https://www.iana.org/domains/root/db/ae.html +ae +ac.ae +co.ae +gov.ae +mil.ae +net.ae +org.ae +sch.ae + +// aero : https://information.aero/registration/policies/dmp +aero +// 2LDs +airline.aero +airport.aero +// 2LDs (currently not accepting registration, seemingly never have) +// As of 2024-07, these are marked as reserved for potential 3LD +// registrations (clause 11 "allocated subdomains" in the 2006 TLD +// policy), but the relevant industry partners have not opened them up +// for registration. Current status can be determined from the TLD's +// policy document: 2LDs that are open for registration must list +// their policy in the TLD's policy. Any 2LD without such a policy is +// not open for registrations. +accident-investigation.aero +accident-prevention.aero +aerobatic.aero +aeroclub.aero +aerodrome.aero +agents.aero +air-surveillance.aero +air-traffic-control.aero +aircraft.aero +airtraffic.aero +ambulance.aero +association.aero +author.aero +ballooning.aero +broker.aero +caa.aero +cargo.aero +catering.aero +certification.aero +championship.aero +charter.aero +civilaviation.aero +club.aero +conference.aero +consultant.aero +consulting.aero +control.aero +council.aero +crew.aero +design.aero +dgca.aero +educator.aero +emergency.aero +engine.aero +engineer.aero +entertainment.aero +equipment.aero +exchange.aero +express.aero +federation.aero +flight.aero +freight.aero +fuel.aero +gliding.aero +government.aero +groundhandling.aero +group.aero +hanggliding.aero +homebuilt.aero +insurance.aero +journal.aero +journalist.aero +leasing.aero +logistics.aero +magazine.aero +maintenance.aero +marketplace.aero +media.aero +microlight.aero +modelling.aero +navigation.aero +parachuting.aero +paragliding.aero +passenger-association.aero +pilot.aero +press.aero +production.aero +recreation.aero +repbody.aero +res.aero +research.aero +rotorcraft.aero +safety.aero +scientist.aero +services.aero +show.aero +skydiving.aero +software.aero +student.aero +taxi.aero +trader.aero +trading.aero +trainer.aero +union.aero +workinggroup.aero +works.aero + +// af : https://www.nic.af/domain-price +af +com.af +edu.af +gov.af +net.af +org.af + +// ag : http://www.nic.ag/prices.htm +ag +co.ag +com.ag +net.ag +nom.ag +org.ag + +// ai : https://www.nic.ai/ +ai +com.ai +net.ai +off.ai +org.ai + +// al : https://akep.al/en/domain-e-application/ -> "Regulations and Decisions" +al +com.al +edu.al +gov.al +mil.al +net.al +org.al + +// am : https://www.amnic.net/policy/en/Policy_EN.pdf +// Confirmed by ISOC AM 2024-11-18 +am +co.am +com.am +commune.am +net.am +org.am + +// ao : https://www.dns.ao/ao/ +ao +co.ao +ed.ao +edu.ao +gov.ao +gv.ao +it.ao +og.ao +org.ao +pb.ao + +// aq : https://www.iana.org/domains/root/db/aq.html +aq + +// ar : https://nic.ar/es/nic-argentina/normativa +ar +bet.ar +com.ar +coop.ar +edu.ar +gob.ar +gov.ar +int.ar +mil.ar +musica.ar +mutual.ar +net.ar +org.ar +seg.ar +senasa.ar +tur.ar + +// arpa : https://www.iana.org/domains/root/db/arpa.html +// Confirmed by registry 2008-06-18 +arpa +e164.arpa +home.arpa +in-addr.arpa +ip6.arpa +iris.arpa +uri.arpa +urn.arpa + +// as : https://www.iana.org/domains/root/db/as.html +as +gov.as + +// asia : https://www.iana.org/domains/root/db/asia.html +asia + +// at : https://www.iana.org/domains/root/db/at.html +// Confirmed by registry 2008-06-17 +at +ac.at +sth.ac.at +co.at +gv.at +or.at + +// au : https://www.iana.org/domains/root/db/au.html +// https://www.auda.org.au/ +// Confirmed by registry 2025-07-16 +au +// 2LDs +asn.au +com.au +edu.au +gov.au +id.au +net.au +org.au +// Historic 2LDs (closed to new registration, but sites still exist) +conf.au +oz.au +// CGDNs : https://www.auda.org.au/au-domain-names/the-different-au-domain-names/state-and-territory-domain-names/ +act.au +nsw.au +nt.au +qld.au +sa.au +tas.au +vic.au +wa.au +// 3LDs +act.edu.au +catholic.edu.au +// eq.edu.au - Removed at the request of the Queensland Department of Education +nsw.edu.au +nt.edu.au +qld.edu.au +sa.edu.au +tas.edu.au +vic.edu.au +wa.edu.au +// act.gov.au - Bug 984824 - Removed at request of Greg Tankard +// nsw.gov.au - Bug 547985 - Removed at request of +// nt.gov.au - Bug 940478 - Removed at request of Greg Connors +qld.gov.au +sa.gov.au +tas.gov.au +vic.gov.au +wa.gov.au +// 4LDs +// education.tas.edu.au - Removed at the request of the Department of Education Tasmania +// schools.nsw.edu.au - Removed at the request of the New South Wales Department of Education. + +// aw : https://www.iana.org/domains/root/db/aw.html +aw +com.aw + +// ax : https://www.iana.org/domains/root/db/ax.html +ax + +// az : https://www.iana.org/domains/root/db/az.html +// Confirmed via https://whois.az/?page_id=10 2024-12-11 +az +biz.az +co.az +com.az +edu.az +gov.az +info.az +int.az +mil.az +name.az +net.az +org.az +pp.az +// No longer available for registration, however domains exist as of 2024-12-11 +// see https://whois.az/?page_id=783 +pro.az + +// ba : https://www.iana.org/domains/root/db/ba.html +ba +com.ba +edu.ba +gov.ba +mil.ba +net.ba +org.ba + +// bb : https://www.iana.org/domains/root/db/bb.html +bb +biz.bb +co.bb +com.bb +edu.bb +gov.bb +info.bb +net.bb +org.bb +store.bb +tv.bb + +// bd : https://www.iana.org/domains/root/db/bd.html +// Confirmed by registry +bd +ac.bd +ai.bd +co.bd +com.bd +edu.bd +gov.bd +id.bd +info.bd +it.bd +mil.bd +net.bd +org.bd +sch.bd +tv.bd + +// be : https://www.iana.org/domains/root/db/be.html +// Confirmed by registry 2008-06-08 +be +ac.be + +// bf : https://www.iana.org/domains/root/db/bf.html +bf +gov.bf + +// bg : https://www.register.bg/ -> "Terms and Conditions" +bg +0.bg +1.bg +2.bg +3.bg +4.bg +5.bg +6.bg +7.bg +8.bg +9.bg +a.bg +b.bg +c.bg +d.bg +e.bg +f.bg +g.bg +h.bg +i.bg +j.bg +k.bg +l.bg +m.bg +n.bg +o.bg +p.bg +q.bg +r.bg +s.bg +t.bg +u.bg +v.bg +w.bg +x.bg +y.bg +z.bg + +// bh : https://www.iana.org/domains/root/db/bh.html +bh +com.bh +edu.bh +gov.bh +net.bh +org.bh + +// bi : http://whois.nic.bi/ +bi +co.bi +com.bi +edu.bi +or.bi +org.bi + +// biz : https://www.iana.org/domains/root/db/biz.html +biz + +// bj : https://nic.bj/bj-suffixes.txt +// Submitted by registry +bj +africa.bj +agro.bj +architectes.bj +assur.bj +avocats.bj +co.bj +com.bj +eco.bj +econo.bj +edu.bj +info.bj +loisirs.bj +money.bj +net.bj +org.bj +ote.bj +restaurant.bj +resto.bj +tourism.bj +univ.bj + +// bm : https://www.bermudanic.bm/domain-registration/index.php +bm +com.bm +edu.bm +gov.bm +net.bm +org.bm + +// bn : http://www.bnnic.bn/faqs +bn +com.bn +edu.bn +gov.bn +net.bn +org.bn + +// bo : https://nic.bo +// Confirmed by registry 2026-09-01 +bo +com.bo +edu.bo +gob.bo +int.bo +mil.bo +net.bo +org.bo +tv.bo +web.bo +// Social Domains +academia.bo +agro.bo +arte.bo +blog.bo +bolivia.bo +ciencia.bo +cooperativa.bo +democracia.bo +deporte.bo +ecologia.bo +economia.bo +empresa.bo +ia.bo +indigena.bo +industria.bo +info.bo +medicina.bo +movimiento.bo +musica.bo +natural.bo +nombre.bo +noticias.bo +patria.bo +plurinacional.bo +politica.bo +profesional.bo +pueblo.bo +revista.bo +salud.bo +tecnologia.bo +tksat.bo +transporte.bo +wiki.bo + +// br : http://registro.br/dominio/categoria.html +// Submitted by registry +br +9guacu.br +abc.br +adm.br +adv.br +agr.br +aju.br +am.br +anani.br +aparecida.br +api.br +app.br +arq.br +art.br +ato.br +b.br +barueri.br +belem.br +bet.br +bhz.br +bib.br +bio.br +blog.br +bmd.br +boavista.br +bsb.br +campinagrande.br +campinas.br +caxias.br +cim.br +cng.br +cnt.br +com.br +contagem.br +coop.br +coz.br +cri.br +cuiaba.br +curitiba.br +def.br +des.br +det.br +dev.br +ecn.br +eco.br +edu.br +emp.br +enf.br +eng.br +esp.br +etc.br +eti.br +far.br +feira.br +flog.br +floripa.br +fm.br +fnd.br +fortal.br +fot.br +foz.br +fst.br +g12.br +geo.br +ggf.br +goiania.br +gov.br +// gov.br 26 states + df https://en.wikipedia.org/wiki/States_of_Brazil +ac.gov.br +al.gov.br +am.gov.br +ap.gov.br +ba.gov.br +ce.gov.br +df.gov.br +es.gov.br +go.gov.br +ma.gov.br +mg.gov.br +ms.gov.br +mt.gov.br +pa.gov.br +pb.gov.br +pe.gov.br +pi.gov.br +pr.gov.br +rj.gov.br +rn.gov.br +ro.gov.br +rr.gov.br +rs.gov.br +sc.gov.br +se.gov.br +sp.gov.br +to.gov.br +gru.br +ia.br +imb.br +ind.br +inf.br +jab.br +jampa.br +jdf.br +joinville.br +jor.br +jus.br +leg.br +leilao.br +lel.br +log.br +londrina.br +macapa.br +maceio.br +manaus.br +maringa.br +mat.br +med.br +mil.br +morena.br +mp.br +mus.br +natal.br +net.br +niteroi.br +*.nom.br +not.br +ntr.br +odo.br +ong.br +org.br +osasco.br +palmas.br +poa.br +ppg.br +pro.br +psc.br +psi.br +pvh.br +qsl.br +radio.br +rec.br +recife.br +rep.br +ribeirao.br +rio.br +riobranco.br +riopreto.br +salvador.br +sampa.br +santamaria.br +santoandre.br +saobernardo.br +saogonca.br +seg.br +sjc.br +slg.br +slz.br +social.br +sorocaba.br +srv.br +taxi.br +tc.br +tec.br +teo.br +the.br +tmp.br +trd.br +tur.br +tv.br +udi.br +vet.br +vix.br +vlog.br +wiki.br +xyz.br +zlg.br + +// bs : http://www.register.bs/rules.html +bs +com.bs +edu.bs +gov.bs +net.bs +org.bs + +// bt : https://www.iana.org/domains/root/db/bt.html +bt +com.bt +edu.bt +gov.bt +net.bt +org.bt + +// bv : No registrations at this time. +// Submitted by registry +bv + +// bw : https://nic.net.bw/bw-name-structure +bw +ac.bw +co.bw +gov.bw +net.bw +org.bw + +// by : https://www.iana.org/domains/root/db/by.html +// http://tld.by/rules_2006_en.html +// list of other 2nd level tlds ? +by +gov.by +mil.by +// Official information does not indicate that com.by is a reserved +// second-level domain, but it's being used as one (see www.google.com.by and +// www.yahoo.com.by, for example), so we list it here for safety's sake. +com.by +// http://hoster.by/ +of.by + +// bz : http://www.belizenic.bz/ +bz +co.bz +com.bz +edu.bz +gov.bz +net.bz +org.bz + +// ca : https://www.iana.org/domains/root/db/ca.html +ca +// ca geographical names +ab.ca +bc.ca +mb.ca +nb.ca +nf.ca +nl.ca +ns.ca +nt.ca +nu.ca +on.ca +pe.ca +qc.ca +sk.ca +yk.ca +// gc.ca: https://en.wikipedia.org/wiki/.gc.ca +// see also: http://registry.gc.ca/en/SubdomainFAQ +gc.ca + +// cat : https://www.iana.org/domains/root/db/cat.html +cat + +// cc : https://www.iana.org/domains/root/db/cc.html +cc + +// cd : https://www.nic.cd +cd +gov.cd + +// cf : https://www.iana.org/domains/root/db/cf.html +cf + +// cg : https://www.iana.org/domains/root/db/cg.html +cg + +// ch : https://www.iana.org/domains/root/db/ch.html +ch + +// ci : https://www.iana.org/domains/root/db/ci.html +ci +ac.ci +aéroport.ci +asso.ci +co.ci +com.ci +ed.ci +edu.ci +go.ci +gouv.ci +int.ci +net.ci +or.ci +org.ci + +// ck : https://www.iana.org/domains/root/db/ck.html +*.ck +!www.ck + +// cl : https://www.nic.cl +// Confirmed by .CL registry +cl +co.cl +gob.cl +gov.cl +mil.cl + +// cm : https://www.iana.org/domains/root/db/cm.html plus bug 981927 +cm +co.cm +com.cm +gov.cm +net.cm + +// cn : https://www.iana.org/domains/root/db/cn.html +// Submitted by registry +cn +ac.cn +com.cn +edu.cn +gov.cn +mil.cn +net.cn +org.cn +公司.cn +網絡.cn +网络.cn +// cn geographic names +ah.cn +bj.cn +cq.cn +fj.cn +gd.cn +gs.cn +gx.cn +gz.cn +ha.cn +hb.cn +he.cn +hi.cn +hk.cn +hl.cn +hn.cn +jl.cn +js.cn +jx.cn +ln.cn +mo.cn +nm.cn +nx.cn +qh.cn +sc.cn +sd.cn +sh.cn +sn.cn +sx.cn +tj.cn +tw.cn +xj.cn +xz.cn +yn.cn +zj.cn + +// co : https://www.iana.org/domains/root/db/co.html +// https://www.cointernet.com.co/como-funciona-un-dominio-restringido +// Confirmed by registry 2024-11-18 +co +com.co +edu.co +gov.co +mil.co +net.co +nom.co +org.co + +// com : https://www.iana.org/domains/root/db/com.html +com + +// coop : https://www.iana.org/domains/root/db/coop.html +coop + +// cr : https://nic.cr/capitulo-1-registro-de-un-nombre-de-dominio/ +cr +ac.cr +co.cr +ed.cr +fi.cr +go.cr +or.cr +sa.cr + +// cu : https://www.iana.org/domains/root/db/cu.html +cu +com.cu +edu.cu +gob.cu +inf.cu +nat.cu +net.cu +org.cu + +// cv : https://www.iana.org/domains/root/db/cv.html +// https://ola.cv/domain-extensions-under-cv/ +// Confirmed by registry 2024-11-26 +cv +com.cv +edu.cv +id.cv +int.cv +net.cv +nome.cv +org.cv +publ.cv + +// cw : https://www.uoc.cw/cw-registry +// Confirmed by registry 2024-11-19 +cw +com.cw +edu.cw +net.cw +org.cw + +// cx : https://www.iana.org/domains/root/db/cx.html +// list of other 2nd level tlds ? +cx +gov.cx + +// cy : http://www.nic.cy/ +// Submitted by Panayiotou Fotia +// https://nic.cy/wp-content/uploads/2024/01/Create-Request-for-domain-name-registration-1.pdf +cy +ac.cy +biz.cy +com.cy +ekloges.cy +gov.cy +ltd.cy +mil.cy +net.cy +org.cy +press.cy +pro.cy +tm.cy + +// cz : https://www.iana.org/domains/root/db/cz.html +// Confirmed by registry 2025-08-06 +cz +gov.cz + +// de : https://www.iana.org/domains/root/db/de.html +// Confirmed by registry (with technical +// reservations) 2008-07-01 +de + +// dj : https://www.iana.org/domains/root/db/dj.html +dj + +// dk : https://www.iana.org/domains/root/db/dk.html +// Confirmed by registry 2008-06-17 +dk + +// dm : https://www.iana.org/domains/root/db/dm.html +// https://nic.dm/policies/pdf/DMRulesandGuidelines2024v1.pdf +// Confirmed by registry 2024-11-19 +dm +co.dm +com.dm +edu.dm +gov.dm +net.dm +org.dm + +// do : https://www.iana.org/domains/root/db/do.html +do +art.do +com.do +edu.do +gob.do +gov.do +mil.do +net.do +org.do +sld.do +web.do + +// dz : http://www.nic.dz/images/pdf_nic/charte.pdf +dz +art.dz +asso.dz +com.dz +edu.dz +gov.dz +net.dz +org.dz +pol.dz +soc.dz +tm.dz + +// ec : https://www.nic.ec/ +// Submitted by registry +ec +abg.ec +adm.ec +agron.ec +arqt.ec +art.ec +bar.ec +chef.ec +com.ec +cont.ec +cpa.ec +cue.ec +dent.ec +dgn.ec +disco.ec +doc.ec +edu.ec +eng.ec +esm.ec +fin.ec +fot.ec +gal.ec +gob.ec +gov.ec +gye.ec +ibr.ec +info.ec +k12.ec +lat.ec +loj.ec +med.ec +mil.ec +mktg.ec +mon.ec +net.ec +ntr.ec +odont.ec +org.ec +pro.ec +prof.ec +psic.ec +psiq.ec +pub.ec +rio.ec +rrpp.ec +sal.ec +tech.ec +tul.ec +tur.ec +uio.ec +vet.ec +xxx.ec + +// edu : https://www.iana.org/domains/root/db/edu.html +edu + +// ee : https://www.internet.ee/domains/general-domains-and-procedure-for-registration-of-sub-domains-under-general-domains +ee +aip.ee +com.ee +edu.ee +fie.ee +gov.ee +lib.ee +med.ee +org.ee +pri.ee +riik.ee + +// eg : https://domain.eg/subdomain-names +eg +ac.eg +com.eg +edu.eg +eun.eg +gov.eg +info.eg +me.eg +mil.eg +name.eg +net.eg +org.eg +sci.eg +sport.eg +tv.eg + +// er : https://www.iana.org/domains/root/db/er.html +*.er + +// es : https://www.dominios.es/en +es +com.es +edu.es +gob.es +nom.es +org.es + +// et : https://www.iana.org/domains/root/db/et.html +et +biz.et +com.et +edu.et +gov.et +info.et +name.et +net.et +org.et + +// eu : https://www.iana.org/domains/root/db/eu.html +eu + +// fi : https://www.iana.org/domains/root/db/fi.html +fi +// aland.fi : https://www.iana.org/domains/root/db/ax.html +// This domain is being phased out in favor of .ax. As there are still many +// domains under aland.fi, we still keep it on the list until aland.fi is +// completely removed. +aland.fi + +// fj : https://www.iana.org/domains/root/db/fj.html +fj +ac.fj +biz.fj +com.fj +edu.fj +gov.fj +id.fj +info.fj +mil.fj +name.fj +net.fj +org.fj +pro.fj + +// fk : https://www.iana.org/domains/root/db/fk.html +*.fk + +// fm : https://www.iana.org/domains/root/db/fm.html +fm +com.fm +edu.fm +net.fm +org.fm + +// fo : https://www.iana.org/domains/root/db/fo.html +fo + +// fr : https://www.afnic.fr/ https://www.afnic.fr/wp-media/uploads/2022/12/afnic-naming-policy-2023-01-01.pdf +fr +asso.fr +com.fr +gouv.fr +nom.fr +prd.fr +tm.fr +// Other SLDs now selfmanaged out of AFNIC range. Former "domaines sectoriels", still registration suffixes +avoues.fr +cci.fr +greta.fr +huissier-justice.fr + +// ga : https://www.iana.org/domains/root/db/ga.html +ga + +// gb : This registry is effectively dormant +// Submitted by registry +gb + +// gd : https://www.iana.org/domains/root/db/gd.html +gd +edu.gd +gov.gd + +// ge : https://nic.ge/en/administrator/the-ge-domain-regulations +// Confirmed by registry 2024-11-20 +ge +com.ge +cyb.ge +edu.ge +gov.ge +llc.ge +net.ge +online.ge +org.ge +pvt.ge +school.ge +tnx.ge + +// gf : https://www.iana.org/domains/root/db/gf.html +gf + +// gg : https://www.channelisles.net/register-1/register-direct +// Confirmed by registry 2013-11-28 +gg +co.gg +net.gg +org.gg + +// gh : https://www.iana.org/domains/root/db/gh.html +// https://www.nic.gh/ +// Although domains directly at second level are not possible at the moment, +// they have been possible for some time and may come back. +gh +biz.gh +com.gh +edu.gh +gov.gh +mil.gh +net.gh +org.gh + +// gi : https://www.nic.gi/rules.html +gi +com.gi +edu.gi +gov.gi +ltd.gi +mod.gi +org.gi + +// gl : http://nic.gl +gl +co.gl +com.gl +edu.gl +net.gl +org.gl + +// gm : https://www.nic.gm/NIC2/policies.html +gm + +// gn : http://psg.com/dns/gn/gn.txt +// Submitted by registry +gn +ac.gn +com.gn +edu.gn +gov.gn +net.gn +org.gn + +// gov : https://www.iana.org/domains/root/db/gov.html +gov + +// gp : http://www.nic.gp/index.php?lang=en +gp +asso.gp +com.gp +edu.gp +mobi.gp +net.gp +org.gp + +// gq : https://www.iana.org/domains/root/db/gq.html +gq + +// gr : https://www.iana.org/domains/root/db/gr.html +// Submitted by registry +gr +com.gr +edu.gr +gov.gr +net.gr +org.gr + +// gs : https://www.iana.org/domains/root/db/gs.html +gs + +// gt : https://www.gt/sitio/registration_policy.php?lang=en +gt +com.gt +edu.gt +gob.gt +ind.gt +mil.gt +net.gt +org.gt + +// gu : https://give.uog.edu/gu-domain-application-form/ +// University of Guam : https://www.uog.edu +// Submitted by uognoc@triton.uog.edu +gu +com.gu +edu.gu +gov.gu +guam.gu +info.gu +net.gu +org.gu +web.gu + +// gw : https://www.iana.org/domains/root/db/gw.html +// gw : https://nic.gw/regras/ +gw + +// gy : http://registry.gy/ +gy +co.gy +com.gy +edu.gy +gov.gy +net.gy +org.gy + +// hk : https://www.hkirc.hk +// Submitted by registry +hk +com.hk +edu.hk +gov.hk +idv.hk +net.hk +org.hk +个人.hk +個人.hk +公司.hk +政府.hk +敎育.hk +教育.hk +箇人.hk +組織.hk +組织.hk +網絡.hk +網络.hk +组織.hk +组织.hk +网絡.hk +网络.hk + +// hm : https://www.iana.org/domains/root/db/hm.html +hm + +// hn : https://www.iana.org/domains/root/db/hn.html +hn +com.hn +edu.hn +gob.hn +mil.hn +net.hn +org.hn + +// hr : https://domene.hr/en/portal/faq +hr +com.hr +// From.hr domene : http://from.hr/ +from.hr +iz.hr +name.hr + +// ht : http://www.nic.ht/info/charte.cfm +ht +adult.ht +art.ht +asso.ht +com.ht +coop.ht +edu.ht +firm.ht +gouv.ht +info.ht +med.ht +net.ht +org.ht +perso.ht +pol.ht +pro.ht +rel.ht +shop.ht + +// hu : https://www.iana.org/domains/root/db/hu.html +// Confirmed by registry 2008-06-12 +hu +2000.hu +agrar.hu +bolt.hu +casino.hu +city.hu +co.hu +erotica.hu +erotika.hu +film.hu +forum.hu +games.hu +hotel.hu +info.hu +ingatlan.hu +jogasz.hu +konyvelo.hu +lakas.hu +media.hu +news.hu +org.hu +priv.hu +reklam.hu +sex.hu +shop.hu +sport.hu +suli.hu +szex.hu +tm.hu +tozsde.hu +utazas.hu +video.hu + +// id : https://www.iana.org/domains/root/db/id.html +id +ac.id +ai.id +biz.id +co.id +desa.id +go.id +kop.id +mil.id +my.id +net.id +or.id +ponpes.id +sch.id +web.id +// xn--9tfky.id (.id, Und-Bali) +ᬩᬮᬶ.id + +// ie : https://www.iana.org/domains/root/db/ie.html +ie +gov.ie + +// il : http://www.isoc.org.il/domains/ +// see also: https://en.isoc.org.il/il-cctld/registration-rules +// ISOC-IL (operated by .il Registry) +il +ac.il +co.il +gov.il +idf.il +k12.il +muni.il +net.il +org.il +// xn--4dbrk0ce ("Israel", Hebrew) : IL +ישראל +// xn--4dbgdty6c.xn--4dbrk0ce. +אקדמיה.ישראל +// xn--5dbhl8d.xn--4dbrk0ce. +ישוב.ישראל +// xn--8dbq2a.xn--4dbrk0ce. +צהל.ישראל +// xn--hebda8b.xn--4dbrk0ce. +ממשל.ישראל + +// im : https://www.nic.im/ +// Submitted by registry +im +ac.im +co.im +ltd.co.im +plc.co.im +com.im +net.im +org.im +tt.im +tv.im + +// in : https://www.iana.org/domains/root/db/in.html +// see also: https://registry.in/policies +// Please note, that nic.in is not an official eTLD, but used by most +// government institutions. +// Confirmed by Gaurav Kansal 2025-11-06 +// Added aero.in, alumni.in, school.in and ub.in by Gaurav Kansal 2026-06-25 +in +5g.in +6g.in +ac.in +aero.in +ai.in +alumni.in +am.in +bank.in +bihar.in +biz.in +business.in +ca.in +cn.in +co.in +com.in +coop.in +cs.in +delhi.in +dr.in +edu.in +er.in +fin.in +firm.in +gen.in +gov.in +gujarat.in +ind.in +info.in +int.in +internet.in +io.in +me.in +mil.in +net.in +nic.in +org.in +pg.in +post.in +pro.in +res.in +school.in +travel.in +tv.in +ub.in +uk.in +up.in +us.in + +// info : https://www.iana.org/domains/root/db/info.html +info + +// int : https://www.iana.org/domains/root/db/int.html +// Confirmed by registry 2008-06-18 +int +eu.int + +// io : http://www.nic.io/rules.htm +io +co.io +com.io +edu.io +gov.io +mil.io +net.io +nom.io +org.io + +// iq : https://cmc.iq/ +iq +com.iq +edu.iq +gov.iq +mil.iq +net.iq +org.iq + +// ir : http://www.nic.ir/Terms_and_Conditions_ir,_Appendix_1_Domain_Rules +// Also see http://www.nic.ir/Internationalized_Domain_Names +// Two .ir entries added at request of , 2010-04-16 +ir +ac.ir +co.ir +gov.ir +id.ir +net.ir +org.ir +sch.ir +// xn--mgba3a4f16a.ir (.ir, Persian YEH) +ایران.ir +// xn--mgba3a4fra.ir (.ir, Arabic YEH) +ايران.ir + +// is : http://www.isnic.is/domain/rules.php +// Confirmed by registry 2024-11-17 +is + +// it : https://www.nic.it/ +it +edu.it +gov.it +// Regions (3.3.1) +// https://www.nic.it/en/manage-your-it/forms-and-docs -> "Assignment and Management of domain names" +abr.it +abruzzo.it +aosta-valley.it +aostavalley.it +bas.it +basilicata.it +cal.it +calabria.it +cam.it +campania.it +emilia-romagna.it +emiliaromagna.it +emr.it +friuli-v-giulia.it +friuli-ve-giulia.it +friuli-vegiulia.it +friuli-venezia-giulia.it +friuli-veneziagiulia.it +friuli-vgiulia.it +friuliv-giulia.it +friulive-giulia.it +friulivegiulia.it +friulivenezia-giulia.it +friuliveneziagiulia.it +friulivgiulia.it +fvg.it +laz.it +lazio.it +lig.it +liguria.it +lom.it +lombardia.it +lombardy.it +lucania.it +mar.it +marche.it +mol.it +molise.it +piedmont.it +piemonte.it +pmn.it +pug.it +puglia.it +sar.it +sardegna.it +sardinia.it +sic.it +sicilia.it +sicily.it +taa.it +tos.it +toscana.it +trentin-sud-tirol.it +trentin-süd-tirol.it +trentin-sudtirol.it +trentin-südtirol.it +trentin-sued-tirol.it +trentin-suedtirol.it +trentino-a-adige.it +trentino-aadige.it +trentino-alto-adige.it +trentino-altoadige.it +trentino-s-tirol.it +trentino-stirol.it +trentino-sud-tirol.it +trentino-süd-tirol.it +trentino-sudtirol.it +trentino-südtirol.it +trentino-sued-tirol.it +trentino-suedtirol.it +trentinoa-adige.it +trentinoaadige.it +trentinoalto-adige.it +trentinoaltoadige.it +trentinos-tirol.it +trentinostirol.it +trentinosud-tirol.it +trentinosüd-tirol.it +trentinosüdtirol.it +trentinosued-tirol.it +trentinosuedtirol.it +trentinsud-tirol.it +trentinsüd-tirol.it +trentinsudtirol.it +trentinsüdtirol.it +trentinsued-tirol.it +trentinsuedtirol.it +tuscany.it +umb.it +umbria.it +val-d-aosta.it +val-daosta.it +vald-aosta.it +valle-aosta.it +valle-d-aosta.it +valle-daosta.it +valleaosta.it +valled-aosta.it +valledaosta.it +vallee-aoste.it +vallée-aoste.it +vallee-d-aoste.it +vallée-d-aoste.it +valleeaoste.it +valléeaoste.it +valleedaoste.it +valléedaoste.it +vao.it +vda.it +ven.it +veneto.it +// Provinces (3.3.2) +ag.it +agrigento.it +al.it +alessandria.it +alto-adige.it +altoadige.it +an.it +ancona.it +andria-barletta-trani.it +andria-trani-barletta.it +andriabarlettatrani.it +andriatranibarletta.it +ao.it +aosta.it +aoste.it +ap.it +aq.it +ar.it +arezzo.it +ascoli-piceno.it +ascolipiceno.it +asti.it +at.it +av.it +avellino.it +ba.it +balsan.it +balsan-sudtirol.it +balsan-südtirol.it +balsan-suedtirol.it +bari.it +barletta-trani-andria.it +barlettatraniandria.it +belluno.it +benevento.it +bergamo.it +bg.it +bi.it +biella.it +bl.it +bn.it +bo.it +bologna.it +bolzano.it +bolzano-altoadige.it +bozen.it +bozen-sudtirol.it +bozen-südtirol.it +bozen-suedtirol.it +br.it +brescia.it +brindisi.it +bs.it +bt.it +bulsan.it +bulsan-sudtirol.it +bulsan-südtirol.it +bulsan-suedtirol.it +bz.it +ca.it +cagliari.it +caltanissetta.it +campidano-medio.it +campidanomedio.it +campobasso.it +carbonia-iglesias.it +carboniaiglesias.it +carrara-massa.it +carraramassa.it +caserta.it +catania.it +catanzaro.it +cb.it +ce.it +cesena-forli.it +cesena-forlì.it +cesenaforli.it +cesenaforlì.it +ch.it +chieti.it +ci.it +cl.it +cn.it +co.it +como.it +cosenza.it +cr.it +cremona.it +crotone.it +cs.it +ct.it +cuneo.it +cz.it +dell-ogliastra.it +dellogliastra.it +en.it +enna.it +fc.it +fe.it +fermo.it +ferrara.it +fg.it +fi.it +firenze.it +florence.it +fm.it +foggia.it +forli-cesena.it +forlì-cesena.it +forlicesena.it +forlìcesena.it +fr.it +frosinone.it +ge.it +genoa.it +genova.it +go.it +gorizia.it +gr.it +grosseto.it +iglesias-carbonia.it +iglesiascarbonia.it +im.it +imperia.it +is.it +isernia.it +kr.it +la-spezia.it +laquila.it +laspezia.it +latina.it +lc.it +le.it +lecce.it +lecco.it +li.it +livorno.it +lo.it +lodi.it +lt.it +lu.it +lucca.it +macerata.it +mantova.it +massa-carrara.it +massacarrara.it +matera.it +mb.it +mc.it +me.it +medio-campidano.it +mediocampidano.it +messina.it +mi.it +milan.it +milano.it +mn.it +mo.it +modena.it +monza.it +monza-brianza.it +monza-e-della-brianza.it +monzabrianza.it +monzaebrianza.it +monzaedellabrianza.it +ms.it +mt.it +na.it +naples.it +napoli.it +no.it +novara.it +nu.it +nuoro.it +og.it +ogliastra.it +olbia-tempio.it +olbiatempio.it +or.it +oristano.it +ot.it +pa.it +padova.it +padua.it +palermo.it +parma.it +pavia.it +pc.it +pd.it +pe.it +perugia.it +pesaro-urbino.it +pesarourbino.it +pescara.it +pg.it +pi.it +piacenza.it +pisa.it +pistoia.it +pn.it +po.it +pordenone.it +potenza.it +pr.it +prato.it +pt.it +pu.it +pv.it +pz.it +ra.it +ragusa.it +ravenna.it +rc.it +re.it +reggio-calabria.it +reggio-emilia.it +reggiocalabria.it +reggioemilia.it +rg.it +ri.it +rieti.it +rimini.it +rm.it +rn.it +ro.it +roma.it +rome.it +rovigo.it +sa.it +salerno.it +sassari.it +savona.it +si.it +siena.it +siracusa.it +so.it +sondrio.it +sp.it +sr.it +ss.it +su.it +sud-sardegna.it +sudsardegna.it +südtirol.it +suedtirol.it +sv.it +ta.it +taranto.it +te.it +tempio-olbia.it +tempioolbia.it +teramo.it +terni.it +tn.it +to.it +torino.it +tp.it +tr.it +trani-andria-barletta.it +trani-barletta-andria.it +traniandriabarletta.it +tranibarlettaandria.it +trapani.it +trentino.it +trento.it +treviso.it +trieste.it +ts.it +turin.it +tv.it +ud.it +udine.it +urbino-pesaro.it +urbinopesaro.it +va.it +varese.it +vb.it +vc.it +ve.it +venezia.it +venice.it +verbania.it +verbano-cusio-ossola.it +vercelli.it +verona.it +vi.it +vibo-valentia.it +vibovalentia.it +vicenza.it +viterbo.it +vr.it +vs.it +vt.it +vv.it + +// je : https://www.iana.org/domains/root/db/je.html +// Confirmed by registry 2013-11-28 +je +co.je +net.je +org.je + +// jm : https://www.iana.org/domains/root/db/jm.html +*.jm + +// jo : https://www.dns.jo/JoFamily.aspx +// Confirmed by registry 2024-11-17 +jo +agri.jo +ai.jo +com.jo +edu.jo +eng.jo +fm.jo +gov.jo +mil.jo +net.jo +org.jo +per.jo +phd.jo +sch.jo +tv.jo + +// jobs : https://www.iana.org/domains/root/db/jobs.html +jobs + +// jp : https://www.iana.org/domains/root/db/jp.html +// http://jprs.co.jp/en/jpdomain.html +// Confirmed by registry 2024-11-22 +jp +// jp organizational type names +ac.jp +ad.jp +co.jp +ed.jp +go.jp +gr.jp +lg.jp +ne.jp +or.jp +// jp prefecture type names +aichi.jp +akita.jp +aomori.jp +chiba.jp +ehime.jp +fukui.jp +fukuoka.jp +fukushima.jp +gifu.jp +gunma.jp +hiroshima.jp +hokkaido.jp +hyogo.jp +ibaraki.jp +ishikawa.jp +iwate.jp +kagawa.jp +kagoshima.jp +kanagawa.jp +kochi.jp +kumamoto.jp +kyoto.jp +mie.jp +miyagi.jp +miyazaki.jp +nagano.jp +nagasaki.jp +nara.jp +niigata.jp +oita.jp +okayama.jp +okinawa.jp +osaka.jp +saga.jp +saitama.jp +shiga.jp +shimane.jp +shizuoka.jp +tochigi.jp +tokushima.jp +tokyo.jp +tottori.jp +toyama.jp +wakayama.jp +yamagata.jp +yamaguchi.jp +yamanashi.jp +三重.jp +京都.jp +佐賀.jp +兵庫.jp +北海道.jp +千葉.jp +和歌山.jp +埼玉.jp +大分.jp +大阪.jp +奈良.jp +宮城.jp +宮崎.jp +富山.jp +山口.jp +山形.jp +山梨.jp +岐阜.jp +岡山.jp +岩手.jp +島根.jp +広島.jp +徳島.jp +愛媛.jp +愛知.jp +新潟.jp +東京.jp +栃木.jp +沖縄.jp +滋賀.jp +熊本.jp +石川.jp +神奈川.jp +福井.jp +福岡.jp +福島.jp +秋田.jp +群馬.jp +茨城.jp +長崎.jp +長野.jp +青森.jp +静岡.jp +香川.jp +高知.jp +鳥取.jp +鹿児島.jp +// jp geographic type names +// http://jprs.jp/doc/rule/saisoku-1.html +// 2024-11-22: JPRS confirmed that jp geographic type names no longer accept new registrations. +// Once all existing registrations expire (marking full discontinuation), these suffixes +// will be removed from the PSL. +*.kawasaki.jp +!city.kawasaki.jp +*.kitakyushu.jp +!city.kitakyushu.jp +*.kobe.jp +!city.kobe.jp +*.nagoya.jp +!city.nagoya.jp +*.sapporo.jp +!city.sapporo.jp +*.sendai.jp +!city.sendai.jp +*.yokohama.jp +!city.yokohama.jp +// 4th level registration +aisai.aichi.jp +ama.aichi.jp +anjo.aichi.jp +asuke.aichi.jp +chiryu.aichi.jp +chita.aichi.jp +fuso.aichi.jp +gamagori.aichi.jp +handa.aichi.jp +hazu.aichi.jp +hekinan.aichi.jp +higashiura.aichi.jp +ichinomiya.aichi.jp +inazawa.aichi.jp +inuyama.aichi.jp +isshiki.aichi.jp +iwakura.aichi.jp +kanie.aichi.jp +kariya.aichi.jp +kasugai.aichi.jp +kira.aichi.jp +kiyosu.aichi.jp +komaki.aichi.jp +konan.aichi.jp +kota.aichi.jp +mihama.aichi.jp +miyoshi.aichi.jp +nishio.aichi.jp +nisshin.aichi.jp +obu.aichi.jp +oguchi.aichi.jp +oharu.aichi.jp +okazaki.aichi.jp +owariasahi.aichi.jp +seto.aichi.jp +shikatsu.aichi.jp +shinshiro.aichi.jp +shitara.aichi.jp +tahara.aichi.jp +takahama.aichi.jp +tobishima.aichi.jp +toei.aichi.jp +togo.aichi.jp +tokai.aichi.jp +tokoname.aichi.jp +toyoake.aichi.jp +toyohashi.aichi.jp +toyokawa.aichi.jp +toyone.aichi.jp +toyota.aichi.jp +tsushima.aichi.jp +yatomi.aichi.jp +akita.akita.jp +daisen.akita.jp +fujisato.akita.jp +gojome.akita.jp +hachirogata.akita.jp +happou.akita.jp +higashinaruse.akita.jp +honjo.akita.jp +honjyo.akita.jp +ikawa.akita.jp +kamikoani.akita.jp +kamioka.akita.jp +katagami.akita.jp +kazuno.akita.jp +kitaakita.akita.jp +kosaka.akita.jp +kyowa.akita.jp +misato.akita.jp +mitane.akita.jp +moriyoshi.akita.jp +nikaho.akita.jp +noshiro.akita.jp +odate.akita.jp +oga.akita.jp +ogata.akita.jp +semboku.akita.jp +yokote.akita.jp +yurihonjo.akita.jp +aomori.aomori.jp +gonohe.aomori.jp +hachinohe.aomori.jp +hashikami.aomori.jp +hiranai.aomori.jp +hirosaki.aomori.jp +itayanagi.aomori.jp +kuroishi.aomori.jp +misawa.aomori.jp +mutsu.aomori.jp +nakadomari.aomori.jp +noheji.aomori.jp +oirase.aomori.jp +owani.aomori.jp +rokunohe.aomori.jp +sannohe.aomori.jp +shichinohe.aomori.jp +shingo.aomori.jp +takko.aomori.jp +towada.aomori.jp +tsugaru.aomori.jp +tsuruta.aomori.jp +abiko.chiba.jp +asahi.chiba.jp +chonan.chiba.jp +chosei.chiba.jp +choshi.chiba.jp +chuo.chiba.jp +funabashi.chiba.jp +futtsu.chiba.jp +hanamigawa.chiba.jp +ichihara.chiba.jp +ichikawa.chiba.jp +ichinomiya.chiba.jp +inzai.chiba.jp +isumi.chiba.jp +kamagaya.chiba.jp +kamogawa.chiba.jp +kashiwa.chiba.jp +katori.chiba.jp +katsuura.chiba.jp +kimitsu.chiba.jp +kisarazu.chiba.jp +kozaki.chiba.jp +kujukuri.chiba.jp +kyonan.chiba.jp +matsudo.chiba.jp +midori.chiba.jp +mihama.chiba.jp +minamiboso.chiba.jp +mobara.chiba.jp +mutsuzawa.chiba.jp +nagara.chiba.jp +nagareyama.chiba.jp +narashino.chiba.jp +narita.chiba.jp +noda.chiba.jp +oamishirasato.chiba.jp +omigawa.chiba.jp +onjuku.chiba.jp +otaki.chiba.jp +sakae.chiba.jp +sakura.chiba.jp +shimofusa.chiba.jp +shirako.chiba.jp +shiroi.chiba.jp +shisui.chiba.jp +sodegaura.chiba.jp +sosa.chiba.jp +tako.chiba.jp +tateyama.chiba.jp +togane.chiba.jp +tohnosho.chiba.jp +tomisato.chiba.jp +urayasu.chiba.jp +yachimata.chiba.jp +yachiyo.chiba.jp +yokaichiba.chiba.jp +yokoshibahikari.chiba.jp +yotsukaido.chiba.jp +ainan.ehime.jp +honai.ehime.jp +ikata.ehime.jp +imabari.ehime.jp +iyo.ehime.jp +kamijima.ehime.jp +kihoku.ehime.jp +kumakogen.ehime.jp +masaki.ehime.jp +matsuno.ehime.jp +matsuyama.ehime.jp +namikata.ehime.jp +niihama.ehime.jp +ozu.ehime.jp +saijo.ehime.jp +seiyo.ehime.jp +shikokuchuo.ehime.jp +tobe.ehime.jp +toon.ehime.jp +uchiko.ehime.jp +uwajima.ehime.jp +yawatahama.ehime.jp +echizen.fukui.jp +eiheiji.fukui.jp +fukui.fukui.jp +ikeda.fukui.jp +katsuyama.fukui.jp +mihama.fukui.jp +minamiechizen.fukui.jp +obama.fukui.jp +ohi.fukui.jp +ono.fukui.jp +sabae.fukui.jp +sakai.fukui.jp +takahama.fukui.jp +tsuruga.fukui.jp +wakasa.fukui.jp +ashiya.fukuoka.jp +buzen.fukuoka.jp +chikugo.fukuoka.jp +chikuho.fukuoka.jp +chikujo.fukuoka.jp +chikushino.fukuoka.jp +chikuzen.fukuoka.jp +chuo.fukuoka.jp +dazaifu.fukuoka.jp +fukuchi.fukuoka.jp +hakata.fukuoka.jp +higashi.fukuoka.jp +hirokawa.fukuoka.jp +hisayama.fukuoka.jp +iizuka.fukuoka.jp +inatsuki.fukuoka.jp +kaho.fukuoka.jp +kasuga.fukuoka.jp +kasuya.fukuoka.jp +kawara.fukuoka.jp +keisen.fukuoka.jp +koga.fukuoka.jp +kurate.fukuoka.jp +kurogi.fukuoka.jp +kurume.fukuoka.jp +minami.fukuoka.jp +miyako.fukuoka.jp +miyama.fukuoka.jp +miyawaka.fukuoka.jp +mizumaki.fukuoka.jp +munakata.fukuoka.jp +nakagawa.fukuoka.jp +nakama.fukuoka.jp +nishi.fukuoka.jp +nogata.fukuoka.jp +ogori.fukuoka.jp +okagaki.fukuoka.jp +okawa.fukuoka.jp +oki.fukuoka.jp +omuta.fukuoka.jp +onga.fukuoka.jp +onojo.fukuoka.jp +oto.fukuoka.jp +saigawa.fukuoka.jp +sasaguri.fukuoka.jp +shingu.fukuoka.jp +shinyoshitomi.fukuoka.jp +shonai.fukuoka.jp +soeda.fukuoka.jp +sue.fukuoka.jp +tachiarai.fukuoka.jp +tagawa.fukuoka.jp +takata.fukuoka.jp +toho.fukuoka.jp +toyotsu.fukuoka.jp +tsuiki.fukuoka.jp +ukiha.fukuoka.jp +umi.fukuoka.jp +usui.fukuoka.jp +yamada.fukuoka.jp +yame.fukuoka.jp +yanagawa.fukuoka.jp +yukuhashi.fukuoka.jp +aizubange.fukushima.jp +aizumisato.fukushima.jp +aizuwakamatsu.fukushima.jp +asakawa.fukushima.jp +bandai.fukushima.jp +date.fukushima.jp +fukushima.fukushima.jp +furudono.fukushima.jp +futaba.fukushima.jp +hanawa.fukushima.jp +higashi.fukushima.jp +hirata.fukushima.jp +hirono.fukushima.jp +iitate.fukushima.jp +inawashiro.fukushima.jp +ishikawa.fukushima.jp +iwaki.fukushima.jp +izumizaki.fukushima.jp +kagamiishi.fukushima.jp +kaneyama.fukushima.jp +kawamata.fukushima.jp +kitakata.fukushima.jp +kitashiobara.fukushima.jp +koori.fukushima.jp +koriyama.fukushima.jp +kunimi.fukushima.jp +miharu.fukushima.jp +mishima.fukushima.jp +namie.fukushima.jp +nango.fukushima.jp +nishiaizu.fukushima.jp +nishigo.fukushima.jp +okuma.fukushima.jp +omotego.fukushima.jp +ono.fukushima.jp +otama.fukushima.jp +samegawa.fukushima.jp +shimogo.fukushima.jp +shirakawa.fukushima.jp +showa.fukushima.jp +soma.fukushima.jp +sukagawa.fukushima.jp +taishin.fukushima.jp +tamakawa.fukushima.jp +tanagura.fukushima.jp +tenei.fukushima.jp +yabuki.fukushima.jp +yamato.fukushima.jp +yamatsuri.fukushima.jp +yanaizu.fukushima.jp +yugawa.fukushima.jp +anpachi.gifu.jp +ena.gifu.jp +gifu.gifu.jp +ginan.gifu.jp +godo.gifu.jp +gujo.gifu.jp +hashima.gifu.jp +hichiso.gifu.jp +hida.gifu.jp +higashishirakawa.gifu.jp +ibigawa.gifu.jp +ikeda.gifu.jp +kakamigahara.gifu.jp +kani.gifu.jp +kasahara.gifu.jp +kasamatsu.gifu.jp +kawaue.gifu.jp +kitagata.gifu.jp +mino.gifu.jp +minokamo.gifu.jp +mitake.gifu.jp +mizunami.gifu.jp +motosu.gifu.jp +nakatsugawa.gifu.jp +ogaki.gifu.jp +sakahogi.gifu.jp +seki.gifu.jp +sekigahara.gifu.jp +shirakawa.gifu.jp +tajimi.gifu.jp +takayama.gifu.jp +tarui.gifu.jp +toki.gifu.jp +tomika.gifu.jp +wanouchi.gifu.jp +yamagata.gifu.jp +yaotsu.gifu.jp +yoro.gifu.jp +annaka.gunma.jp +chiyoda.gunma.jp +fujioka.gunma.jp +higashiagatsuma.gunma.jp +isesaki.gunma.jp +itakura.gunma.jp +kanna.gunma.jp +kanra.gunma.jp +katashina.gunma.jp +kawaba.gunma.jp +kiryu.gunma.jp +kusatsu.gunma.jp +maebashi.gunma.jp +meiwa.gunma.jp +midori.gunma.jp +minakami.gunma.jp +naganohara.gunma.jp +nakanojo.gunma.jp +nanmoku.gunma.jp +numata.gunma.jp +oizumi.gunma.jp +ora.gunma.jp +ota.gunma.jp +shibukawa.gunma.jp +shimonita.gunma.jp +shinto.gunma.jp +showa.gunma.jp +takasaki.gunma.jp +takayama.gunma.jp +tamamura.gunma.jp +tatebayashi.gunma.jp +tomioka.gunma.jp +tsukiyono.gunma.jp +tsumagoi.gunma.jp +ueno.gunma.jp +yoshioka.gunma.jp +asaminami.hiroshima.jp +daiwa.hiroshima.jp +etajima.hiroshima.jp +fuchu.hiroshima.jp +fukuyama.hiroshima.jp +hatsukaichi.hiroshima.jp +higashihiroshima.hiroshima.jp +hongo.hiroshima.jp +jinsekikogen.hiroshima.jp +kaita.hiroshima.jp +kui.hiroshima.jp +kumano.hiroshima.jp +kure.hiroshima.jp +mihara.hiroshima.jp +miyoshi.hiroshima.jp +naka.hiroshima.jp +onomichi.hiroshima.jp +osakikamijima.hiroshima.jp +otake.hiroshima.jp +saka.hiroshima.jp +sera.hiroshima.jp +seranishi.hiroshima.jp +shinichi.hiroshima.jp +shobara.hiroshima.jp +takehara.hiroshima.jp +abashiri.hokkaido.jp +abira.hokkaido.jp +aibetsu.hokkaido.jp +akabira.hokkaido.jp +akkeshi.hokkaido.jp +asahikawa.hokkaido.jp +ashibetsu.hokkaido.jp +ashoro.hokkaido.jp +assabu.hokkaido.jp +atsuma.hokkaido.jp +bibai.hokkaido.jp +biei.hokkaido.jp +bifuka.hokkaido.jp +bihoro.hokkaido.jp +biratori.hokkaido.jp +chippubetsu.hokkaido.jp +chitose.hokkaido.jp +date.hokkaido.jp +ebetsu.hokkaido.jp +embetsu.hokkaido.jp +eniwa.hokkaido.jp +erimo.hokkaido.jp +esan.hokkaido.jp +esashi.hokkaido.jp +fukagawa.hokkaido.jp +fukushima.hokkaido.jp +furano.hokkaido.jp +furubira.hokkaido.jp +haboro.hokkaido.jp +hakodate.hokkaido.jp +hamatonbetsu.hokkaido.jp +hidaka.hokkaido.jp +higashikagura.hokkaido.jp +higashikawa.hokkaido.jp +hiroo.hokkaido.jp +hokuryu.hokkaido.jp +hokuto.hokkaido.jp +honbetsu.hokkaido.jp +horokanai.hokkaido.jp +horonobe.hokkaido.jp +ikeda.hokkaido.jp +imakane.hokkaido.jp +ishikari.hokkaido.jp +iwamizawa.hokkaido.jp +iwanai.hokkaido.jp +kamifurano.hokkaido.jp +kamikawa.hokkaido.jp +kamishihoro.hokkaido.jp +kamisunagawa.hokkaido.jp +kamoenai.hokkaido.jp +kayabe.hokkaido.jp +kembuchi.hokkaido.jp +kikonai.hokkaido.jp +kimobetsu.hokkaido.jp +kitahiroshima.hokkaido.jp +kitami.hokkaido.jp +kiyosato.hokkaido.jp +koshimizu.hokkaido.jp +kunneppu.hokkaido.jp +kuriyama.hokkaido.jp +kuromatsunai.hokkaido.jp +kushiro.hokkaido.jp +kutchan.hokkaido.jp +kyowa.hokkaido.jp +mashike.hokkaido.jp +matsumae.hokkaido.jp +mikasa.hokkaido.jp +minamifurano.hokkaido.jp +mombetsu.hokkaido.jp +moseushi.hokkaido.jp +mukawa.hokkaido.jp +muroran.hokkaido.jp +naie.hokkaido.jp +nakagawa.hokkaido.jp +nakasatsunai.hokkaido.jp +nakatombetsu.hokkaido.jp +nanae.hokkaido.jp +nanporo.hokkaido.jp +nayoro.hokkaido.jp +nemuro.hokkaido.jp +niikappu.hokkaido.jp +niki.hokkaido.jp +nishiokoppe.hokkaido.jp +noboribetsu.hokkaido.jp +numata.hokkaido.jp +obihiro.hokkaido.jp +obira.hokkaido.jp +oketo.hokkaido.jp +okoppe.hokkaido.jp +otaru.hokkaido.jp +otobe.hokkaido.jp +otofuke.hokkaido.jp +otoineppu.hokkaido.jp +oumu.hokkaido.jp +ozora.hokkaido.jp +pippu.hokkaido.jp +rankoshi.hokkaido.jp +rebun.hokkaido.jp +rikubetsu.hokkaido.jp +rishiri.hokkaido.jp +rishirifuji.hokkaido.jp +saroma.hokkaido.jp +sarufutsu.hokkaido.jp +shakotan.hokkaido.jp +shari.hokkaido.jp +shibecha.hokkaido.jp +shibetsu.hokkaido.jp +shikabe.hokkaido.jp +shikaoi.hokkaido.jp +shimamaki.hokkaido.jp +shimizu.hokkaido.jp +shimokawa.hokkaido.jp +shinshinotsu.hokkaido.jp +shintoku.hokkaido.jp +shiranuka.hokkaido.jp +shiraoi.hokkaido.jp +shiriuchi.hokkaido.jp +sobetsu.hokkaido.jp +sunagawa.hokkaido.jp +taiki.hokkaido.jp +takasu.hokkaido.jp +takikawa.hokkaido.jp +takinoue.hokkaido.jp +teshikaga.hokkaido.jp +tobetsu.hokkaido.jp +tohma.hokkaido.jp +tomakomai.hokkaido.jp +tomari.hokkaido.jp +toya.hokkaido.jp +toyako.hokkaido.jp +toyotomi.hokkaido.jp +toyoura.hokkaido.jp +tsubetsu.hokkaido.jp +tsukigata.hokkaido.jp +urakawa.hokkaido.jp +urausu.hokkaido.jp +uryu.hokkaido.jp +utashinai.hokkaido.jp +wakkanai.hokkaido.jp +wassamu.hokkaido.jp +yakumo.hokkaido.jp +yoichi.hokkaido.jp +aioi.hyogo.jp +akashi.hyogo.jp +ako.hyogo.jp +amagasaki.hyogo.jp +aogaki.hyogo.jp +asago.hyogo.jp +ashiya.hyogo.jp +awaji.hyogo.jp +fukusaki.hyogo.jp +goshiki.hyogo.jp +harima.hyogo.jp +himeji.hyogo.jp +ichikawa.hyogo.jp +inagawa.hyogo.jp +itami.hyogo.jp +kakogawa.hyogo.jp +kamigori.hyogo.jp +kamikawa.hyogo.jp +kasai.hyogo.jp +kasuga.hyogo.jp +kawanishi.hyogo.jp +miki.hyogo.jp +minamiawaji.hyogo.jp +nishinomiya.hyogo.jp +nishiwaki.hyogo.jp +ono.hyogo.jp +sanda.hyogo.jp +sannan.hyogo.jp +sasayama.hyogo.jp +sayo.hyogo.jp +shingu.hyogo.jp +shinonsen.hyogo.jp +shiso.hyogo.jp +sumoto.hyogo.jp +taishi.hyogo.jp +taka.hyogo.jp +takarazuka.hyogo.jp +takasago.hyogo.jp +takino.hyogo.jp +tamba.hyogo.jp +tatsuno.hyogo.jp +toyooka.hyogo.jp +yabu.hyogo.jp +yashiro.hyogo.jp +yoka.hyogo.jp +yokawa.hyogo.jp +ami.ibaraki.jp +asahi.ibaraki.jp +bando.ibaraki.jp +chikusei.ibaraki.jp +daigo.ibaraki.jp +fujishiro.ibaraki.jp +hitachi.ibaraki.jp +hitachinaka.ibaraki.jp +hitachiomiya.ibaraki.jp +hitachiota.ibaraki.jp +ibaraki.ibaraki.jp +ina.ibaraki.jp +inashiki.ibaraki.jp +itako.ibaraki.jp +iwama.ibaraki.jp +joso.ibaraki.jp +kamisu.ibaraki.jp +kasama.ibaraki.jp +kashima.ibaraki.jp +kasumigaura.ibaraki.jp +koga.ibaraki.jp +miho.ibaraki.jp +mito.ibaraki.jp +moriya.ibaraki.jp +naka.ibaraki.jp +namegata.ibaraki.jp +oarai.ibaraki.jp +ogawa.ibaraki.jp +omitama.ibaraki.jp +ryugasaki.ibaraki.jp +sakai.ibaraki.jp +sakuragawa.ibaraki.jp +shimodate.ibaraki.jp +shimotsuma.ibaraki.jp +shirosato.ibaraki.jp +sowa.ibaraki.jp +suifu.ibaraki.jp +takahagi.ibaraki.jp +tamatsukuri.ibaraki.jp +tokai.ibaraki.jp +tomobe.ibaraki.jp +tone.ibaraki.jp +toride.ibaraki.jp +tsuchiura.ibaraki.jp +tsukuba.ibaraki.jp +uchihara.ibaraki.jp +ushiku.ibaraki.jp +yachiyo.ibaraki.jp +yamagata.ibaraki.jp +yawara.ibaraki.jp +yuki.ibaraki.jp +anamizu.ishikawa.jp +hakui.ishikawa.jp +hakusan.ishikawa.jp +kaga.ishikawa.jp +kahoku.ishikawa.jp +kanazawa.ishikawa.jp +kawakita.ishikawa.jp +komatsu.ishikawa.jp +nakanoto.ishikawa.jp +nanao.ishikawa.jp +nomi.ishikawa.jp +nonoichi.ishikawa.jp +noto.ishikawa.jp +shika.ishikawa.jp +suzu.ishikawa.jp +tsubata.ishikawa.jp +tsurugi.ishikawa.jp +uchinada.ishikawa.jp +wajima.ishikawa.jp +fudai.iwate.jp +fujisawa.iwate.jp +hanamaki.iwate.jp +hiraizumi.iwate.jp +hirono.iwate.jp +ichinohe.iwate.jp +ichinoseki.iwate.jp +iwaizumi.iwate.jp +iwate.iwate.jp +joboji.iwate.jp +kamaishi.iwate.jp +kanegasaki.iwate.jp +karumai.iwate.jp +kawai.iwate.jp +kitakami.iwate.jp +kuji.iwate.jp +kunohe.iwate.jp +kuzumaki.iwate.jp +miyako.iwate.jp +mizusawa.iwate.jp +morioka.iwate.jp +ninohe.iwate.jp +noda.iwate.jp +ofunato.iwate.jp +oshu.iwate.jp +otsuchi.iwate.jp +rikuzentakata.iwate.jp +shiwa.iwate.jp +shizukuishi.iwate.jp +sumita.iwate.jp +tanohata.iwate.jp +tono.iwate.jp +yahaba.iwate.jp +yamada.iwate.jp +ayagawa.kagawa.jp +higashikagawa.kagawa.jp +kanonji.kagawa.jp +kotohira.kagawa.jp +manno.kagawa.jp +marugame.kagawa.jp +mitoyo.kagawa.jp +naoshima.kagawa.jp +sanuki.kagawa.jp +tadotsu.kagawa.jp +takamatsu.kagawa.jp +tonosho.kagawa.jp +uchinomi.kagawa.jp +utazu.kagawa.jp +zentsuji.kagawa.jp +akune.kagoshima.jp +amami.kagoshima.jp +hioki.kagoshima.jp +isa.kagoshima.jp +isen.kagoshima.jp +izumi.kagoshima.jp +kagoshima.kagoshima.jp +kanoya.kagoshima.jp +kawanabe.kagoshima.jp +kinko.kagoshima.jp +kouyama.kagoshima.jp +makurazaki.kagoshima.jp +matsumoto.kagoshima.jp +minamitane.kagoshima.jp +nakatane.kagoshima.jp +nishinoomote.kagoshima.jp +satsumasendai.kagoshima.jp +soo.kagoshima.jp +tarumizu.kagoshima.jp +yusui.kagoshima.jp +aikawa.kanagawa.jp +atsugi.kanagawa.jp +ayase.kanagawa.jp +chigasaki.kanagawa.jp +ebina.kanagawa.jp +fujisawa.kanagawa.jp +hadano.kanagawa.jp +hakone.kanagawa.jp +hiratsuka.kanagawa.jp +isehara.kanagawa.jp +kaisei.kanagawa.jp +kamakura.kanagawa.jp +kiyokawa.kanagawa.jp +matsuda.kanagawa.jp +minamiashigara.kanagawa.jp +miura.kanagawa.jp +nakai.kanagawa.jp +ninomiya.kanagawa.jp +odawara.kanagawa.jp +oi.kanagawa.jp +oiso.kanagawa.jp +sagamihara.kanagawa.jp +samukawa.kanagawa.jp +tsukui.kanagawa.jp +yamakita.kanagawa.jp +yamato.kanagawa.jp +yokosuka.kanagawa.jp +yugawara.kanagawa.jp +zama.kanagawa.jp +zushi.kanagawa.jp +aki.kochi.jp +geisei.kochi.jp +hidaka.kochi.jp +higashitsuno.kochi.jp +ino.kochi.jp +kagami.kochi.jp +kami.kochi.jp +kitagawa.kochi.jp +kochi.kochi.jp +mihara.kochi.jp +motoyama.kochi.jp +muroto.kochi.jp +nahari.kochi.jp +nakamura.kochi.jp +nankoku.kochi.jp +nishitosa.kochi.jp +niyodogawa.kochi.jp +ochi.kochi.jp +okawa.kochi.jp +otoyo.kochi.jp +otsuki.kochi.jp +sakawa.kochi.jp +sukumo.kochi.jp +susaki.kochi.jp +tosa.kochi.jp +tosashimizu.kochi.jp +toyo.kochi.jp +tsuno.kochi.jp +umaji.kochi.jp +yasuda.kochi.jp +yusuhara.kochi.jp +amakusa.kumamoto.jp +arao.kumamoto.jp +aso.kumamoto.jp +choyo.kumamoto.jp +gyokuto.kumamoto.jp +kamiamakusa.kumamoto.jp +kikuchi.kumamoto.jp +kumamoto.kumamoto.jp +mashiki.kumamoto.jp +mifune.kumamoto.jp +minamata.kumamoto.jp +minamioguni.kumamoto.jp +nagasu.kumamoto.jp +nishihara.kumamoto.jp +oguni.kumamoto.jp +ozu.kumamoto.jp +sumoto.kumamoto.jp +takamori.kumamoto.jp +uki.kumamoto.jp +uto.kumamoto.jp +yamaga.kumamoto.jp +yamato.kumamoto.jp +yatsushiro.kumamoto.jp +ayabe.kyoto.jp +fukuchiyama.kyoto.jp +higashiyama.kyoto.jp +ide.kyoto.jp +ine.kyoto.jp +joyo.kyoto.jp +kameoka.kyoto.jp +kamo.kyoto.jp +kita.kyoto.jp +kizu.kyoto.jp +kumiyama.kyoto.jp +kyotamba.kyoto.jp +kyotanabe.kyoto.jp +kyotango.kyoto.jp +maizuru.kyoto.jp +minami.kyoto.jp +minamiyamashiro.kyoto.jp +miyazu.kyoto.jp +muko.kyoto.jp +nagaokakyo.kyoto.jp +nakagyo.kyoto.jp +nantan.kyoto.jp +oyamazaki.kyoto.jp +sakyo.kyoto.jp +seika.kyoto.jp +tanabe.kyoto.jp +uji.kyoto.jp +ujitawara.kyoto.jp +wazuka.kyoto.jp +yamashina.kyoto.jp +yawata.kyoto.jp +asahi.mie.jp +inabe.mie.jp +ise.mie.jp +kameyama.mie.jp +kawagoe.mie.jp +kiho.mie.jp +kisosaki.mie.jp +kiwa.mie.jp +komono.mie.jp +kumano.mie.jp +kuwana.mie.jp +matsusaka.mie.jp +meiwa.mie.jp +mihama.mie.jp +minamiise.mie.jp +misugi.mie.jp +miyama.mie.jp +nabari.mie.jp +shima.mie.jp +suzuka.mie.jp +tado.mie.jp +taiki.mie.jp +taki.mie.jp +tamaki.mie.jp +toba.mie.jp +tsu.mie.jp +udono.mie.jp +ureshino.mie.jp +watarai.mie.jp +yokkaichi.mie.jp +furukawa.miyagi.jp +higashimatsushima.miyagi.jp +ishinomaki.miyagi.jp +iwanuma.miyagi.jp +kakuda.miyagi.jp +kami.miyagi.jp +kawasaki.miyagi.jp +marumori.miyagi.jp +matsushima.miyagi.jp +minamisanriku.miyagi.jp +misato.miyagi.jp +murata.miyagi.jp +natori.miyagi.jp +ogawara.miyagi.jp +ohira.miyagi.jp +onagawa.miyagi.jp +osaki.miyagi.jp +rifu.miyagi.jp +semine.miyagi.jp +shibata.miyagi.jp +shichikashuku.miyagi.jp +shikama.miyagi.jp +shiogama.miyagi.jp +shiroishi.miyagi.jp +tagajo.miyagi.jp +taiwa.miyagi.jp +tome.miyagi.jp +tomiya.miyagi.jp +wakuya.miyagi.jp +watari.miyagi.jp +yamamoto.miyagi.jp +zao.miyagi.jp +aya.miyazaki.jp +ebino.miyazaki.jp +gokase.miyazaki.jp +hyuga.miyazaki.jp +kadogawa.miyazaki.jp +kawaminami.miyazaki.jp +kijo.miyazaki.jp +kitagawa.miyazaki.jp +kitakata.miyazaki.jp +kitaura.miyazaki.jp +kobayashi.miyazaki.jp +kunitomi.miyazaki.jp +kushima.miyazaki.jp +mimata.miyazaki.jp +miyakonojo.miyazaki.jp +miyazaki.miyazaki.jp +morotsuka.miyazaki.jp +nichinan.miyazaki.jp +nishimera.miyazaki.jp +nobeoka.miyazaki.jp +saito.miyazaki.jp +shiiba.miyazaki.jp +shintomi.miyazaki.jp +takaharu.miyazaki.jp +takanabe.miyazaki.jp +takazaki.miyazaki.jp +tsuno.miyazaki.jp +achi.nagano.jp +agematsu.nagano.jp +anan.nagano.jp +aoki.nagano.jp +asahi.nagano.jp +azumino.nagano.jp +chikuhoku.nagano.jp +chikuma.nagano.jp +chino.nagano.jp +fujimi.nagano.jp +hakuba.nagano.jp +hara.nagano.jp +hiraya.nagano.jp +iida.nagano.jp +iijima.nagano.jp +iiyama.nagano.jp +iizuna.nagano.jp +ikeda.nagano.jp +ikusaka.nagano.jp +ina.nagano.jp +karuizawa.nagano.jp +kawakami.nagano.jp +kiso.nagano.jp +kisofukushima.nagano.jp +kitaaiki.nagano.jp +komagane.nagano.jp +komoro.nagano.jp +matsukawa.nagano.jp +matsumoto.nagano.jp +miasa.nagano.jp +minamiaiki.nagano.jp +minamimaki.nagano.jp +minamiminowa.nagano.jp +minowa.nagano.jp +miyada.nagano.jp +miyota.nagano.jp +mochizuki.nagano.jp +nagano.nagano.jp +nagawa.nagano.jp +nagiso.nagano.jp +nakagawa.nagano.jp +nakano.nagano.jp +nozawaonsen.nagano.jp +obuse.nagano.jp +ogawa.nagano.jp +okaya.nagano.jp +omachi.nagano.jp +omi.nagano.jp +ookuwa.nagano.jp +ooshika.nagano.jp +otaki.nagano.jp +otari.nagano.jp +sakae.nagano.jp +sakaki.nagano.jp +saku.nagano.jp +sakuho.nagano.jp +shimosuwa.nagano.jp +shinanomachi.nagano.jp +shiojiri.nagano.jp +suwa.nagano.jp +suzaka.nagano.jp +takagi.nagano.jp +takamori.nagano.jp +takayama.nagano.jp +tateshina.nagano.jp +tatsuno.nagano.jp +togakushi.nagano.jp +togura.nagano.jp +tomi.nagano.jp +ueda.nagano.jp +wada.nagano.jp +yamagata.nagano.jp +yamanouchi.nagano.jp +yasaka.nagano.jp +yasuoka.nagano.jp +chijiwa.nagasaki.jp +futsu.nagasaki.jp +goto.nagasaki.jp +hasami.nagasaki.jp +hirado.nagasaki.jp +iki.nagasaki.jp +isahaya.nagasaki.jp +kawatana.nagasaki.jp +kuchinotsu.nagasaki.jp +matsuura.nagasaki.jp +nagasaki.nagasaki.jp +obama.nagasaki.jp +omura.nagasaki.jp +oseto.nagasaki.jp +saikai.nagasaki.jp +sasebo.nagasaki.jp +seihi.nagasaki.jp +shimabara.nagasaki.jp +shinkamigoto.nagasaki.jp +togitsu.nagasaki.jp +tsushima.nagasaki.jp +unzen.nagasaki.jp +ando.nara.jp +gose.nara.jp +heguri.nara.jp +higashiyoshino.nara.jp +ikaruga.nara.jp +ikoma.nara.jp +kamikitayama.nara.jp +kanmaki.nara.jp +kashiba.nara.jp +kashihara.nara.jp +katsuragi.nara.jp +kawai.nara.jp +kawakami.nara.jp +kawanishi.nara.jp +koryo.nara.jp +kurotaki.nara.jp +mitsue.nara.jp +miyake.nara.jp +nara.nara.jp +nosegawa.nara.jp +oji.nara.jp +ouda.nara.jp +oyodo.nara.jp +sakurai.nara.jp +sango.nara.jp +shimoichi.nara.jp +shimokitayama.nara.jp +shinjo.nara.jp +soni.nara.jp +takatori.nara.jp +tawaramoto.nara.jp +tenkawa.nara.jp +tenri.nara.jp +uda.nara.jp +yamatokoriyama.nara.jp +yamatotakada.nara.jp +yamazoe.nara.jp +yoshino.nara.jp +aga.niigata.jp +agano.niigata.jp +gosen.niigata.jp +itoigawa.niigata.jp +izumozaki.niigata.jp +joetsu.niigata.jp +kamo.niigata.jp +kariwa.niigata.jp +kashiwazaki.niigata.jp +minamiuonuma.niigata.jp +mitsuke.niigata.jp +muika.niigata.jp +murakami.niigata.jp +myoko.niigata.jp +nagaoka.niigata.jp +niigata.niigata.jp +ojiya.niigata.jp +omi.niigata.jp +sado.niigata.jp +sanjo.niigata.jp +seiro.niigata.jp +seirou.niigata.jp +sekikawa.niigata.jp +shibata.niigata.jp +tagami.niigata.jp +tainai.niigata.jp +tochio.niigata.jp +tokamachi.niigata.jp +tsubame.niigata.jp +tsunan.niigata.jp +uonuma.niigata.jp +yahiko.niigata.jp +yoita.niigata.jp +yuzawa.niigata.jp +beppu.oita.jp +bungoono.oita.jp +bungotakada.oita.jp +hasama.oita.jp +hiji.oita.jp +himeshima.oita.jp +hita.oita.jp +kamitsue.oita.jp +kokonoe.oita.jp +kuju.oita.jp +kunisaki.oita.jp +kusu.oita.jp +oita.oita.jp +saiki.oita.jp +taketa.oita.jp +tsukumi.oita.jp +usa.oita.jp +usuki.oita.jp +yufu.oita.jp +akaiwa.okayama.jp +asakuchi.okayama.jp +bizen.okayama.jp +hayashima.okayama.jp +ibara.okayama.jp +kagamino.okayama.jp +kasaoka.okayama.jp +kibichuo.okayama.jp +kumenan.okayama.jp +kurashiki.okayama.jp +maniwa.okayama.jp +misaki.okayama.jp +nagi.okayama.jp +niimi.okayama.jp +nishiawakura.okayama.jp +okayama.okayama.jp +satosho.okayama.jp +setouchi.okayama.jp +shinjo.okayama.jp +shoo.okayama.jp +soja.okayama.jp +takahashi.okayama.jp +tamano.okayama.jp +tsuyama.okayama.jp +wake.okayama.jp +yakage.okayama.jp +aguni.okinawa.jp +ginowan.okinawa.jp +ginoza.okinawa.jp +gushikami.okinawa.jp +haebaru.okinawa.jp +higashi.okinawa.jp +hirara.okinawa.jp +iheya.okinawa.jp +ishigaki.okinawa.jp +ishikawa.okinawa.jp +itoman.okinawa.jp +izena.okinawa.jp +kadena.okinawa.jp +kin.okinawa.jp +kitadaito.okinawa.jp +kitanakagusuku.okinawa.jp +kumejima.okinawa.jp +kunigami.okinawa.jp +minamidaito.okinawa.jp +motobu.okinawa.jp +nago.okinawa.jp +naha.okinawa.jp +nakagusuku.okinawa.jp +nakijin.okinawa.jp +nanjo.okinawa.jp +nishihara.okinawa.jp +ogimi.okinawa.jp +okinawa.okinawa.jp +onna.okinawa.jp +shimoji.okinawa.jp +taketomi.okinawa.jp +tarama.okinawa.jp +tokashiki.okinawa.jp +tomigusuku.okinawa.jp +tonaki.okinawa.jp +urasoe.okinawa.jp +uruma.okinawa.jp +yaese.okinawa.jp +yomitan.okinawa.jp +yonabaru.okinawa.jp +yonaguni.okinawa.jp +zamami.okinawa.jp +abeno.osaka.jp +chihayaakasaka.osaka.jp +chuo.osaka.jp +daito.osaka.jp +fujiidera.osaka.jp +habikino.osaka.jp +hannan.osaka.jp +higashiosaka.osaka.jp +higashisumiyoshi.osaka.jp +higashiyodogawa.osaka.jp +hirakata.osaka.jp +ibaraki.osaka.jp +ikeda.osaka.jp +izumi.osaka.jp +izumiotsu.osaka.jp +izumisano.osaka.jp +kadoma.osaka.jp +kaizuka.osaka.jp +kanan.osaka.jp +kashiwara.osaka.jp +katano.osaka.jp +kawachinagano.osaka.jp +kishiwada.osaka.jp +kita.osaka.jp +kumatori.osaka.jp +matsubara.osaka.jp +minato.osaka.jp +minoh.osaka.jp +misaki.osaka.jp +moriguchi.osaka.jp +neyagawa.osaka.jp +nishi.osaka.jp +nose.osaka.jp +osakasayama.osaka.jp +sakai.osaka.jp +sayama.osaka.jp +sennan.osaka.jp +settsu.osaka.jp +shijonawate.osaka.jp +shimamoto.osaka.jp +suita.osaka.jp +tadaoka.osaka.jp +taishi.osaka.jp +tajiri.osaka.jp +takaishi.osaka.jp +takatsuki.osaka.jp +tondabayashi.osaka.jp +toyonaka.osaka.jp +toyono.osaka.jp +yao.osaka.jp +ariake.saga.jp +arita.saga.jp +fukudomi.saga.jp +genkai.saga.jp +hamatama.saga.jp +hizen.saga.jp +imari.saga.jp +kamimine.saga.jp +kanzaki.saga.jp +karatsu.saga.jp +kashima.saga.jp +kitagata.saga.jp +kitahata.saga.jp +kiyama.saga.jp +kouhoku.saga.jp +kyuragi.saga.jp +nishiarita.saga.jp +ogi.saga.jp +omachi.saga.jp +ouchi.saga.jp +saga.saga.jp +shiroishi.saga.jp +taku.saga.jp +tara.saga.jp +tosu.saga.jp +yoshinogari.saga.jp +arakawa.saitama.jp +asaka.saitama.jp +chichibu.saitama.jp +fujimi.saitama.jp +fujimino.saitama.jp +fukaya.saitama.jp +hanno.saitama.jp +hanyu.saitama.jp +hasuda.saitama.jp +hatogaya.saitama.jp +hatoyama.saitama.jp +hidaka.saitama.jp +higashichichibu.saitama.jp +higashimatsuyama.saitama.jp +honjo.saitama.jp +ina.saitama.jp +iruma.saitama.jp +iwatsuki.saitama.jp +kamiizumi.saitama.jp +kamikawa.saitama.jp +kamisato.saitama.jp +kasukabe.saitama.jp +kawagoe.saitama.jp +kawaguchi.saitama.jp +kawajima.saitama.jp +kazo.saitama.jp +kitamoto.saitama.jp +koshigaya.saitama.jp +kounosu.saitama.jp +kuki.saitama.jp +kumagaya.saitama.jp +matsubushi.saitama.jp +minano.saitama.jp +misato.saitama.jp +miyashiro.saitama.jp +miyoshi.saitama.jp +moroyama.saitama.jp +nagatoro.saitama.jp +namegawa.saitama.jp +niiza.saitama.jp +ogano.saitama.jp +ogawa.saitama.jp +ogose.saitama.jp +okegawa.saitama.jp +omiya.saitama.jp +otaki.saitama.jp +ranzan.saitama.jp +ryokami.saitama.jp +saitama.saitama.jp +sakado.saitama.jp +satte.saitama.jp +sayama.saitama.jp +shiki.saitama.jp +shiraoka.saitama.jp +soka.saitama.jp +sugito.saitama.jp +toda.saitama.jp +tokigawa.saitama.jp +tokorozawa.saitama.jp +tsurugashima.saitama.jp +urawa.saitama.jp +warabi.saitama.jp +yashio.saitama.jp +yokoze.saitama.jp +yono.saitama.jp +yorii.saitama.jp +yoshida.saitama.jp +yoshikawa.saitama.jp +yoshimi.saitama.jp +aisho.shiga.jp +gamo.shiga.jp +higashiomi.shiga.jp +hikone.shiga.jp +koka.shiga.jp +konan.shiga.jp +kosei.shiga.jp +koto.shiga.jp +kusatsu.shiga.jp +maibara.shiga.jp +moriyama.shiga.jp +nagahama.shiga.jp +nishiazai.shiga.jp +notogawa.shiga.jp +omihachiman.shiga.jp +otsu.shiga.jp +ritto.shiga.jp +ryuoh.shiga.jp +takashima.shiga.jp +takatsuki.shiga.jp +torahime.shiga.jp +toyosato.shiga.jp +yasu.shiga.jp +akagi.shimane.jp +ama.shimane.jp +gotsu.shimane.jp +hamada.shimane.jp +higashiizumo.shimane.jp +hikawa.shimane.jp +hikimi.shimane.jp +izumo.shimane.jp +kakinoki.shimane.jp +masuda.shimane.jp +matsue.shimane.jp +misato.shimane.jp +nishinoshima.shimane.jp +ohda.shimane.jp +okinoshima.shimane.jp +okuizumo.shimane.jp +shimane.shimane.jp +tamayu.shimane.jp +tsuwano.shimane.jp +unnan.shimane.jp +yakumo.shimane.jp +yasugi.shimane.jp +yatsuka.shimane.jp +arai.shizuoka.jp +atami.shizuoka.jp +fuji.shizuoka.jp +fujieda.shizuoka.jp +fujikawa.shizuoka.jp +fujinomiya.shizuoka.jp +fukuroi.shizuoka.jp +gotemba.shizuoka.jp +haibara.shizuoka.jp +hamamatsu.shizuoka.jp +higashiizu.shizuoka.jp +ito.shizuoka.jp +iwata.shizuoka.jp +izu.shizuoka.jp +izunokuni.shizuoka.jp +kakegawa.shizuoka.jp +kannami.shizuoka.jp +kawanehon.shizuoka.jp +kawazu.shizuoka.jp +kikugawa.shizuoka.jp +kosai.shizuoka.jp +makinohara.shizuoka.jp +matsuzaki.shizuoka.jp +minamiizu.shizuoka.jp +mishima.shizuoka.jp +morimachi.shizuoka.jp +nishiizu.shizuoka.jp +numazu.shizuoka.jp +omaezaki.shizuoka.jp +shimada.shizuoka.jp +shimizu.shizuoka.jp +shimoda.shizuoka.jp +shizuoka.shizuoka.jp +susono.shizuoka.jp +yaizu.shizuoka.jp +yoshida.shizuoka.jp +ashikaga.tochigi.jp +bato.tochigi.jp +haga.tochigi.jp +ichikai.tochigi.jp +iwafune.tochigi.jp +kaminokawa.tochigi.jp +kanuma.tochigi.jp +karasuyama.tochigi.jp +kuroiso.tochigi.jp +mashiko.tochigi.jp +mibu.tochigi.jp +moka.tochigi.jp +motegi.tochigi.jp +nasu.tochigi.jp +nasushiobara.tochigi.jp +nikko.tochigi.jp +nishikata.tochigi.jp +nogi.tochigi.jp +ohira.tochigi.jp +ohtawara.tochigi.jp +oyama.tochigi.jp +sakura.tochigi.jp +sano.tochigi.jp +shimotsuke.tochigi.jp +shioya.tochigi.jp +takanezawa.tochigi.jp +tochigi.tochigi.jp +tsuga.tochigi.jp +ujiie.tochigi.jp +utsunomiya.tochigi.jp +yaita.tochigi.jp +aizumi.tokushima.jp +anan.tokushima.jp +ichiba.tokushima.jp +itano.tokushima.jp +kainan.tokushima.jp +komatsushima.tokushima.jp +matsushige.tokushima.jp +mima.tokushima.jp +minami.tokushima.jp +miyoshi.tokushima.jp +mugi.tokushima.jp +nakagawa.tokushima.jp +naruto.tokushima.jp +sanagochi.tokushima.jp +shishikui.tokushima.jp +tokushima.tokushima.jp +wajiki.tokushima.jp +adachi.tokyo.jp +akiruno.tokyo.jp +akishima.tokyo.jp +aogashima.tokyo.jp +arakawa.tokyo.jp +bunkyo.tokyo.jp +chiyoda.tokyo.jp +chofu.tokyo.jp +chuo.tokyo.jp +edogawa.tokyo.jp +fuchu.tokyo.jp +fussa.tokyo.jp +hachijo.tokyo.jp +hachioji.tokyo.jp +hamura.tokyo.jp +higashikurume.tokyo.jp +higashimurayama.tokyo.jp +higashiyamato.tokyo.jp +hino.tokyo.jp +hinode.tokyo.jp +hinohara.tokyo.jp +inagi.tokyo.jp +itabashi.tokyo.jp +katsushika.tokyo.jp +kita.tokyo.jp +kiyose.tokyo.jp +kodaira.tokyo.jp +koganei.tokyo.jp +kokubunji.tokyo.jp +komae.tokyo.jp +koto.tokyo.jp +kouzushima.tokyo.jp +kunitachi.tokyo.jp +machida.tokyo.jp +meguro.tokyo.jp +minato.tokyo.jp +mitaka.tokyo.jp +mizuho.tokyo.jp +musashimurayama.tokyo.jp +musashino.tokyo.jp +nakano.tokyo.jp +nerima.tokyo.jp +ogasawara.tokyo.jp +okutama.tokyo.jp +ome.tokyo.jp +oshima.tokyo.jp +ota.tokyo.jp +setagaya.tokyo.jp +shibuya.tokyo.jp +shinagawa.tokyo.jp +shinjuku.tokyo.jp +suginami.tokyo.jp +sumida.tokyo.jp +tachikawa.tokyo.jp +taito.tokyo.jp +tama.tokyo.jp +toshima.tokyo.jp +chizu.tottori.jp +hino.tottori.jp +kawahara.tottori.jp +koge.tottori.jp +kotoura.tottori.jp +misasa.tottori.jp +nanbu.tottori.jp +nichinan.tottori.jp +sakaiminato.tottori.jp +tottori.tottori.jp +wakasa.tottori.jp +yazu.tottori.jp +yonago.tottori.jp +asahi.toyama.jp +fuchu.toyama.jp +fukumitsu.toyama.jp +funahashi.toyama.jp +himi.toyama.jp +imizu.toyama.jp +inami.toyama.jp +johana.toyama.jp +kamiichi.toyama.jp +kurobe.toyama.jp +nakaniikawa.toyama.jp +namerikawa.toyama.jp +nanto.toyama.jp +nyuzen.toyama.jp +oyabe.toyama.jp +taira.toyama.jp +takaoka.toyama.jp +tateyama.toyama.jp +toga.toyama.jp +tonami.toyama.jp +toyama.toyama.jp +unazuki.toyama.jp +uozu.toyama.jp +yamada.toyama.jp +arida.wakayama.jp +aridagawa.wakayama.jp +gobo.wakayama.jp +hashimoto.wakayama.jp +hidaka.wakayama.jp +hirogawa.wakayama.jp +inami.wakayama.jp +iwade.wakayama.jp +kainan.wakayama.jp +kamitonda.wakayama.jp +katsuragi.wakayama.jp +kimino.wakayama.jp +kinokawa.wakayama.jp +kitayama.wakayama.jp +koya.wakayama.jp +koza.wakayama.jp +kozagawa.wakayama.jp +kudoyama.wakayama.jp +kushimoto.wakayama.jp +mihama.wakayama.jp +misato.wakayama.jp +nachikatsuura.wakayama.jp +shingu.wakayama.jp +shirahama.wakayama.jp +taiji.wakayama.jp +tanabe.wakayama.jp +wakayama.wakayama.jp +yuasa.wakayama.jp +yura.wakayama.jp +asahi.yamagata.jp +funagata.yamagata.jp +higashine.yamagata.jp +iide.yamagata.jp +kahoku.yamagata.jp +kaminoyama.yamagata.jp +kaneyama.yamagata.jp +kawanishi.yamagata.jp +mamurogawa.yamagata.jp +mikawa.yamagata.jp +murayama.yamagata.jp +nagai.yamagata.jp +nakayama.yamagata.jp +nanyo.yamagata.jp +nishikawa.yamagata.jp +obanazawa.yamagata.jp +oe.yamagata.jp +oguni.yamagata.jp +ohkura.yamagata.jp +oishida.yamagata.jp +sagae.yamagata.jp +sakata.yamagata.jp +sakegawa.yamagata.jp +shinjo.yamagata.jp +shirataka.yamagata.jp +shonai.yamagata.jp +takahata.yamagata.jp +tendo.yamagata.jp +tozawa.yamagata.jp +tsuruoka.yamagata.jp +yamagata.yamagata.jp +yamanobe.yamagata.jp +yonezawa.yamagata.jp +yuza.yamagata.jp +abu.yamaguchi.jp +hagi.yamaguchi.jp +hikari.yamaguchi.jp +hofu.yamaguchi.jp +iwakuni.yamaguchi.jp +kudamatsu.yamaguchi.jp +mitou.yamaguchi.jp +nagato.yamaguchi.jp +oshima.yamaguchi.jp +shimonoseki.yamaguchi.jp +shunan.yamaguchi.jp +tabuse.yamaguchi.jp +tokuyama.yamaguchi.jp +toyota.yamaguchi.jp +ube.yamaguchi.jp +yuu.yamaguchi.jp +chuo.yamanashi.jp +doshi.yamanashi.jp +fuefuki.yamanashi.jp +fujikawa.yamanashi.jp +fujikawaguchiko.yamanashi.jp +fujiyoshida.yamanashi.jp +hayakawa.yamanashi.jp +hokuto.yamanashi.jp +ichikawamisato.yamanashi.jp +kai.yamanashi.jp +kofu.yamanashi.jp +koshu.yamanashi.jp +kosuge.yamanashi.jp +minami-alps.yamanashi.jp +minobu.yamanashi.jp +nakamichi.yamanashi.jp +nanbu.yamanashi.jp +narusawa.yamanashi.jp +nirasaki.yamanashi.jp +nishikatsura.yamanashi.jp +oshino.yamanashi.jp +otsuki.yamanashi.jp +showa.yamanashi.jp +tabayama.yamanashi.jp +tsuru.yamanashi.jp +uenohara.yamanashi.jp +yamanakako.yamanashi.jp +yamanashi.yamanashi.jp + +// ke : http://www.kenic.or.ke/index.php/en/ke-domains/ke-domains +ke +ac.ke +co.ke +go.ke +info.ke +me.ke +mobi.ke +ne.ke +or.ke +sc.ke + +// kg : http://www.domain.kg/dmn_n.html +kg +com.kg +edu.kg +gov.kg +mil.kg +net.kg +org.kg + +// kh : https://trc.gov.kh +// Submitted by khnic@trc.gov.kh +kh +com.kh +edu.kh +gov.kh +net.kh +org.kh + +// ki : https://www.iana.org/domains/root/db/ki.html +ki +biz.ki +com.ki +edu.ki +gov.ki +info.ki +net.ki +org.ki + +// km : https://www.domaine.km/ +km +ass.km +com.km +edu.km +gov.km +mil.km +nom.km +org.km +prd.km +tm.km +// These are only mentioned as proposed suggestions at domaine.km, but +// https://en.wikipedia.org/wiki/.km says they're available for registration: +asso.km +coop.km +gouv.km +medecin.km +notaires.km +pharmaciens.km +presse.km +veterinaire.km + +// kn : https://nic.kn/ +kn +edu.kn +gov.kn +net.kn +org.kn + +// kp : http://www.star.co.kp/ +kp +com.kp +edu.kp +gov.kp +org.kp +rep.kp +tra.kp + +// kr : https://www.iana.org/domains/root/db/kr.html +// see also: https://krnic.kisa.or.kr/jsp/infoboard/law/domBylawsReg.jsp +kr +ac.kr +ai.kr +co.kr +es.kr +go.kr +hs.kr +io.kr +it.kr +kg.kr +me.kr +mil.kr +ms.kr +ne.kr +or.kr +pe.kr +re.kr +sc.kr +// kr geographical names +busan.kr +chungbuk.kr +chungnam.kr +daegu.kr +daejeon.kr +gangwon.kr +gwangju.kr +gyeongbuk.kr +gyeonggi.kr +gyeongnam.kr +incheon.kr +jeju.kr +jeonbuk.kr +jeonnam.kr +seoul.kr +ulsan.kr + +// kw : https://www.nic.kw/policies/ +// Confirmed by registry +kw +com.kw +edu.kw +emb.kw +gov.kw +ind.kw +net.kw +org.kw + +// ky : https://www.ofreg.ky/ict/kydomain-introduction +ky +com.ky +edu.ky +net.ky +org.ky + +// kz : https://www.iana.org/domains/root/db/kz.html +// see also: http://www.nic.kz/rules/index.jsp +kz +com.kz +edu.kz +gov.kz +mil.kz +net.kz +org.kz + +// la : https://www.iana.org/domains/root/db/la.html +// Submitted by registry +la +com.la +edu.la +gov.la +info.la +int.la +net.la +org.la +per.la + +// lb : https://www.iana.org/domains/root/db/lb.html +// Submitted by registry +lb +com.lb +edu.lb +gov.lb +net.lb +org.lb + +// lc : https://www.iana.org/domains/root/db/lc.html +// see also: http://www.nic.lc/rules.htm +lc +co.lc +com.lc +edu.lc +gov.lc +net.lc +org.lc + +// li : https://www.iana.org/domains/root/db/li.html +li + +// lk : https://www.iana.org/domains/root/db/lk.html +lk +ac.lk +assn.lk +com.lk +edu.lk +gov.lk +grp.lk +hotel.lk +int.lk +ltd.lk +net.lk +ngo.lk +org.lk +sch.lk +soc.lk +web.lk + +// lr : http://psg.com/dns/lr/lr.txt +// Submitted by registry +lr +com.lr +edu.lr +gov.lr +net.lr +org.lr + +// ls : http://www.nic.ls/ +// Confirmed by registry +ls +ac.ls +biz.ls +co.ls +edu.ls +gov.ls +info.ls +net.ls +org.ls +sc.ls + +// lt : https://www.domreg.lt/ +lt +gov.lt + +// lu : http://www.dns.lu/en/ +lu + +// lv : https://www.iana.org/domains/root/db/lv.html +lv +asn.lv +com.lv +conf.lv +edu.lv +gov.lv +id.lv +mil.lv +net.lv +org.lv + +// ly : http://www.nic.ly/regulations.php +ly +com.ly +edu.ly +gov.ly +id.ly +med.ly +net.ly +org.ly +plc.ly +sch.ly + +// ma : http://www.anrt.ma/fr/admin/download/upload/file_fr782.pdf +ma +ac.ma +co.ma +gov.ma +net.ma +org.ma +press.ma + +// mc : http://www.nic.mc/ +mc +asso.mc +tm.mc + +// md : https://www.iana.org/domains/root/db/md.html +md + +// me : https://www.iana.org/domains/root/db/me.html +me +ac.me +co.me +edu.me +gov.me +its.me +net.me +org.me +priv.me + +// mg : https://nic.mg +mg +co.mg +com.mg +edu.mg +gov.mg +mil.mg +nom.mg +org.mg +prd.mg + +// mh : https://www.iana.org/domains/root/db/mh.html +mh + +// mil : https://www.iana.org/domains/root/db/mil.html +mil + +// mk : https://marnet.mk/ -> "ПРАВИЛНИК" +mk +com.mk +edu.mk +gov.mk +inf.mk +name.mk +net.mk +org.mk + +// ml : https://www.iana.org/domains/root/db/ml.html +// Confirmed by Boubacar NDIAYE 2024-12-31 +ml +ac.ml +art.ml +asso.ml +com.ml +edu.ml +gouv.ml +gov.ml +info.ml +inst.ml +net.ml +org.ml +pr.ml +presse.ml + +// mm : https://www.iana.org/domains/root/db/mm.html +*.mm + +// mn : https://www.iana.org/domains/root/db/mn.html +mn +edu.mn +gov.mn +org.mn + +// mo : https://www.monic.mo/ +mo +com.mo +edu.mo +gov.mo +net.mo +org.mo + +// mobi : https://www.iana.org/domains/root/db/mobi.html +mobi + +// mp : http://get.mp/ +mp + +// mq : https://www.iana.org/domains/root/db/mq.html +mq + +// mr : https://www.iana.org/domains/root/db/mr.html +mr +gov.mr + +// ms : https://www.iana.org/domains/root/db/ms.html +ms +com.ms +edu.ms +gov.ms +net.ms +org.ms + +// mt : https://www.nic.org.mt/go/policy +// Submitted by registry +mt +com.mt +edu.mt +net.mt +org.mt + +// mu : https://www.iana.org/domains/root/db/mu.html +mu +ac.mu +co.mu +com.mu +gov.mu +net.mu +or.mu +org.mu + +// museum : https://welcome.museum/wp-content/uploads/2018/05/20180525-Registration-Policy-MUSEUM-EN_VF-2.pdf https://welcome.museum/buy-your-dot-museum-2/ +museum + +// mv : https://www.iana.org/domains/root/db/mv.html +// "mv" included because, contra Wikipedia, google.mv exists. +mv +aero.mv +biz.mv +com.mv +coop.mv +edu.mv +gov.mv +info.mv +int.mv +mil.mv +museum.mv +name.mv +net.mv +org.mv +pro.mv + +// mw : http://www.registrar.mw/ +mw +ac.mw +biz.mw +co.mw +com.mw +coop.mw +edu.mw +gov.mw +int.mw +net.mw +org.mw + +// mx : http://www.nic.mx/ +// Submitted by registry +mx +com.mx +edu.mx +gob.mx +net.mx +org.mx + +// my : http://www.mynic.my/ +// Available strings: https://mynic.my/resources/domains/buying-a-domain/ +my +biz.my +com.my +edu.my +gov.my +mil.my +name.my +net.my +org.my + +// mz : http://www.uem.mz/ +// Submitted by registry +mz +ac.mz +adv.mz +co.mz +edu.mz +gov.mz +mil.mz +net.mz +org.mz + +// na : http://www.na-nic.com.na/ +na +alt.na +co.na +com.na +gov.na +net.na +org.na + +// name : http://www.nic.name/ +// Regarding 2LDs: https://github.com/publicsuffix/list/issues/2306 +name + +// nc : http://www.cctld.nc/ +nc +asso.nc +nom.nc + +// ne : https://www.iana.org/domains/root/db/ne.html +ne + +// net : https://www.iana.org/domains/root/db/net.html +net + +// nf : https://www.iana.org/domains/root/db/nf.html +nf +arts.nf +com.nf +firm.nf +info.nf +net.nf +other.nf +per.nf +rec.nf +store.nf +web.nf + +// ng : https://www.nira.org.ng/ +ng +com.ng +edu.ng +gov.ng +i.ng +mil.ng +mobi.ng +name.ng +net.ng +org.ng +sch.ng + +// ni : https://www.nic.ni/ +ni +ac.ni +biz.ni +co.ni +com.ni +edu.ni +gob.ni +in.ni +info.ni +int.ni +mil.ni +net.ni +nom.ni +org.ni +web.ni + +// nl : https://www.sidn.nl/ +nl + +// no : https://www.norid.no/en/om-domenenavn/regelverk-for-no/ +// Norid geographical second level domains : https://www.norid.no/en/om-domenenavn/regelverk-for-no/vedlegg-b/ +// Norid category second level domains : https://www.norid.no/en/om-domenenavn/regelverk-for-no/vedlegg-c/ +// Norid category second-level domains managed by parties other than Norid : https://www.norid.no/en/om-domenenavn/regelverk-for-no/vedlegg-d/ +// RSS feed: https://teknisk.norid.no/en/feed/ +no +// Norid category second level domains : https://www.norid.no/en/om-domenenavn/regelverk-for-no/vedlegg-c/ +fhs.no +folkebibl.no +fylkesbibl.no +gielda.no +herad.no +idrett.no +kommune.no +museum.no +priv.no +suohkan.no +tjielte.no +uenorge.no +vgs.no +// Norid category second-level domains managed by parties other than Norid : https://www.norid.no/en/om-domenenavn/regelverk-for-no/vedlegg-d/ +dep.no +mil.no +stat.no +// Norid geographical second level domains : https://www.norid.no/en/om-domenenavn/regelverk-for-no/vedlegg-b/ +// counties +aa.no +ah.no +bu.no +fm.no +hl.no +hm.no +jan-mayen.no +mr.no +nl.no +nt.no +of.no +ol.no +oslo.no +rl.no +sf.no +st.no +svalbard.no +tm.no +tr.no +va.no +vf.no +// primary and lower secondary schools per county +gs.aa.no +gs.ah.no +gs.bu.no +gs.fm.no +gs.hl.no +gs.hm.no +gs.jan-mayen.no +gs.mr.no +gs.nl.no +gs.nt.no +gs.of.no +gs.ol.no +gs.oslo.no +gs.rl.no +gs.sf.no +gs.st.no +gs.svalbard.no +gs.tm.no +gs.tr.no +gs.va.no +gs.vf.no +// cities +akrehamn.no +åkrehamn.no +algard.no +ålgård.no +arna.no +bronnoysund.no +brønnøysund.no +brumunddal.no +bryne.no +drobak.no +drøbak.no +egersund.no +fetsund.no +floro.no +florø.no +fredrikstad.no +hokksund.no +honefoss.no +hønefoss.no +jessheim.no +jorpeland.no +jørpeland.no +kirkenes.no +kopervik.no +krokstadelva.no +langevag.no +langevåg.no +leirvik.no +mjondalen.no +mjøndalen.no +mo-i-rana.no +mosjoen.no +mosjøen.no +nesoddtangen.no +orkanger.no +osoyro.no +osøyro.no +raholt.no +råholt.no +sandnessjoen.no +sandnessjøen.no +skedsmokorset.no +slattum.no +spjelkavik.no +stathelle.no +stavern.no +stjordalshalsen.no +stjørdalshalsen.no +tananger.no +tranby.no +vossevangen.no +// communities +aarborte.no +aejrie.no +afjord.no +åfjord.no +agdenes.no +nes.akershus.no +aknoluokta.no +ákŋoluokta.no +al.no +ål.no +alaheadju.no +álaheadju.no +alesund.no +ålesund.no +alstahaug.no +alta.no +áltá.no +alvdal.no +amli.no +åmli.no +amot.no +åmot.no +andasuolo.no +andebu.no +andoy.no +andøy.no +ardal.no +årdal.no +aremark.no +arendal.no +ås.no +aseral.no +åseral.no +asker.no +askim.no +askoy.no +askøy.no +askvoll.no +asnes.no +åsnes.no +audnedal.no +aukra.no +aure.no +aurland.no +aurskog-holand.no +aurskog-høland.no +austevoll.no +austrheim.no +averoy.no +averøy.no +badaddja.no +bådåddjå.no +bærum.no +bahcavuotna.no +báhcavuotna.no +bahccavuotna.no +báhccavuotna.no +baidar.no +báidár.no +bajddar.no +bájddar.no +balat.no +bálát.no +balestrand.no +ballangen.no +balsfjord.no +bamble.no +bardu.no +barum.no +batsfjord.no +båtsfjord.no +bearalvahki.no +bearalváhki.no +beardu.no +beiarn.no +berg.no +bergen.no +berlevag.no +berlevåg.no +bievat.no +bievát.no +bindal.no +birkenes.no +bjerkreim.no +bjugn.no +bodo.no +bodø.no +bokn.no +bomlo.no +bømlo.no +bremanger.no +bronnoy.no +brønnøy.no +budejju.no +nes.buskerud.no +bygland.no +bykle.no +cahcesuolo.no +čáhcesuolo.no +davvenjarga.no +davvenjárga.no +davvesiida.no +deatnu.no +dielddanuorri.no +divtasvuodna.no +divttasvuotna.no +donna.no +dønna.no +dovre.no +drammen.no +drangedal.no +dyroy.no +dyrøy.no +eid.no +eidfjord.no +eidsberg.no +eidskog.no +eidsvoll.no +eigersund.no +elverum.no +enebakk.no +engerdal.no +etne.no +etnedal.no +evenassi.no +evenášši.no +evenes.no +evje-og-hornnes.no +farsund.no +fauske.no +fedje.no +fet.no +finnoy.no +finnøy.no +fitjar.no +fjaler.no +fjell.no +fla.no +flå.no +flakstad.no +flatanger.no +flekkefjord.no +flesberg.no +flora.no +folldal.no +forde.no +førde.no +forsand.no +fosnes.no +fræna.no +frana.no +frogn.no +froland.no +frosta.no +froya.no +frøya.no +fuoisku.no +fuossko.no +fusa.no +fyresdal.no +gaivuotna.no +gáivuotna.no +galsa.no +gálsá.no +gamvik.no +gangaviika.no +gáŋgaviika.no +gaular.no +gausdal.no +giehtavuoatna.no +gildeskal.no +gildeskål.no +giske.no +gjemnes.no +gjerdrum.no +gjerstad.no +gjesdal.no +gjovik.no +gjøvik.no +gloppen.no +gol.no +gran.no +grane.no +granvin.no +gratangen.no +grimstad.no +grong.no +grue.no +gulen.no +guovdageaidnu.no +ha.no +hå.no +habmer.no +hábmer.no +hadsel.no +hægebostad.no +hagebostad.no +halden.no +halsa.no +hamar.no +hamaroy.no +hamarøy.no +hammarfeasta.no +hámmárfeasta.no +hammerfest.no +hapmir.no +hápmir.no +haram.no +hareid.no +harstad.no +hasvik.no +hattfjelldal.no +haugesund.no +os.hedmark.no +valer.hedmark.no +våler.hedmark.no +hemne.no +hemnes.no +hemsedal.no +hitra.no +hjartdal.no +hjelmeland.no +hobol.no +hobøl.no +hof.no +hol.no +hole.no +holmestrand.no +holtalen.no +holtålen.no +os.hordaland.no +hornindal.no +horten.no +hoyanger.no +høyanger.no +hoylandet.no +høylandet.no +hurdal.no +hurum.no +hvaler.no +hyllestad.no +ibestad.no +inderoy.no +inderøy.no +iveland.no +ivgu.no +jevnaker.no +jolster.no +jølster.no +jondal.no +kafjord.no +kåfjord.no +karasjohka.no +kárášjohka.no +karasjok.no +karlsoy.no +karlsøy.no +karmoy.no +karmøy.no +kautokeino.no +klabu.no +klæbu.no +klepp.no +kongsberg.no +kongsvinger.no +kraanghke.no +kråanghke.no +kragero.no +kragerø.no +kristiansand.no +kristiansund.no +krodsherad.no +krødsherad.no +kvæfjord.no +kvænangen.no +kvafjord.no +kvalsund.no +kvam.no +kvanangen.no +kvinesdal.no +kvinnherad.no +kviteseid.no +kvitsoy.no +kvitsøy.no +laakesvuemie.no +lærdal.no +lahppi.no +láhppi.no +lardal.no +larvik.no +lavagis.no +lavangen.no +leangaviika.no +leaŋgaviika.no +lebesby.no +leikanger.no +leirfjord.no +leka.no +leksvik.no +lenvik.no +lerdal.no +lesja.no +levanger.no +lier.no +lierne.no +lillehammer.no +lillesand.no +lindas.no +lindås.no +lindesnes.no +loabat.no +loabát.no +lodingen.no +lødingen.no +lom.no +loppa.no +lorenskog.no +lørenskog.no +loten.no +løten.no +lund.no +lunner.no +luroy.no +lurøy.no +luster.no +lyngdal.no +lyngen.no +malatvuopmi.no +málatvuopmi.no +malselv.no +målselv.no +malvik.no +mandal.no +marker.no +marnardal.no +masfjorden.no +masoy.no +måsøy.no +matta-varjjat.no +mátta-várjjat.no +meland.no +meldal.no +melhus.no +meloy.no +meløy.no +meraker.no +meråker.no +midsund.no +midtre-gauldal.no +moareke.no +moåreke.no +modalen.no +modum.no +molde.no +heroy.more-og-romsdal.no +sande.more-og-romsdal.no +herøy.møre-og-romsdal.no +sande.møre-og-romsdal.no +moskenes.no +moss.no +muosat.no +muosát.no +naamesjevuemie.no +nååmesjevuemie.no +nærøy.no +namdalseid.no +namsos.no +namsskogan.no +nannestad.no +naroy.no +narviika.no +narvik.no +naustdal.no +navuotna.no +návuotna.no +nedre-eiker.no +nesna.no +nesodden.no +nesseby.no +nesset.no +nissedal.no +nittedal.no +nord-aurdal.no +nord-fron.no +nord-odal.no +norddal.no +nordkapp.no +bo.nordland.no +bø.nordland.no +heroy.nordland.no +herøy.nordland.no +nordre-land.no +nordreisa.no +nore-og-uvdal.no +notodden.no +notteroy.no +nøtterøy.no +odda.no +oksnes.no +øksnes.no +omasvuotna.no +oppdal.no +oppegard.no +oppegård.no +orkdal.no +orland.no +ørland.no +orskog.no +ørskog.no +orsta.no +ørsta.no +osen.no +osteroy.no +osterøy.no +valer.ostfold.no +våler.østfold.no +ostre-toten.no +østre-toten.no +overhalla.no +ovre-eiker.no +øvre-eiker.no +oyer.no +øyer.no +oygarden.no +øygarden.no +oystre-slidre.no +øystre-slidre.no +porsanger.no +porsangu.no +porsáŋgu.no +porsgrunn.no +rade.no +råde.no +radoy.no +radøy.no +rælingen.no +rahkkeravju.no +ráhkkerávju.no +raisa.no +ráisa.no +rakkestad.no +ralingen.no +rana.no +randaberg.no +rauma.no +re.no +rendalen.no +rennebu.no +rennesoy.no +rennesøy.no +rindal.no +ringebu.no +ringerike.no +ringsaker.no +risor.no +risør.no +rissa.no +roan.no +rodoy.no +rødøy.no +rollag.no +romsa.no +romskog.no +rømskog.no +roros.no +røros.no +rost.no +røst.no +royken.no +røyken.no +royrvik.no +røyrvik.no +ruovat.no +rygge.no +salangen.no +salat.no +sálat.no +sálát.no +saltdal.no +samnanger.no +sandefjord.no +sandnes.no +sandoy.no +sandøy.no +sarpsborg.no +sauda.no +sauherad.no +sel.no +selbu.no +selje.no +seljord.no +siellak.no +sigdal.no +siljan.no +sirdal.no +skanit.no +skánit.no +skanland.no +skånland.no +skaun.no +skedsmo.no +ski.no +skien.no +skierva.no +skiervá.no +skiptvet.no +skjak.no +skjåk.no +skjervoy.no +skjervøy.no +skodje.no +smola.no +smøla.no +snaase.no +snåase.no +snasa.no +snåsa.no +snillfjord.no +snoasa.no +sogndal.no +sogne.no +søgne.no +sokndal.no +sola.no +solund.no +somna.no +sømna.no +sondre-land.no +søndre-land.no +songdalen.no +sor-aurdal.no +sør-aurdal.no +sor-fron.no +sør-fron.no +sor-odal.no +sør-odal.no +sor-varanger.no +sør-varanger.no +sorfold.no +sørfold.no +sorreisa.no +sørreisa.no +sortland.no +sorum.no +sørum.no +spydeberg.no +stange.no +stavanger.no +steigen.no +steinkjer.no +stjordal.no +stjørdal.no +stokke.no +stor-elvdal.no +stord.no +stordal.no +storfjord.no +strand.no +stranda.no +stryn.no +sula.no +suldal.no +sund.no +sunndal.no +surnadal.no +sveio.no +svelvik.no +sykkylven.no +tana.no +bo.telemark.no +bø.telemark.no +time.no +tingvoll.no +tinn.no +tjeldsund.no +tjome.no +tjøme.no +tokke.no +tolga.no +tonsberg.no +tønsberg.no +torsken.no +træna.no +trana.no +tranoy.no +tranøy.no +troandin.no +trogstad.no +trøgstad.no +tromsa.no +tromso.no +tromsø.no +trondheim.no +trysil.no +tvedestrand.no +tydal.no +tynset.no +tysfjord.no +tysnes.no +tysvær.no +tysvar.no +ullensaker.no +ullensvang.no +ulstein.no +ulvik.no +unjarga.no +unjárga.no +utsira.no +vaapste.no +vadso.no +vadsø.no +værøy.no +vaga.no +vågå.no +vagan.no +vågan.no +vagsoy.no +vågsøy.no +vaksdal.no +valle.no +vang.no +vanylven.no +vardo.no +vardø.no +varggat.no +várggát.no +varoy.no +vefsn.no +vega.no +vegarshei.no +vegårshei.no +vennesla.no +verdal.no +verran.no +vestby.no +sande.vestfold.no +vestnes.no +vestre-slidre.no +vestre-toten.no +vestvagoy.no +vestvågøy.no +vevelstad.no +vik.no +vikna.no +vindafjord.no +voagat.no +volda.no +voss.no + +// np : https://www.mos.com.np/ +*.np + +// nr : http://cenpac.net.nr/dns/index.html +// Submitted by registry +nr +biz.nr +com.nr +edu.nr +gov.nr +info.nr +net.nr +org.nr + +// nu : https://www.iana.org/domains/root/db/nu.html +nu + +// nz : https://www.iana.org/domains/root/db/nz.html +// Submitted by registry +nz +ac.nz +co.nz +cri.nz +geek.nz +gen.nz +govt.nz +health.nz +iwi.nz +kiwi.nz +maori.nz +māori.nz +mil.nz +net.nz +org.nz +parliament.nz +school.nz + +// om : https://www.iana.org/domains/root/db/om.html +om +co.om +com.om +edu.om +gov.om +med.om +museum.om +net.om +org.om +pro.om + +// onion : https://tools.ietf.org/html/rfc7686 +onion + +// org : https://www.iana.org/domains/root/db/org.html +org + +// pa : http://www.nic.pa/ +// Some additional second level "domains" resolve directly as hostnames, such as +// pannet.pa, so we add a rule for "pa". +pa +abo.pa +ac.pa +com.pa +edu.pa +gob.pa +ing.pa +med.pa +net.pa +nom.pa +org.pa +sld.pa + +// pe : https://punto.pe/policy.php +pe +com.pe +edu.pe +gob.pe +mil.pe +net.pe +nom.pe +org.pe + +// pf : https://www.iana.org/domains/root/db/pf.html +pf +com.pf +edu.pf +org.pf + +// pg : https://www.iana.org/domains/root/db/pg.html +*.pg + +// ph : https://www.iana.org/domains/root/db/ph.html +// Submitted by registry +ph +com.ph +edu.ph +gov.ph +i.ph +mil.ph +net.ph +ngo.ph +org.ph + +// pk : https://www.pknic.net.pk/domain-structure.html +// Contact Email: staff@pknic.net.pk +pk +ac.pk +biz.pk +com.pk +edu.pk +fam.pk +gkp.pk +gob.pk +gog.pk +gok.pk +gop.pk +gos.pk +gov.pk +net.pk +org.pk +web.pk + +// pl : https://www.dns.pl/en/ +// Confirmed by registry 2024-11-18 +pl +com.pl +net.pl +org.pl +// pl functional domains : https://www.dns.pl/en/list_of_functional_domain_names +agro.pl +aid.pl +atm.pl +auto.pl +biz.pl +edu.pl +gmina.pl +gsm.pl +info.pl +mail.pl +media.pl +miasta.pl +mil.pl +nieruchomosci.pl +nom.pl +pc.pl +powiat.pl +priv.pl +realestate.pl +rel.pl +sex.pl +shop.pl +sklep.pl +sos.pl +szkola.pl +targi.pl +tm.pl +tourism.pl +travel.pl +turystyka.pl +// Government domains : https://www.dns.pl/informacje_o_rejestracji_domen_gov_pl +// In accordance with the .gov.pl Domain Name Regulations : https://www.dns.pl/regulamin_gov_pl +gov.pl +ap.gov.pl +griw.gov.pl +ic.gov.pl +is.gov.pl +kmpsp.gov.pl +konsulat.gov.pl +kppsp.gov.pl +kwp.gov.pl +kwpsp.gov.pl +mup.gov.pl +mw.gov.pl +oia.gov.pl +oirm.gov.pl +oke.gov.pl +oow.gov.pl +oschr.gov.pl +oum.gov.pl +pa.gov.pl +pinb.gov.pl +piw.gov.pl +po.gov.pl +pr.gov.pl +psp.gov.pl +psse.gov.pl +pup.gov.pl +rzgw.gov.pl +sa.gov.pl +sdn.gov.pl +sko.gov.pl +so.gov.pl +sr.gov.pl +starostwo.gov.pl +ug.gov.pl +ugim.gov.pl +um.gov.pl +umig.gov.pl +upow.gov.pl +uppo.gov.pl +us.gov.pl +uw.gov.pl +uzs.gov.pl +wif.gov.pl +wiih.gov.pl +winb.gov.pl +wios.gov.pl +witd.gov.pl +wiw.gov.pl +wkz.gov.pl +wsa.gov.pl +wskr.gov.pl +wsse.gov.pl +wuoz.gov.pl +wzmiuw.gov.pl +zp.gov.pl +zpisdn.gov.pl +// pl regional domains : https://www.dns.pl/en/list_of_regional_domain_names +augustow.pl +babia-gora.pl +bedzin.pl +beskidy.pl +bialowieza.pl +bialystok.pl +bielawa.pl +bieszczady.pl +boleslawiec.pl +bydgoszcz.pl +bytom.pl +cieszyn.pl +czeladz.pl +czest.pl +dlugoleka.pl +elblag.pl +elk.pl +glogow.pl +gniezno.pl +gorlice.pl +grajewo.pl +ilawa.pl +jaworzno.pl +jelenia-gora.pl +jgora.pl +kalisz.pl +karpacz.pl +kartuzy.pl +kaszuby.pl +katowice.pl +kazimierz-dolny.pl +kepno.pl +ketrzyn.pl +klodzko.pl +kobierzyce.pl +kolobrzeg.pl +konin.pl +konskowola.pl +kutno.pl +lapy.pl +lebork.pl +legnica.pl +lezajsk.pl +limanowa.pl +lomza.pl +lowicz.pl +lubin.pl +lukow.pl +malbork.pl +malopolska.pl +mazowsze.pl +mazury.pl +mielec.pl +mielno.pl +mragowo.pl +naklo.pl +nowaruda.pl +nysa.pl +olawa.pl +olecko.pl +olkusz.pl +olsztyn.pl +opoczno.pl +opole.pl +ostroda.pl +ostroleka.pl +ostrowiec.pl +ostrowwlkp.pl +pila.pl +pisz.pl +podhale.pl +podlasie.pl +polkowice.pl +pomorskie.pl +pomorze.pl +prochowice.pl +pruszkow.pl +przeworsk.pl +pulawy.pl +radom.pl +rawa-maz.pl +rybnik.pl +rzeszow.pl +sanok.pl +sejny.pl +skoczow.pl +slask.pl +slupsk.pl +sosnowiec.pl +stalowa-wola.pl +starachowice.pl +stargard.pl +suwalki.pl +swidnica.pl +swiebodzin.pl +swinoujscie.pl +szczecin.pl +szczytno.pl +tarnobrzeg.pl +tgory.pl +turek.pl +tychy.pl +ustka.pl +walbrzych.pl +warmia.pl +warszawa.pl +waw.pl +wegrow.pl +wielun.pl +wlocl.pl +wloclawek.pl +wodzislaw.pl +wolomin.pl +wroclaw.pl +zachpomor.pl +zagan.pl +zarow.pl +zgora.pl +zgorzelec.pl + +// pm : https://www.afnic.fr/wp-media/uploads/2022/12/afnic-naming-policy-2023-01-01.pdf +pm + +// pn : https://www.iana.org/domains/root/db/pn.html +pn +co.pn +edu.pn +gov.pn +net.pn +org.pn + +// post : https://www.iana.org/domains/root/db/post.html +post + +// pr : https://www.domains.pr/ +pr +ac.pr +biz.pr +com.pr +edu.pr +est.pr +gov.pr +info.pr +isla.pr +name.pr +net.pr +org.pr +pro.pr +prof.pr + +// pro : http://registry.pro/get-pro +pro +aaa.pro +aca.pro +acct.pro +avocat.pro +bar.pro +cpa.pro +eng.pro +jur.pro +law.pro +med.pro +recht.pro + +// ps : https://www.pnina.ps/registration-policy/ +ps +com.ps +edu.ps +gov.ps +net.ps +org.ps +plo.ps +sec.ps + +// pt : https://www.dns.pt/en/domain/pt-terms-and-conditions-registration-rules/ +pt +com.pt +edu.pt +gov.pt +int.pt +net.pt +nome.pt +org.pt +publ.pt + +// pw : https://www.iana.org/domains/root/db/pw.html +// Confirmed by registry in private correspondence with @dnsguru 2024-12-09 +pw +gov.pw + +// py : https://www.iana.org/domains/root/db/py.html +// Submitted by registry +py +com.py +coop.py +edu.py +gov.py +mil.py +net.py +org.py + +// qa : http://domains.qa/en/ +qa +com.qa +edu.qa +gov.qa +mil.qa +name.qa +net.qa +org.qa +sch.qa + +// re : https://www.afnic.fr/wp-media/uploads/2022/12/afnic-naming-policy-2023-01-01.pdf +// Confirmed by registry 2024-11-18 +re +// Closed for registration on 2013-03-15 but domains are still maintained +asso.re +com.re + +// ro : http://www.rotld.ro/ +ro +arts.ro +com.ro +firm.ro +info.ro +nom.ro +nt.ro +org.ro +rec.ro +store.ro +tm.ro +www.ro + +// rs : https://www.rnids.rs/en/domains/national-domains +rs +ac.rs +co.rs +edu.rs +gov.rs +in.rs +org.rs + +// ru : https://cctld.ru/files/pdf/docs/en/rules_ru-rf.pdf +// Submitted by George Georgievsky +ru + +// rw : https://www.iana.org/domains/root/db/rw.html +rw +ac.rw +co.rw +coop.rw +gov.rw +mil.rw +net.rw +org.rw + +// sa : http://www.nic.net.sa/ +sa +com.sa +edu.sa +gov.sa +med.sa +net.sa +org.sa +pub.sa +sch.sa + +// sb : http://www.nic.net.sb/ +sb +com.sb +edu.sb +gov.sb +net.sb +org.sb + +// sc : https://www.nic.sc/en/policies.html +sc +com.sc +edu.sc +gov.sc +net.sc +org.sc + +// sd : https://www.iana.org/domains/root/db/sd.html +// Submitted by registry +sd +com.sd +edu.sd +gov.sd +info.sd +med.sd +net.sd +org.sd +tv.sd + +// se : https://www.iana.org/domains/root/db/se.html +// https://data.internetstiftelsen.se/barred_domains_list.txt -> Second level domains & Sub-domains +// Confirmed by Registry Services 2024-11-20 +se +a.se +ac.se +b.se +bd.se +brand.se +c.se +d.se +e.se +f.se +fh.se +fhsk.se +fhv.se +g.se +h.se +i.se +k.se +komforb.se +kommunalforbund.se +komvux.se +l.se +lanbib.se +m.se +n.se +naturbruksgymn.se +o.se +org.se +p.se +parti.se +pp.se +press.se +r.se +s.se +t.se +tm.se +u.se +w.se +x.se +y.se +z.se + +// sg : https://www.sgnic.sg/domain-registration/sg-categories-rules +// Confirmed by registry 2024-11-19 +sg +com.sg +edu.sg +gov.sg +net.sg +org.sg + +// sh : http://nic.sh/rules.htm +sh +com.sh +gov.sh +mil.sh +net.sh +org.sh + +// si : https://www.iana.org/domains/root/db/si.html +si + +// sj : No registrations at this time. +// Submitted by registry +sj + +// sk : https://sk-nic.sk/ +sk +org.sk + +// sl : http://www.nic.sl +// Submitted by registry +sl +com.sl +edu.sl +gov.sl +net.sl +org.sl + +// sm : https://www.iana.org/domains/root/db/sm.html +sm + +// sn : https://www.iana.org/domains/root/db/sn.html +sn +art.sn +com.sn +edu.sn +gouv.sn +org.sn +univ.sn + +// so : https://sonic.so/policies/ +so +com.so +edu.so +gov.so +me.so +net.so +org.so + +// sr : https://www.iana.org/domains/root/db/sr.html +sr + +// ss : https://registry.nic.ss/ +// Submitted by registry +ss +biz.ss +co.ss +com.ss +edu.ss +gov.ss +me.ss +net.ss +org.ss +sch.ss + +// st : http://www.nic.st/html/policyrules/ +st +co.st +com.st +consulado.st +edu.st +embaixada.st +mil.st +net.st +org.st +principe.st +saotome.st +store.st + +// su : https://www.iana.org/domains/root/db/su.html +su + +// sv : https://www.iana.org/domains/root/db/sv.html +sv +com.sv +edu.sv +gob.sv +org.sv +red.sv + +// sx : https://www.iana.org/domains/root/db/sx.html +// Submitted by registry +sx +gov.sx + +// sy : https://www.iana.org/domains/root/db/sy.html +sy +com.sy +edu.sy +gov.sy +mil.sy +net.sy +org.sy + +// sz : http://www.sispa.org.sz/ +sz +ac.sz +co.sz +org.sz + +// tc : https://www.iana.org/domains/root/db/tc.html +tc + +// td : https://www.iana.org/domains/root/db/td.html +td + +// tel : http://www.telnic.org/ +tel + +// tf : https://www.afnic.fr/wp-media/uploads/2022/12/afnic-naming-policy-2023-01-01.pdf +tf + +// tg : http://www.nic.tg/ +tg + +// th : https://www.iana.org/domains/root/db/th.html +// Submitted by registry +th +ac.th +co.th +go.th +in.th +mi.th +net.th +or.th + +// tj : http://www.nic.tj/policy.html +tj +biz.tj +co.tj +com.tj +edu.tj +go.tj +gov.tj +int.tj +mil.tj +name.tj +net.tj +nic.tj +org.tj +test.tj +web.tj + +// tk : https://www.iana.org/domains/root/db/tk.html +tk + +// tl : https://www.iana.org/domains/root/db/tl.html +tl +gov.tl + +// tm : https://www.nic.tm/local.html +// Confirmed by registry 2024-11-19 +tm +co.tm +com.tm +edu.tm +gov.tm +mil.tm +net.tm +nom.tm +org.tm + +// tn : http://www.registre.tn/fr/ +// https://whois.ati.tn/ +tn +com.tn +ens.tn +fin.tn +gov.tn +ind.tn +info.tn +intl.tn +mincom.tn +nat.tn +net.tn +org.tn +perso.tn +tourism.tn + +// to : https://www.iana.org/domains/root/db/to.html +// Submitted by registry +to +com.to +edu.to +gov.to +mil.to +net.to +org.to + +// tr : https://nic.tr/ +// https://nic.tr/forms/eng/policies.pdf +// https://nic.tr/index.php?USRACTN=PRICELST +tr +av.tr +bbs.tr +bel.tr +biz.tr +com.tr +dr.tr +edu.tr +gen.tr +gov.tr +info.tr +k12.tr +kep.tr +mil.tr +name.tr +net.tr +org.tr +pol.tr +tel.tr +tsk.tr +tv.tr +web.tr +// Used by Northern Cyprus +nc.tr +// Used by government agencies of Northern Cyprus +gov.nc.tr + +// tt : https://www.nic.tt/ +// Confirmed by registry 2024-11-19 +tt +biz.tt +co.tt +com.tt +edu.tt +gov.tt +info.tt +mil.tt +name.tt +net.tt +org.tt +pro.tt + +// tv : https://www.iana.org/domains/root/db/tv.html +// Not listing any 2LDs as reserved since none seem to exist in practice, +// Wikipedia notwithstanding. +tv + +// tw : https://www.iana.org/domains/root/db/tw.html +// https://twnic.tw/dnservice_catag.php +// Confirmed by registry 2024-11-26 +tw +club.tw +com.tw +ebiz.tw +edu.tw +game.tw +gov.tw +idv.tw +mil.tw +net.tw +org.tw + +// tz : https://karibu.tz/regulations +tz +ac.tz +co.tz +go.tz +hotel.tz +info.tz +me.tz +mil.tz +mobi.tz +ne.tz +or.tz +sc.tz +tv.tz + +// ua : https://hostmaster.ua/policy/?ua +// Submitted by registry +ua +// ua 2LD +com.ua +edu.ua +gov.ua +in.ua +net.ua +org.ua +// ua geographic names +// https://hostmaster.ua/2ld/ +cherkassy.ua +cherkasy.ua +chernigov.ua +chernihiv.ua +chernivtsi.ua +chernovtsy.ua +ck.ua +cn.ua +cr.ua +crimea.ua +cv.ua +dn.ua +dnepropetrovsk.ua +dnipropetrovsk.ua +donetsk.ua +dp.ua +if.ua +ivano-frankivsk.ua +kh.ua +kharkiv.ua +kharkov.ua +kherson.ua +khmelnitskiy.ua +khmelnytskyi.ua +kiev.ua +kirovograd.ua +km.ua +kr.ua +kropyvnytskyi.ua +krym.ua +ks.ua +kv.ua +kyiv.ua +lg.ua +lt.ua +lugansk.ua +luhansk.ua +lutsk.ua +lv.ua +lviv.ua +mk.ua +mykolaiv.ua +nikolaev.ua +od.ua +odesa.ua +odessa.ua +pl.ua +poltava.ua +rivne.ua +rovno.ua +rv.ua +sb.ua +sebastopol.ua +sevastopol.ua +sm.ua +sumy.ua +te.ua +ternopil.ua +uz.ua +uzhgorod.ua +uzhhorod.ua +vinnica.ua +vinnytsia.ua +vn.ua +volyn.ua +yalta.ua +zakarpattia.ua +zaporizhzhe.ua +zaporizhzhia.ua +zhitomir.ua +zhytomyr.ua +zp.ua +zt.ua + +// ug : https://www.registry.co.ug/ +// https://www.registry.co.ug, https://whois.co.ug +// Confirmed by registry 2025-01-20 +ug +ac.ug +co.ug +com.ug +edu.ug +go.ug +gov.ug +mil.ug +ne.ug +or.ug +org.ug +sc.ug +us.ug + +// uk : https://www.iana.org/domains/root/db/uk.html +// Submitted by registry +uk +ac.uk +co.uk +gov.uk +ltd.uk +me.uk +net.uk +nhs.uk +org.uk +plc.uk +police.uk +*.sch.uk + +// us : https://www.iana.org/domains/root/db/us.html +// Confirmed via the .us zone file by William Harrison 2024-12-10 +us +dni.us +isa.us +nsn.us +// Geographic Names +ak.us +al.us +ar.us +as.us +az.us +ca.us +co.us +ct.us +dc.us +de.us +fl.us +ga.us +gu.us +hi.us +ia.us +id.us +il.us +in.us +ks.us +ky.us +la.us +ma.us +md.us +me.us +mi.us +mn.us +mo.us +ms.us +mt.us +nc.us +nd.us +ne.us +nh.us +nj.us +nm.us +nv.us +ny.us +oh.us +ok.us +or.us +pa.us +pr.us +ri.us +sc.us +sd.us +tn.us +tx.us +ut.us +va.us +vi.us +vt.us +wa.us +wi.us +wv.us +wy.us +// The registrar notes several more specific domains available in each state, +// such as state.*.us, dst.*.us, etc., but resolution of these is somewhat +// haphazard; in some states these domains resolve as addresses, while in others +// only subdomains are available, or even nothing at all. We include the +// most common ones where it's clear that different sites are different +// entities. +k12.ak.us +k12.al.us +k12.ar.us +k12.as.us +k12.az.us +k12.ca.us +k12.co.us +k12.ct.us +k12.dc.us +k12.fl.us +k12.ga.us +k12.gu.us +// k12.hi.us - Bug 614565 - Hawaii has a state-wide DOE login +k12.ia.us +k12.id.us +k12.il.us +k12.in.us +k12.ks.us +k12.ky.us +k12.la.us +k12.ma.us +k12.md.us +k12.me.us +k12.mi.us +k12.mn.us +k12.mo.us +k12.ms.us +k12.mt.us +k12.nc.us +k12.ne.us +k12.nh.us +k12.nj.us +k12.nm.us +k12.nv.us +k12.ny.us +k12.oh.us +k12.ok.us +k12.or.us +k12.pa.us +k12.pr.us +// k12.ri.us - Removed at request of Kim Cournoyer +k12.sc.us +// k12.sd.us - Bug 934131 - Removed at request of James Booze +k12.tn.us +k12.tx.us +k12.ut.us +k12.va.us +k12.vi.us +k12.vt.us +k12.wa.us +k12.wi.us +// k12.wv.us - Bug 947705 - Removed at request of Verne Britton +cc.ak.us +lib.ak.us +cc.al.us +lib.al.us +cc.ar.us +lib.ar.us +cc.as.us +lib.as.us +cc.az.us +lib.az.us +cc.ca.us +lib.ca.us +cc.co.us +lib.co.us +cc.ct.us +lib.ct.us +cc.dc.us +lib.dc.us +cc.de.us +cc.fl.us +lib.fl.us +cc.ga.us +lib.ga.us +cc.gu.us +lib.gu.us +cc.hi.us +lib.hi.us +cc.ia.us +lib.ia.us +cc.id.us +lib.id.us +cc.il.us +lib.il.us +cc.in.us +lib.in.us +cc.ks.us +lib.ks.us +cc.ky.us +lib.ky.us +cc.la.us +lib.la.us +cc.ma.us +lib.ma.us +cc.md.us +lib.md.us +cc.me.us +lib.me.us +cc.mi.us +lib.mi.us +cc.mn.us +lib.mn.us +cc.mo.us +lib.mo.us +cc.ms.us +cc.mt.us +lib.mt.us +cc.nc.us +lib.nc.us +cc.ne.us +lib.ne.us +cc.nh.us +lib.nh.us +cc.nj.us +lib.nj.us +cc.nm.us +lib.nm.us +cc.nv.us +lib.nv.us +cc.ny.us +lib.ny.us +cc.oh.us +lib.oh.us +cc.ok.us +lib.ok.us +cc.or.us +lib.or.us +cc.pa.us +lib.pa.us +cc.pr.us +lib.pr.us +cc.ri.us +lib.ri.us +cc.sc.us +lib.sc.us +cc.sd.us +lib.sd.us +cc.tn.us +lib.tn.us +cc.tx.us +lib.tx.us +cc.ut.us +lib.ut.us +cc.va.us +lib.va.us +cc.vi.us +lib.vi.us +cc.vt.us +lib.vt.us +cc.wa.us +lib.wa.us +cc.wi.us +lib.wi.us +cc.wv.us +cc.wy.us +k12.wy.us +// lib.wv.us - Bug 941670 - Removed at request of Larry W Arnold +lib.wy.us +// k12.ma.us contains school districts in Massachusetts. The 4LDs are +// managed independently except for private (PVT), charter (CHTR) and +// parochial (PAROCH) schools. Those are delegated directly to the +// 5LD operators. +chtr.k12.ma.us +paroch.k12.ma.us +pvt.k12.ma.us +// Merit Network, Inc. maintains the registry for =~ /(k12|cc|lib).mi.us/ and the following +// see also: https://domreg.merit.edu : domreg@merit.edu +// see also: whois -h whois.domreg.merit.edu help +ann-arbor.mi.us +cog.mi.us +dst.mi.us +eaton.mi.us +gen.mi.us +mus.mi.us +tec.mi.us +washtenaw.mi.us + +// uy : http://www.nic.org.uy/ +uy +com.uy +edu.uy +gub.uy +mil.uy +net.uy +org.uy + +// uz : http://www.reg.uz/ +uz +co.uz +com.uz +net.uz +org.uz + +// va : https://www.iana.org/domains/root/db/va.html +va + +// vc : https://www.iana.org/domains/root/db/vc.html +// Submitted by registry +vc +com.vc +edu.vc +gov.vc +mil.vc +net.vc +org.vc + +// ve : https://nic.ve/ +// https://nic.ve/site/user-agreement -> under "III. Clasificación de Nombres de Dominio" +// Submitted by registry nic@nic.ve and nicve@conatel.gob.ve +ve +arts.ve +bib.ve +co.ve +com.ve +e12.ve +edu.ve +emprende.ve +firm.ve +gob.ve +gov.ve +ia.ve +info.ve +int.ve +mil.ve +net.ve +nom.ve +org.ve +rar.ve +rec.ve +store.ve +tec.ve +web.ve + +// vg : https://www.iana.org/domains/root/db/vg.html +// Confirmed by registry 2025-01-10 +vg +edu.vg + +// vi : https://www.iana.org/domains/root/db/vi.html +vi +co.vi +com.vi +k12.vi +net.vi +org.vi + +// vn : https://vnnic.vn/en/domain-name-vn/domain-name/cctldvn +vn +ac.vn +ai.vn +biz.vn +com.vn +edu.vn +gov.vn +health.vn +id.vn +info.vn +int.vn +io.vn +name.vn +net.vn +org.vn +pro.vn + +// vn geographical names +angiang.vn +bacgiang.vn +backan.vn +baclieu.vn +bacninh.vn +baria-vungtau.vn +bentre.vn +binhdinh.vn +binhduong.vn +binhphuoc.vn +binhthuan.vn +camau.vn +cantho.vn +caobang.vn +daklak.vn +daknong.vn +danang.vn +dienbien.vn +dongnai.vn +dongthap.vn +gialai.vn +hagiang.vn +haiduong.vn +haiphong.vn +hanam.vn +hanoi.vn +hatinh.vn +haugiang.vn +hoabinh.vn +hue.vn +hungyen.vn +khanhhoa.vn +kiengiang.vn +kontum.vn +laichau.vn +lamdong.vn +langson.vn +laocai.vn +longan.vn +namdinh.vn +nghean.vn +ninhbinh.vn +ninhthuan.vn +phutho.vn +phuyen.vn +quangbinh.vn +quangnam.vn +quangngai.vn +quangninh.vn +quangtri.vn +soctrang.vn +sonla.vn +tayninh.vn +thaibinh.vn +thainguyen.vn +thanhhoa.vn +thanhphohochiminh.vn +thuathienhue.vn +tiengiang.vn +travinh.vn +tuyenquang.vn +vinhlong.vn +vinhphuc.vn +yenbai.vn + +// vu : https://www.iana.org/domains/root/db/vu.html +// http://www.vunic.vu/ +vu +com.vu +edu.vu +net.vu +org.vu + +// wf : https://www.afnic.fr/wp-media/uploads/2022/12/afnic-naming-policy-2023-01-01.pdf +wf + +// ws : https://www.iana.org/domains/root/db/ws.html +// http://samoanic.ws/index.dhtml +ws +com.ws +edu.ws +gov.ws +net.ws +org.ws + +// yt : https://www.afnic.fr/wp-media/uploads/2022/12/afnic-naming-policy-2023-01-01.pdf +yt + +// IDN ccTLDs +// When submitting patches, please maintain a sort by ISO 3166 ccTLD, then +// U-label, and follow this format: +// // A-Label ("", [, variant info]) : +// // [sponsoring org] +// U-Label + +// xn--mgbaam7a8h ("Emerat", Arabic) : AE +// http://aeda.ae/ +امارات + +// xn--y9a3aq ("hye", Armenian) : AM +// ISOC AM (operated by .am Registry) +հայ + +// xn--54b7fta0cc ("Bangla", Bangla) : BD +বাংলা + +// xn--90ae ("bg", Bulgarian) : BG +бг + +// xn--mgbcpq6gpa1a ("albahrain", Arabic) : BH +البحرين + +// xn--90ais ("bel", Belarusian/Russian Cyrillic) : BY +// Operated by .by registry +бел + +// xn--fiqs8s ("Zhongguo/China", Chinese, Simplified) : CN +// CNNIC +// https://www.cnnic.cn/11/192/index.html +中国 + +// xn--fiqz9s ("Zhongguo/China", Chinese, Traditional) : CN +// CNNIC +// https://www.cnnic.com.cn/AU/MediaC/Announcement/201609/t20160905_54470.htm +中國 + +// xn--lgbbat1ad8j ("Algeria/Al Jazair", Arabic) : DZ +الجزائر + +// xn--wgbh1c ("Egypt/Masr", Arabic) : EG +// http://www.dotmasr.eg/ +مصر + +// xn--e1a4c ("eu", Cyrillic) : EU +// https://eurid.eu +ею + +// xn--qxa6a ("eu", Greek) : EU +// https://eurid.eu +ευ + +// xn--mgbah1a3hjkrd ("Mauritania", Arabic) : MR +موريتانيا + +// xn--node ("ge", Georgian Mkhedruli) : GE +გე + +// xn--qxam ("el", Greek) : GR +// Hellenic Ministry of Infrastructure, Transport, and Networks +ελ + +// xn--j6w193g ("Hong Kong", Chinese) : HK +// https://www.hkirc.hk +// Submitted by registry +// https://www.hkirc.hk/content.jsp?id=30#!/34 +香港 +個人.香港 +公司.香港 +政府.香港 +教育.香港 +組織.香港 +網絡.香港 + +// xn--2scrj9c ("Bharat", Kannada) : IN +// India +ಭಾರತ + +// xn--3hcrj9c ("Bharat", Oriya) : IN +// India +ଭାରତ + +// xn--45br5cyl ("Bharatam", Assamese) : IN +// India +ভাৰত + +// xn--h2breg3eve ("Bharatam", Sanskrit) : IN +// India +भारतम् + +// xn--h2brj9c8c ("Bharot", Santali) : IN +// India +भारोत + +// xn--mgbgu82a ("Bharat", Sindhi) : IN +// India +ڀارت + +// xn--rvc1e0am3e ("Bharatam", Malayalam) : IN +// India +ഭാരതം + +// xn--h2brj9c ("Bharat", Devanagari) : IN +// India +भारत + +// xn--mgbbh1a ("Bharat", Kashmiri) : IN +// India +بارت + +// xn--mgbbh1a71e ("Bharat", Arabic) : IN +// India +بھارت + +// xn--fpcrj9c3d ("Bharat", Telugu) : IN +// India +భారత్ + +// xn--gecrj9c ("Bharat", Gujarati) : IN +// India +ભારત + +// xn--s9brj9c ("Bharat", Gurmukhi) : IN +// India +ਭਾਰਤ + +// xn--45brj9c ("Bharat", Bengali) : IN +// India +ভারত + +// xn--xkc2dl3a5ee0h ("India", Tamil) : IN +// India +இந்தியா + +// xn--mgba3a4f16a ("Iran", Persian) : IR +ایران + +// xn--mgba3a4fra ("Iran", Arabic) : IR +ايران + +// xn--mgbtx2b ("Iraq", Arabic) : IQ +// Communications and Media Commission +عراق + +// xn--mgbayh7gpa ("al-Ordon", Arabic) : JO +// National Information Technology Center (NITC) +// Royal Scientific Society, Al-Jubeiha +الاردن + +// xn--3e0b707e ("Republic of Korea", Hangul) : KR +한국 + +// xn--80ao21a ("Kaz", Kazakh) : KZ +қаз + +// xn--q7ce6a ("Lao", Lao) : LA +ລາວ + +// xn--fzc2c9e2c ("Lanka", Sinhalese-Sinhala) : LK +// http://www.domains.lk/ +ලංකා + +// xn--xkc2al3hye2a ("Ilangai", Tamil) : LK +// http://www.domains.lk/ +இலங்கை + +// xn--mgbc0a9azcg ("Morocco/al-Maghrib", Arabic) : MA +المغرب + +// xn--d1alf ("mkd", Macedonian) : MK +// MARnet +мкд + +// xn--l1acc ("mon", Mongolian) : MN +мон + +// xn--mix891f ("Macao", Chinese, Traditional) : MO +// MONIC / HNET Asia (Registry Operator for .mo) +澳門 + +// xn--mix082f ("Macao", Chinese, Simplified) : MO +澳门 + +// xn--mgbx4cd0ab ("Malaysia", Malay) : MY +مليسيا + +// xn--mgb9awbf ("Oman", Arabic) : OM +عمان + +// xn--mgbai9azgqp6j ("Pakistan", Urdu/Arabic) : PK +پاکستان + +// xn--mgbai9a5eva00b ("Pakistan", Urdu/Arabic, variant) : PK +پاكستان + +// xn--ygbi2ammx ("Falasteen", Arabic) : PS +// The Palestinian National Internet Naming Authority (PNINA) +// http://www.pnina.ps +فلسطين + +// xn--90a3ac ("srb", Cyrillic) : RS +// https://www.rnids.rs/en/domains/national-domains +срб +ак.срб +обр.срб +од.срб +орг.срб +пр.срб +упр.срб + +// xn--p1ai ("rf", Russian-Cyrillic) : RU +// https://cctld.ru/files/pdf/docs/en/rules_ru-rf.pdf +// Submitted by George Georgievsky +рф + +// xn--wgbl6a ("Qatar", Arabic) : QA +// https://www.cra.gov.qa/ +قطر + +// xn--mgberp4a5d4ar ("AlSaudiah", Arabic) : SA +// http://www.nic.net.sa/ +السعودية + +// xn--mgberp4a5d4a87g ("AlSaudiah", Arabic, variant): SA +السعودیة + +// xn--mgbqly7c0a67fbc ("AlSaudiah", Arabic, variant) : SA +السعودیۃ + +// xn--mgbqly7cvafr ("AlSaudiah", Arabic, variant) : SA +السعوديه + +// xn--mgbpl2fh ("sudan", Arabic) : SD +// Operated by .sd registry +سودان + +// xn--yfro4i67o Singapore ("Singapore", Chinese) : SG +新加坡 + +// xn--clchc0ea0b2g2a9gcd ("Singapore", Tamil) : SG +சிங்கப்பூர் + +// xn--ogbpf8fl ("Syria", Arabic) : SY +سورية + +// xn--mgbtf8fl ("Syria", Arabic, variant) : SY +سوريا + +// xn--o3cw4h ("Thai", Thai) : TH +// http://www.thnic.co.th +ไทย +ทหาร.ไทย +ธุรกิจ.ไทย +เน็ต.ไทย +รัฐบาล.ไทย +ศึกษา.ไทย +องค์กร.ไทย + +// xn--pgbs0dh ("Tunisia", Arabic) : TN +// http://nic.tn +تونس + +// xn--kpry57d ("Taiwan", Chinese, Traditional) : TW +// https://twnic.tw/dnservice_catag.php +台灣 + +// xn--kprw13d ("Taiwan", Chinese, Simplified) : TW +// http://www.twnic.net/english/dn/dn_07a.htm +台湾 + +// xn--nnx388a ("Taiwan", Chinese, variant) : TW +臺灣 + +// xn--j1amh ("ukr", Cyrillic) : UA +укр + +// xn--mgb2ddes ("AlYemen", Arabic) : YE +اليمن + +// xxx : https://icmregistry.biz/ +xxx + +// ye : https://www.iana.org/domains/root/db/ye.html +ye +com.ye +edu.ye +gov.ye +mil.ye +net.ye +org.ye + +// za : https://www.iana.org/domains/root/db/za.html +ac.za +agric.za +alt.za +co.za +edu.za +gov.za +grondar.za +law.za +mil.za +net.za +ngo.za +nic.za +nis.za +nom.za +org.za +school.za +tm.za +web.za + +// zm : https://zicta.zm/ +zm +ac.zm +biz.zm +co.zm +com.zm +edu.zm +gov.zm +info.zm +mil.zm +net.zm +org.zm +sch.zm + +// zw : https://www.potraz.gov.zw/ +// Confirmed by registry 2017-01-25 +zw +ac.zw +co.zw +gov.zw +mil.zw +org.zw + +// newGTLDs + +// List of new gTLDs imported from https://www.icann.org/resources/registries/gtlds/v2/gtlds.json on 2026-10-01T20:07:38Z +// This list is auto-generated, don't edit it manually. +// aaa : American Automobile Association, Inc. +// https://www.iana.org/domains/root/db/aaa.html +aaa + +// aarp : AARP +// https://www.iana.org/domains/root/db/aarp.html +aarp + +// abb : ABB Ltd +// https://www.iana.org/domains/root/db/abb.html +abb + +// abbott : Abbott Laboratories, Inc. +// https://www.iana.org/domains/root/db/abbott.html +abbott + +// abbvie : AbbVie Inc. +// https://www.iana.org/domains/root/db/abbvie.html +abbvie + +// abc : Disney Enterprises, Inc. +// https://www.iana.org/domains/root/db/abc.html +abc + +// able : Able Inc. +// https://www.iana.org/domains/root/db/able.html +able + +// abogado : Registry Services, LLC +// https://www.iana.org/domains/root/db/abogado.html +abogado + +// abudhabi : Abu Dhabi Systems and Information Centre +// https://www.iana.org/domains/root/db/abudhabi.html +abudhabi + +// academy : Binky Moon, LLC +// https://www.iana.org/domains/root/db/academy.html +academy + +// accenture : Accenture plc +// https://www.iana.org/domains/root/db/accenture.html +accenture + +// accountant : dot Accountant Limited +// https://www.iana.org/domains/root/db/accountant.html +accountant + +// accountants : Binky Moon, LLC +// https://www.iana.org/domains/root/db/accountants.html +accountants + +// aco : ACO Severin Ahlmann GmbH & Co. KG +// https://www.iana.org/domains/root/db/aco.html +aco + +// actor : Dog Beach, LLC +// https://www.iana.org/domains/root/db/actor.html +actor + +// ads : Charleston Road Registry Inc. +// https://www.iana.org/domains/root/db/ads.html +ads + +// adult : ICM Registry AD LLC +// https://www.iana.org/domains/root/db/adult.html +adult + +// aeg : Aktiebolaget Electrolux +// https://www.iana.org/domains/root/db/aeg.html +aeg + +// aetna : Aetna Life Insurance Company +// https://www.iana.org/domains/root/db/aetna.html +aetna + +// afl : Australian Football League +// https://www.iana.org/domains/root/db/afl.html +afl + +// africa : ZA Central Registry NPC trading as Registry.Africa +// https://www.iana.org/domains/root/db/africa.html +africa + +// agakhan : Fondation Aga Khan (Aga Khan Foundation) +// https://www.iana.org/domains/root/db/agakhan.html +agakhan + +// agency : Binky Moon, LLC +// https://www.iana.org/domains/root/db/agency.html +agency + +// aig : American International Group, Inc. +// https://www.iana.org/domains/root/db/aig.html +aig + +// airbus : Airbus S.A.S. +// https://www.iana.org/domains/root/db/airbus.html +airbus + +// airforce : Dog Beach, LLC +// https://www.iana.org/domains/root/db/airforce.html +airforce + +// airtel : Bharti Airtel Limited +// https://www.iana.org/domains/root/db/airtel.html +airtel + +// akdn : Fondation Aga Khan (Aga Khan Foundation) +// https://www.iana.org/domains/root/db/akdn.html +akdn + +// alibaba : Alibaba Group Holding Limited +// https://www.iana.org/domains/root/db/alibaba.html +alibaba + +// alipay : Alibaba Group Holding Limited +// https://www.iana.org/domains/root/db/alipay.html +alipay + +// allfinanz : Allfinanz Deutsche Vermögensberatung Aktiengesellschaft +// https://www.iana.org/domains/root/db/allfinanz.html +allfinanz + +// allstate : Allstate Fire and Casualty Insurance Company +// https://www.iana.org/domains/root/db/allstate.html +allstate + +// ally : Ally Financial Inc. +// https://www.iana.org/domains/root/db/ally.html +ally + +// alsace : Region Grand Est +// https://www.iana.org/domains/root/db/alsace.html +alsace + +// alstom : ALSTOM +// https://www.iana.org/domains/root/db/alstom.html +alstom + +// amazon : Amazon Registry Services, Inc. +// https://www.iana.org/domains/root/db/amazon.html +amazon + +// americanexpress : American Express Travel Related Services Company, Inc. +// https://www.iana.org/domains/root/db/americanexpress.html +americanexpress + +// americanfamily : AmFam, Inc. +// https://www.iana.org/domains/root/db/americanfamily.html +americanfamily + +// amex : American Express Travel Related Services Company, Inc. +// https://www.iana.org/domains/root/db/amex.html +amex + +// amfam : AmFam, Inc. +// https://www.iana.org/domains/root/db/amfam.html +amfam + +// amica : Amica Mutual Insurance Company +// https://www.iana.org/domains/root/db/amica.html +amica + +// amsterdam : Gemeente Amsterdam +// https://www.iana.org/domains/root/db/amsterdam.html +amsterdam + +// analytics : Campus IP LLC +// https://www.iana.org/domains/root/db/analytics.html +analytics + +// android : Charleston Road Registry Inc. +// https://www.iana.org/domains/root/db/android.html +android + +// anquan : Beijing Qihu Keji Co., Ltd. +// https://www.iana.org/domains/root/db/anquan.html +anquan + +// anz : Australia and New Zealand Banking Group Limited +// https://www.iana.org/domains/root/db/anz.html +anz + +// aol : AOL Media LLC +// https://www.iana.org/domains/root/db/aol.html +aol + +// apartments : Binky Moon, LLC +// https://www.iana.org/domains/root/db/apartments.html +apartments + +// app : Charleston Road Registry Inc. +// https://www.iana.org/domains/root/db/app.html +app + +// apple : Apple Inc. +// https://www.iana.org/domains/root/db/apple.html +apple + +// aquarelle : Aquarelle.com +// https://www.iana.org/domains/root/db/aquarelle.html +aquarelle + +// arab : League of Arab States +// https://www.iana.org/domains/root/db/arab.html +arab + +// aramco : Aramco Services Company +// https://www.iana.org/domains/root/db/aramco.html +aramco + +// archi : Identity Digital Domains Limited +// https://www.iana.org/domains/root/db/archi.html +archi + +// army : Dog Beach, LLC +// https://www.iana.org/domains/root/db/army.html +army + +// art : UK Creative Ideas Limited +// https://www.iana.org/domains/root/db/art.html +art + +// arte : Association Relative à la Télévision Européenne G.E.I.E. +// https://www.iana.org/domains/root/db/arte.html +arte + +// asda : Asda Stores Limited +// https://www.iana.org/domains/root/db/asda.html +asda + +// associates : Binky Moon, LLC +// https://www.iana.org/domains/root/db/associates.html +associates + +// athleta : The Gap, Inc. +// https://www.iana.org/domains/root/db/athleta.html +athleta + +// attorney : Dog Beach, LLC +// https://www.iana.org/domains/root/db/attorney.html +attorney + +// auction : Dog Beach, LLC +// https://www.iana.org/domains/root/db/auction.html +auction + +// audi : AUDI Aktiengesellschaft +// https://www.iana.org/domains/root/db/audi.html +audi + +// audible : Amazon Registry Services, Inc. +// https://www.iana.org/domains/root/db/audible.html +audible + +// audio : XYZ.COM LLC +// https://www.iana.org/domains/root/db/audio.html +audio + +// auspost : Australian Postal Corporation +// https://www.iana.org/domains/root/db/auspost.html +auspost + +// author : Amazon Registry Services, Inc. +// https://www.iana.org/domains/root/db/author.html +author + +// auto : XYZ.COM LLC +// https://www.iana.org/domains/root/db/auto.html +auto + +// autos : XYZ.COM LLC +// https://www.iana.org/domains/root/db/autos.html +autos + +// aws : AWS Registry LLC +// https://www.iana.org/domains/root/db/aws.html +aws + +// axa : AXA Group Operations SAS +// https://www.iana.org/domains/root/db/axa.html +axa + +// azure : Microsoft Corporation +// https://www.iana.org/domains/root/db/azure.html +azure + +// baby : XYZ.COM LLC +// https://www.iana.org/domains/root/db/baby.html +baby + +// baidu : Baidu, Inc. +// https://www.iana.org/domains/root/db/baidu.html +baidu + +// banamex : Citigroup Inc. +// https://www.iana.org/domains/root/db/banamex.html +banamex + +// band : Dog Beach, LLC +// https://www.iana.org/domains/root/db/band.html +band + +// bank : fTLD Registry Services LLC +// https://www.iana.org/domains/root/db/bank.html +bank + +// bar : Punto 2012 Sociedad Anonima Promotora de Inversion de Capital Variable +// https://www.iana.org/domains/root/db/bar.html +bar + +// barcelona : Municipi de Barcelona +// https://www.iana.org/domains/root/db/barcelona.html +barcelona + +// barclaycard : Barclays Bank PLC +// https://www.iana.org/domains/root/db/barclaycard.html +barclaycard + +// barclays : Barclays Bank PLC +// https://www.iana.org/domains/root/db/barclays.html +barclays + +// barefoot : Gallo Vineyards, Inc. +// https://www.iana.org/domains/root/db/barefoot.html +barefoot + +// bargains : Binky Moon, LLC +// https://www.iana.org/domains/root/db/bargains.html +bargains + +// baseball : MLB Advanced Media DH, LLC +// https://www.iana.org/domains/root/db/baseball.html +baseball + +// basketball : Fédération Internationale de Basketball (FIBA) +// https://www.iana.org/domains/root/db/basketball.html +basketball + +// bauhaus : Werkhaus GmbH +// https://www.iana.org/domains/root/db/bauhaus.html +bauhaus + +// bayern : Bayern Connect GmbH +// https://www.iana.org/domains/root/db/bayern.html +bayern + +// bbc : British Broadcasting Corporation +// https://www.iana.org/domains/root/db/bbc.html +bbc + +// bbt : BB&T Corporation +// https://www.iana.org/domains/root/db/bbt.html +bbt + +// bbva : BANCO BILBAO VIZCAYA ARGENTARIA, S.A. +// https://www.iana.org/domains/root/db/bbva.html +bbva + +// bcg : The Boston Consulting Group, Inc. +// https://www.iana.org/domains/root/db/bcg.html +bcg + +// bcn : Municipi de Barcelona +// https://www.iana.org/domains/root/db/bcn.html +bcn + +// beats : Beats Electronics, LLC +// https://www.iana.org/domains/root/db/beats.html +beats + +// beauty : XYZ.COM LLC +// https://www.iana.org/domains/root/db/beauty.html +beauty + +// beer : Registry Services, LLC +// https://www.iana.org/domains/root/db/beer.html +beer + +// berlin : dotBERLIN GmbH & Co. KG +// https://www.iana.org/domains/root/db/berlin.html +berlin + +// best : BestTLD Pty Ltd +// https://www.iana.org/domains/root/db/best.html +best + +// bestbuy : BBY Solutions, Inc. +// https://www.iana.org/domains/root/db/bestbuy.html +bestbuy + +// bet : Identity Digital Domains Limited +// https://www.iana.org/domains/root/db/bet.html +bet + +// bharti : Bharti Enterprises (Holding) Private Limited +// https://www.iana.org/domains/root/db/bharti.html +bharti + +// bible : American Bible Society +// https://www.iana.org/domains/root/db/bible.html +bible + +// bid : dot Bid Limited +// https://www.iana.org/domains/root/db/bid.html +bid + +// bike : Binky Moon, LLC +// https://www.iana.org/domains/root/db/bike.html +bike + +// bing : Microsoft Corporation +// https://www.iana.org/domains/root/db/bing.html +bing + +// bingo : Binky Moon, LLC +// https://www.iana.org/domains/root/db/bingo.html +bingo + +// bio : Identity Digital Domains Limited +// https://www.iana.org/domains/root/db/bio.html +bio + +// black : Identity Digital Domains Limited +// https://www.iana.org/domains/root/db/black.html +black + +// blackfriday : Registry Services, LLC +// https://www.iana.org/domains/root/db/blackfriday.html +blackfriday + +// blockbuster : Dish DBS Corporation +// https://www.iana.org/domains/root/db/blockbuster.html +blockbuster + +// blog : Knock Knock WHOIS There, LLC +// https://www.iana.org/domains/root/db/blog.html +blog + +// bloomberg : Bloomberg IP Holdings LLC +// https://www.iana.org/domains/root/db/bloomberg.html +bloomberg + +// blue : Identity Digital Domains Limited +// https://www.iana.org/domains/root/db/blue.html +blue + +// bms : Bristol-Myers Squibb Company +// https://www.iana.org/domains/root/db/bms.html +bms + +// bmw : Bayerische Motoren Werke Aktiengesellschaft +// https://www.iana.org/domains/root/db/bmw.html +bmw + +// bnpparibas : BNP Paribas +// https://www.iana.org/domains/root/db/bnpparibas.html +bnpparibas + +// boats : XYZ.COM LLC +// https://www.iana.org/domains/root/db/boats.html +boats + +// boehringer : Boehringer Ingelheim International GmbH +// https://www.iana.org/domains/root/db/boehringer.html +boehringer + +// bofa : Bank of America Corporation +// https://www.iana.org/domains/root/db/bofa.html +bofa + +// bom : Núcleo de Informação e Coordenação do Ponto BR - NIC.br +// https://www.iana.org/domains/root/db/bom.html +bom + +// bond : ShortDot SA +// https://www.iana.org/domains/root/db/bond.html +bond + +// boo : Charleston Road Registry Inc. +// https://www.iana.org/domains/root/db/boo.html +boo + +// book : Amazon Registry Services, Inc. +// https://www.iana.org/domains/root/db/book.html +book + +// booking : Booking.com B.V. +// https://www.iana.org/domains/root/db/booking.html +booking + +// bosch : Robert Bosch GMBH +// https://www.iana.org/domains/root/db/bosch.html +bosch + +// bostik : Bostik SA +// https://www.iana.org/domains/root/db/bostik.html +bostik + +// boston : Registry Services, LLC +// https://www.iana.org/domains/root/db/boston.html +boston + +// bot : Amazon Registry Services, Inc. +// https://www.iana.org/domains/root/db/bot.html +bot + +// boutique : Binky Moon, LLC +// https://www.iana.org/domains/root/db/boutique.html +boutique + +// box : Intercap Registry Inc. +// https://www.iana.org/domains/root/db/box.html +box + +// bradesco : Banco Bradesco S.A. +// https://www.iana.org/domains/root/db/bradesco.html +bradesco + +// bridgestone : Bridgestone Corporation +// https://www.iana.org/domains/root/db/bridgestone.html +bridgestone + +// broadway : Celebrate Broadway, Inc. +// https://www.iana.org/domains/root/db/broadway.html +broadway + +// broker : Dog Beach, LLC +// https://www.iana.org/domains/root/db/broker.html +broker + +// brother : Brother Industries, Ltd. +// https://www.iana.org/domains/root/db/brother.html +brother + +// brussels : DNS.be vzw +// https://www.iana.org/domains/root/db/brussels.html +brussels + +// build : Plan Bee LLC +// https://www.iana.org/domains/root/db/build.html +build + +// builders : Binky Moon, LLC +// https://www.iana.org/domains/root/db/builders.html +builders + +// business : Binky Moon, LLC +// https://www.iana.org/domains/root/db/business.html +business + +// buy : Amazon Registry Services, Inc. +// https://www.iana.org/domains/root/db/buy.html +buy + +// buzz : DOTSTRATEGY CO. +// https://www.iana.org/domains/root/db/buzz.html +buzz + +// bzh : Association www.bzh +// https://www.iana.org/domains/root/db/bzh.html +bzh + +// cab : Binky Moon, LLC +// https://www.iana.org/domains/root/db/cab.html +cab + +// cafe : Binky Moon, LLC +// https://www.iana.org/domains/root/db/cafe.html +cafe + +// cal : Charleston Road Registry Inc. +// https://www.iana.org/domains/root/db/cal.html +cal + +// call : Amazon Registry Services, Inc. +// https://www.iana.org/domains/root/db/call.html +call + +// calvinklein : PVH gTLD Holdings LLC +// https://www.iana.org/domains/root/db/calvinklein.html +calvinklein + +// cam : Cam Connecting SARL +// https://www.iana.org/domains/root/db/cam.html +cam + +// camera : Binky Moon, LLC +// https://www.iana.org/domains/root/db/camera.html +camera + +// camp : Binky Moon, LLC +// https://www.iana.org/domains/root/db/camp.html +camp + +// canon : Canon Inc. +// https://www.iana.org/domains/root/db/canon.html +canon + +// capetown : ZA Central Registry NPC trading as ZA Central Registry +// https://www.iana.org/domains/root/db/capetown.html +capetown + +// capital : Binky Moon, LLC +// https://www.iana.org/domains/root/db/capital.html +capital + +// capitalone : Capital One Financial Corporation +// https://www.iana.org/domains/root/db/capitalone.html +capitalone + +// car : XYZ.COM LLC +// https://www.iana.org/domains/root/db/car.html +car + +// caravan : Caravan International, Inc. +// https://www.iana.org/domains/root/db/caravan.html +caravan + +// cards : Binky Moon, LLC +// https://www.iana.org/domains/root/db/cards.html +cards + +// care : Binky Moon, LLC +// https://www.iana.org/domains/root/db/care.html +care + +// career : dotCareer LLC +// https://www.iana.org/domains/root/db/career.html +career + +// careers : Binky Moon, LLC +// https://www.iana.org/domains/root/db/careers.html +careers + +// cars : XYZ.COM LLC +// https://www.iana.org/domains/root/db/cars.html +cars + +// casa : Registry Services, LLC +// https://www.iana.org/domains/root/db/casa.html +casa + +// case : Digity, LLC +// https://www.iana.org/domains/root/db/case.html +case + +// cash : Binky Moon, LLC +// https://www.iana.org/domains/root/db/cash.html +cash + +// casino : Binky Moon, LLC +// https://www.iana.org/domains/root/db/casino.html +casino + +// catering : Binky Moon, LLC +// https://www.iana.org/domains/root/db/catering.html +catering + +// catholic : Pontificium Consilium de Comunicationibus Socialibus (PCCS) (Pontifical Council for Social Communication) +// https://www.iana.org/domains/root/db/catholic.html +catholic + +// cba : COMMONWEALTH BANK OF AUSTRALIA +// https://www.iana.org/domains/root/db/cba.html +cba + +// cbn : The Christian Broadcasting Network, Inc. +// https://www.iana.org/domains/root/db/cbn.html +cbn + +// cbre : CBRE, Inc. +// https://www.iana.org/domains/root/db/cbre.html +cbre + +// center : Binky Moon, LLC +// https://www.iana.org/domains/root/db/center.html +center + +// ceo : XYZ.COM LLC +// https://www.iana.org/domains/root/db/ceo.html +ceo + +// cern : European Organization for Nuclear Research ("CERN") +// https://www.iana.org/domains/root/db/cern.html +cern + +// cfa : CFA Institute +// https://www.iana.org/domains/root/db/cfa.html +cfa + +// cfd : ShortDot SA +// https://www.iana.org/domains/root/db/cfd.html +cfd + +// chanel : Chanel International B.V. +// https://www.iana.org/domains/root/db/chanel.html +chanel + +// channel : Charleston Road Registry Inc. +// https://www.iana.org/domains/root/db/channel.html +channel + +// charity : Public Interest Registry +// https://www.iana.org/domains/root/db/charity.html +charity + +// chase : JPMorgan Chase Bank, National Association +// https://www.iana.org/domains/root/db/chase.html +chase + +// chat : Binky Moon, LLC +// https://www.iana.org/domains/root/db/chat.html +chat + +// cheap : Binky Moon, LLC +// https://www.iana.org/domains/root/db/cheap.html +cheap + +// chintai : CHINTAI Corporation +// https://www.iana.org/domains/root/db/chintai.html +chintai + +// christmas : XYZ.COM LLC +// https://www.iana.org/domains/root/db/christmas.html +christmas + +// chrome : Charleston Road Registry Inc. +// https://www.iana.org/domains/root/db/chrome.html +chrome + +// church : Binky Moon, LLC +// https://www.iana.org/domains/root/db/church.html +church + +// cipriani : Hotel Cipriani Srl +// https://www.iana.org/domains/root/db/cipriani.html +cipriani + +// circle : Jolly Host, LLC +// https://www.iana.org/domains/root/db/circle.html +circle + +// cisco : Cisco Technology, Inc. +// https://www.iana.org/domains/root/db/cisco.html +cisco + +// citadel : Citadel Domain LLC +// https://www.iana.org/domains/root/db/citadel.html +citadel + +// citi : Citigroup Inc. +// https://www.iana.org/domains/root/db/citi.html +citi + +// citic : CITIC Group Corporation +// https://www.iana.org/domains/root/db/citic.html +citic + +// city : Binky Moon, LLC +// https://www.iana.org/domains/root/db/city.html +city + +// claims : Binky Moon, LLC +// https://www.iana.org/domains/root/db/claims.html +claims + +// cleaning : Binky Moon, LLC +// https://www.iana.org/domains/root/db/cleaning.html +cleaning + +// click : Waterford Limited +// https://www.iana.org/domains/root/db/click.html +click + +// clinic : Binky Moon, LLC +// https://www.iana.org/domains/root/db/clinic.html +clinic + +// clinique : The Estée Lauder Companies Inc. +// https://www.iana.org/domains/root/db/clinique.html +clinique + +// clothing : Binky Moon, LLC +// https://www.iana.org/domains/root/db/clothing.html +clothing + +// cloud : Aruba PEC S.p.A. +// https://www.iana.org/domains/root/db/cloud.html +cloud + +// club : Registry Services, LLC +// https://www.iana.org/domains/root/db/club.html +club + +// clubmed : Club Méditerranée S.A. +// https://www.iana.org/domains/root/db/clubmed.html +clubmed + +// coach : Binky Moon, LLC +// https://www.iana.org/domains/root/db/coach.html +coach + +// codes : Binky Moon, LLC +// https://www.iana.org/domains/root/db/codes.html +codes + +// coffee : Binky Moon, LLC +// https://www.iana.org/domains/root/db/coffee.html +coffee + +// college : XYZ.COM LLC +// https://www.iana.org/domains/root/db/college.html +college + +// cologne : domainworx Service & Management GmbH +// https://www.iana.org/domains/root/db/cologne.html +cologne + +// commbank : COMMONWEALTH BANK OF AUSTRALIA +// https://www.iana.org/domains/root/db/commbank.html +commbank + +// community : Binky Moon, LLC +// https://www.iana.org/domains/root/db/community.html +community + +// company : Binky Moon, LLC +// https://www.iana.org/domains/root/db/company.html +company + +// compare : Registry Services, LLC +// https://www.iana.org/domains/root/db/compare.html +compare + +// computer : Binky Moon, LLC +// https://www.iana.org/domains/root/db/computer.html +computer + +// comsec : VeriSign, Inc. +// https://www.iana.org/domains/root/db/comsec.html +comsec + +// condos : Binky Moon, LLC +// https://www.iana.org/domains/root/db/condos.html +condos + +// construction : Binky Moon, LLC +// https://www.iana.org/domains/root/db/construction.html +construction + +// consulting : Dog Beach, LLC +// https://www.iana.org/domains/root/db/consulting.html +consulting + +// contact : Dog Beach, LLC +// https://www.iana.org/domains/root/db/contact.html +contact + +// contractors : Binky Moon, LLC +// https://www.iana.org/domains/root/db/contractors.html +contractors + +// cooking : Registry Services, LLC +// https://www.iana.org/domains/root/db/cooking.html +cooking + +// cool : Binky Moon, LLC +// https://www.iana.org/domains/root/db/cool.html +cool + +// corsica : Collectivité de Corse +// https://www.iana.org/domains/root/db/corsica.html +corsica + +// country : Internet Naming Company LLC +// https://www.iana.org/domains/root/db/country.html +country + +// coupon : Amazon Registry Services, Inc. +// https://www.iana.org/domains/root/db/coupon.html +coupon + +// coupons : Binky Moon, LLC +// https://www.iana.org/domains/root/db/coupons.html +coupons + +// courses : Registry Services, LLC +// https://www.iana.org/domains/root/db/courses.html +courses + +// cpa : American Institute of Certified Public Accountants +// https://www.iana.org/domains/root/db/cpa.html +cpa + +// credit : Binky Moon, LLC +// https://www.iana.org/domains/root/db/credit.html +credit + +// creditcard : Binky Moon, LLC +// https://www.iana.org/domains/root/db/creditcard.html +creditcard + +// creditunion : DotCooperation LLC +// https://www.iana.org/domains/root/db/creditunion.html +creditunion + +// cricket : dot Cricket Limited +// https://www.iana.org/domains/root/db/cricket.html +cricket + +// crown : Crown Equipment Corporation +// https://www.iana.org/domains/root/db/crown.html +crown + +// crs : Federated Co-operatives Limited +// https://www.iana.org/domains/root/db/crs.html +crs + +// cruise : Viking River Cruises (Bermuda) Ltd. +// https://www.iana.org/domains/root/db/cruise.html +cruise + +// cruises : Binky Moon, LLC +// https://www.iana.org/domains/root/db/cruises.html +cruises + +// cuisinella : SCHMIDT GROUPE S.A.S. +// https://www.iana.org/domains/root/db/cuisinella.html +cuisinella + +// cymru : Nominet UK +// https://www.iana.org/domains/root/db/cymru.html +cymru + +// cyou : ShortDot SA +// https://www.iana.org/domains/root/db/cyou.html +cyou + +// dad : Charleston Road Registry Inc. +// https://www.iana.org/domains/root/db/dad.html +dad + +// dance : Dog Beach, LLC +// https://www.iana.org/domains/root/db/dance.html +dance + +// data : Dish DBS Corporation +// https://www.iana.org/domains/root/db/data.html +data + +// date : dot Date Limited +// https://www.iana.org/domains/root/db/date.html +date + +// dating : Binky Moon, LLC +// https://www.iana.org/domains/root/db/dating.html +dating + +// datsun : NISSAN MOTOR CO., LTD. +// https://www.iana.org/domains/root/db/datsun.html +datsun + +// day : Charleston Road Registry Inc. +// https://www.iana.org/domains/root/db/day.html +day + +// dclk : Charleston Road Registry Inc. +// https://www.iana.org/domains/root/db/dclk.html +dclk + +// dds : Registry Services, LLC +// https://www.iana.org/domains/root/db/dds.html +dds + +// deal : Amazon Registry Services, Inc. +// https://www.iana.org/domains/root/db/deal.html +deal + +// dealer : Intercap Registry Inc. +// https://www.iana.org/domains/root/db/dealer.html +dealer + +// deals : Binky Moon, LLC +// https://www.iana.org/domains/root/db/deals.html +deals + +// degree : Dog Beach, LLC +// https://www.iana.org/domains/root/db/degree.html +degree + +// delivery : Binky Moon, LLC +// https://www.iana.org/domains/root/db/delivery.html +delivery + +// dell : Dell Inc. +// https://www.iana.org/domains/root/db/dell.html +dell + +// deloitte : Deloitte Touche Tohmatsu +// https://www.iana.org/domains/root/db/deloitte.html +deloitte + +// delta : Delta Air Lines, Inc. +// https://www.iana.org/domains/root/db/delta.html +delta + +// democrat : Dog Beach, LLC +// https://www.iana.org/domains/root/db/democrat.html +democrat + +// dental : Binky Moon, LLC +// https://www.iana.org/domains/root/db/dental.html +dental + +// dentist : Dog Beach, LLC +// https://www.iana.org/domains/root/db/dentist.html +dentist + +// desi +// https://www.iana.org/domains/root/db/desi.html +desi + +// design : Registry Services, LLC +// https://www.iana.org/domains/root/db/design.html +design + +// dev : Charleston Road Registry Inc. +// https://www.iana.org/domains/root/db/dev.html +dev + +// dhl : Deutsche Post AG +// https://www.iana.org/domains/root/db/dhl.html +dhl + +// diamonds : Binky Moon, LLC +// https://www.iana.org/domains/root/db/diamonds.html +diamonds + +// diet : XYZ.COM LLC +// https://www.iana.org/domains/root/db/diet.html +diet + +// digital : Binky Moon, LLC +// https://www.iana.org/domains/root/db/digital.html +digital + +// direct : Binky Moon, LLC +// https://www.iana.org/domains/root/db/direct.html +direct + +// directory : Binky Moon, LLC +// https://www.iana.org/domains/root/db/directory.html +directory + +// discount : Binky Moon, LLC +// https://www.iana.org/domains/root/db/discount.html +discount + +// discover : Discover Financial Services +// https://www.iana.org/domains/root/db/discover.html +discover + +// dish : Dish DBS Corporation +// https://www.iana.org/domains/root/db/dish.html +dish + +// diy : Internet Naming Company LLC +// https://www.iana.org/domains/root/db/diy.html +diy + +// dnp : Dai Nippon Printing Co., Ltd. +// https://www.iana.org/domains/root/db/dnp.html +dnp + +// docs : Charleston Road Registry Inc. +// https://www.iana.org/domains/root/db/docs.html +docs + +// doctor : Binky Moon, LLC +// https://www.iana.org/domains/root/db/doctor.html +doctor + +// dog : Binky Moon, LLC +// https://www.iana.org/domains/root/db/dog.html +dog + +// domains : Binky Moon, LLC +// https://www.iana.org/domains/root/db/domains.html +domains + +// dot : Dish DBS Corporation +// https://www.iana.org/domains/root/db/dot.html +dot + +// download : dot Support Limited +// https://www.iana.org/domains/root/db/download.html +download + +// drive : Charleston Road Registry Inc. +// https://www.iana.org/domains/root/db/drive.html +drive + +// dtv : Dish DBS Corporation +// https://www.iana.org/domains/root/db/dtv.html +dtv + +// dubai : Dubai Smart Government Department +// https://www.iana.org/domains/root/db/dubai.html +dubai + +// dupont : DuPont Specialty Products USA, LLC +// https://www.iana.org/domains/root/db/dupont.html +dupont + +// durban : ZA Central Registry NPC trading as ZA Central Registry +// https://www.iana.org/domains/root/db/durban.html +durban + +// dvag : Deutsche Vermögensberatung Aktiengesellschaft DVAG +// https://www.iana.org/domains/root/db/dvag.html +dvag + +// dvr : DISH Technologies L.L.C. +// https://www.iana.org/domains/root/db/dvr.html +dvr + +// earth : Interlink Systems Innovation Institute K.K. +// https://www.iana.org/domains/root/db/earth.html +earth + +// eat : Charleston Road Registry Inc. +// https://www.iana.org/domains/root/db/eat.html +eat + +// eco : Big Room Inc. +// https://www.iana.org/domains/root/db/eco.html +eco + +// edeka : EDEKA Verband kaufmännischer Genossenschaften e.V. +// https://www.iana.org/domains/root/db/edeka.html +edeka + +// education : Binky Moon, LLC +// https://www.iana.org/domains/root/db/education.html +education + +// email : Binky Moon, LLC +// https://www.iana.org/domains/root/db/email.html +email + +// emerck : Merck KGaA +// https://www.iana.org/domains/root/db/emerck.html +emerck + +// energy : Binky Moon, LLC +// https://www.iana.org/domains/root/db/energy.html +energy + +// engineer : Dog Beach, LLC +// https://www.iana.org/domains/root/db/engineer.html +engineer + +// engineering : Binky Moon, LLC +// https://www.iana.org/domains/root/db/engineering.html +engineering + +// enterprises : Binky Moon, LLC +// https://www.iana.org/domains/root/db/enterprises.html +enterprises + +// epson : Seiko Epson Corporation +// https://www.iana.org/domains/root/db/epson.html +epson + +// equipment : Binky Moon, LLC +// https://www.iana.org/domains/root/db/equipment.html +equipment + +// ericsson : Telefonaktiebolaget L M Ericsson +// https://www.iana.org/domains/root/db/ericsson.html +ericsson + +// erni : ERNI Group Holding AG +// https://www.iana.org/domains/root/db/erni.html +erni + +// esq : Charleston Road Registry Inc. +// https://www.iana.org/domains/root/db/esq.html +esq + +// estate : Binky Moon, LLC +// https://www.iana.org/domains/root/db/estate.html +estate + +// eurovision : European Broadcasting Union (EBU) +// https://www.iana.org/domains/root/db/eurovision.html +eurovision + +// eus : Puntueus Fundazioa +// https://www.iana.org/domains/root/db/eus.html +eus + +// events : Binky Moon, LLC +// https://www.iana.org/domains/root/db/events.html +events + +// exchange : Binky Moon, LLC +// https://www.iana.org/domains/root/db/exchange.html +exchange + +// expert : Binky Moon, LLC +// https://www.iana.org/domains/root/db/expert.html +expert + +// exposed : Binky Moon, LLC +// https://www.iana.org/domains/root/db/exposed.html +exposed + +// express : Binky Moon, LLC +// https://www.iana.org/domains/root/db/express.html +express + +// extraspace : Extra Space Storage LLC +// https://www.iana.org/domains/root/db/extraspace.html +extraspace + +// fage : Fage International S.A. +// https://www.iana.org/domains/root/db/fage.html +fage + +// fail : Binky Moon, LLC +// https://www.iana.org/domains/root/db/fail.html +fail + +// fairwinds : FairWinds Partners, LLC +// https://www.iana.org/domains/root/db/fairwinds.html +fairwinds + +// faith : dot Faith Limited +// https://www.iana.org/domains/root/db/faith.html +faith + +// family : Dog Beach, LLC +// https://www.iana.org/domains/root/db/family.html +family + +// fan : Dog Beach, LLC +// https://www.iana.org/domains/root/db/fan.html +fan + +// fans : ZDNS International Limited +// https://www.iana.org/domains/root/db/fans.html +fans + +// farm : Binky Moon, LLC +// https://www.iana.org/domains/root/db/farm.html +farm + +// farmers : Farmers Insurance Exchange +// https://www.iana.org/domains/root/db/farmers.html +farmers + +// fashion : Registry Services, LLC +// https://www.iana.org/domains/root/db/fashion.html +fashion + +// fast : Amazon Registry Services, Inc. +// https://www.iana.org/domains/root/db/fast.html +fast + +// fedex : Federal Express Corporation +// https://www.iana.org/domains/root/db/fedex.html +fedex + +// feedback : Top Level Spectrum, Inc. +// https://www.iana.org/domains/root/db/feedback.html +feedback + +// ferrari : Fiat Chrysler Automobiles N.V. +// https://www.iana.org/domains/root/db/ferrari.html +ferrari + +// ferrero : Ferrero Trading Lux S.A. +// https://www.iana.org/domains/root/db/ferrero.html +ferrero + +// fidelity : Fidelity Brokerage Services LLC +// https://www.iana.org/domains/root/db/fidelity.html +fidelity + +// fido : Rogers Communications Canada Inc. +// https://www.iana.org/domains/root/db/fido.html +fido + +// film : Motion Picture Domain Registry Pty Ltd +// https://www.iana.org/domains/root/db/film.html +film + +// final : Núcleo de Informação e Coordenação do Ponto BR - NIC.br +// https://www.iana.org/domains/root/db/final.html +final + +// finance : Binky Moon, LLC +// https://www.iana.org/domains/root/db/finance.html +finance + +// financial : Binky Moon, LLC +// https://www.iana.org/domains/root/db/financial.html +financial + +// fire : Amazon Registry Services, Inc. +// https://www.iana.org/domains/root/db/fire.html +fire + +// firestone : Bridgestone Licensing Services, Inc +// https://www.iana.org/domains/root/db/firestone.html +firestone + +// firmdale : Firmdale Holdings Limited +// https://www.iana.org/domains/root/db/firmdale.html +firmdale + +// fish : Binky Moon, LLC +// https://www.iana.org/domains/root/db/fish.html +fish + +// fishing : Registry Services, LLC +// https://www.iana.org/domains/root/db/fishing.html +fishing + +// fit : Registry Services, LLC +// https://www.iana.org/domains/root/db/fit.html +fit + +// fitness : Binky Moon, LLC +// https://www.iana.org/domains/root/db/fitness.html +fitness + +// flickr : Flickr, Inc. +// https://www.iana.org/domains/root/db/flickr.html +flickr + +// flights : Binky Moon, LLC +// https://www.iana.org/domains/root/db/flights.html +flights + +// flir : FLIR Systems, Inc. +// https://www.iana.org/domains/root/db/flir.html +flir + +// florist : Binky Moon, LLC +// https://www.iana.org/domains/root/db/florist.html +florist + +// flowers : XYZ.COM LLC +// https://www.iana.org/domains/root/db/flowers.html +flowers + +// fly : Charleston Road Registry Inc. +// https://www.iana.org/domains/root/db/fly.html +fly + +// foo : Charleston Road Registry Inc. +// https://www.iana.org/domains/root/db/foo.html +foo + +// food : Internet Naming Company LLC +// https://www.iana.org/domains/root/db/food.html +food + +// football : Binky Moon, LLC +// https://www.iana.org/domains/root/db/football.html +football + +// ford : Ford Motor Company +// https://www.iana.org/domains/root/db/ford.html +ford + +// forex : Dog Beach, LLC +// https://www.iana.org/domains/root/db/forex.html +forex + +// forsale : Dog Beach, LLC +// https://www.iana.org/domains/root/db/forsale.html +forsale + +// forum : Waterford Limited +// https://www.iana.org/domains/root/db/forum.html +forum + +// foundation : Public Interest Registry +// https://www.iana.org/domains/root/db/foundation.html +foundation + +// fox : FOX Registry, LLC +// https://www.iana.org/domains/root/db/fox.html +fox + +// free : Amazon Registry Services, Inc. +// https://www.iana.org/domains/root/db/free.html +free + +// fresenius : Fresenius Immobilien-Verwaltungs-GmbH +// https://www.iana.org/domains/root/db/fresenius.html +fresenius + +// frl : FRLregistry B.V. +// https://www.iana.org/domains/root/db/frl.html +frl + +// frogans : OP3FT +// https://www.iana.org/domains/root/db/frogans.html +frogans + +// frontier : Frontier Communications Corporation +// https://www.iana.org/domains/root/db/frontier.html +frontier + +// ftr : Frontier Communications Corporation +// https://www.iana.org/domains/root/db/ftr.html +ftr + +// fujitsu : Fujitsu Limited +// https://www.iana.org/domains/root/db/fujitsu.html +fujitsu + +// fun : Radix Technologies Inc SEZC +// https://www.iana.org/domains/root/db/fun.html +fun + +// fund : Binky Moon, LLC +// https://www.iana.org/domains/root/db/fund.html +fund + +// furniture : Binky Moon, LLC +// https://www.iana.org/domains/root/db/furniture.html +furniture + +// futbol : Dog Beach, LLC +// https://www.iana.org/domains/root/db/futbol.html +futbol + +// fyi : Binky Moon, LLC +// https://www.iana.org/domains/root/db/fyi.html +fyi + +// gal : Asociación puntoGAL +// https://www.iana.org/domains/root/db/gal.html +gal + +// gallery : Binky Moon, LLC +// https://www.iana.org/domains/root/db/gallery.html +gallery + +// gallo : Gallo Vineyards, Inc. +// https://www.iana.org/domains/root/db/gallo.html +gallo + +// gallup : Gallup, Inc. +// https://www.iana.org/domains/root/db/gallup.html +gallup + +// game : XYZ.COM LLC +// https://www.iana.org/domains/root/db/game.html +game + +// games : Dog Beach, LLC +// https://www.iana.org/domains/root/db/games.html +games + +// gap : The Gap, Inc. +// https://www.iana.org/domains/root/db/gap.html +gap + +// garden : Registry Services, LLC +// https://www.iana.org/domains/root/db/garden.html +garden + +// gay : Registry Services, LLC +// https://www.iana.org/domains/root/db/gay.html +gay + +// gbiz : Charleston Road Registry Inc. +// https://www.iana.org/domains/root/db/gbiz.html +gbiz + +// gdn : Joint Stock Company "Navigation-information systems" +// https://www.iana.org/domains/root/db/gdn.html +gdn + +// gea : GEA Group Aktiengesellschaft +// https://www.iana.org/domains/root/db/gea.html +gea + +// gent : Easyhost BV +// https://www.iana.org/domains/root/db/gent.html +gent + +// genting : Resorts World Inc Pte. Ltd. +// https://www.iana.org/domains/root/db/genting.html +genting + +// george : Wal-Mart Stores, Inc. +// https://www.iana.org/domains/root/db/george.html +george + +// ggee : GMO Internet, Inc. +// https://www.iana.org/domains/root/db/ggee.html +ggee + +// gift : DotGift, LLC +// https://www.iana.org/domains/root/db/gift.html +gift + +// gifts : Binky Moon, LLC +// https://www.iana.org/domains/root/db/gifts.html +gifts + +// gives : Public Interest Registry +// https://www.iana.org/domains/root/db/gives.html +gives + +// giving : Public Interest Registry +// https://www.iana.org/domains/root/db/giving.html +giving + +// glass : Binky Moon, LLC +// https://www.iana.org/domains/root/db/glass.html +glass + +// gle : Charleston Road Registry Inc. +// https://www.iana.org/domains/root/db/gle.html +gle + +// global : Identity Digital Domains Limited +// https://www.iana.org/domains/root/db/global.html +global + +// globo : Globo Comunicação e Participações S.A +// https://www.iana.org/domains/root/db/globo.html +globo + +// gmail : Charleston Road Registry Inc. +// https://www.iana.org/domains/root/db/gmail.html +gmail + +// gmbh : Binky Moon, LLC +// https://www.iana.org/domains/root/db/gmbh.html +gmbh + +// gmo : GMO Internet, Inc. +// https://www.iana.org/domains/root/db/gmo.html +gmo + +// gmx : 1&1 Mail & Media GmbH +// https://www.iana.org/domains/root/db/gmx.html +gmx + +// godaddy : Go Daddy East, LLC +// https://www.iana.org/domains/root/db/godaddy.html +godaddy + +// gold : Binky Moon, LLC +// https://www.iana.org/domains/root/db/gold.html +gold + +// goldpoint : YODOBASHI CAMERA CO.,LTD. +// https://www.iana.org/domains/root/db/goldpoint.html +goldpoint + +// golf : Binky Moon, LLC +// https://www.iana.org/domains/root/db/golf.html +golf + +// goodyear : The Goodyear Tire & Rubber Company +// https://www.iana.org/domains/root/db/goodyear.html +goodyear + +// goog : Charleston Road Registry Inc. +// https://www.iana.org/domains/root/db/goog.html +goog + +// google : Charleston Road Registry Inc. +// https://www.iana.org/domains/root/db/google.html +google + +// gop : Republican State Leadership Committee, Inc. +// https://www.iana.org/domains/root/db/gop.html +gop + +// got : Jolly Host, LLC +// https://www.iana.org/domains/root/db/got.html +got + +// grainger : Grainger Registry Services, LLC +// https://www.iana.org/domains/root/db/grainger.html +grainger + +// graphics : Binky Moon, LLC +// https://www.iana.org/domains/root/db/graphics.html +graphics + +// gratis : Binky Moon, LLC +// https://www.iana.org/domains/root/db/gratis.html +gratis + +// green : Identity Digital Domains Limited +// https://www.iana.org/domains/root/db/green.html +green + +// gripe : Binky Moon, LLC +// https://www.iana.org/domains/root/db/gripe.html +gripe + +// grocery : Wal-Mart Stores, Inc. +// https://www.iana.org/domains/root/db/grocery.html +grocery + +// group : Binky Moon, LLC +// https://www.iana.org/domains/root/db/group.html +group + +// gucci : Guccio Gucci S.p.a. +// https://www.iana.org/domains/root/db/gucci.html +gucci + +// guge : Charleston Road Registry Inc. +// https://www.iana.org/domains/root/db/guge.html +guge + +// guide : Binky Moon, LLC +// https://www.iana.org/domains/root/db/guide.html +guide + +// guitars : XYZ.COM LLC +// https://www.iana.org/domains/root/db/guitars.html +guitars + +// guru : Binky Moon, LLC +// https://www.iana.org/domains/root/db/guru.html +guru + +// hair : XYZ.COM LLC +// https://www.iana.org/domains/root/db/hair.html +hair + +// hamburg : Hamburg Top-Level-Domain GmbH +// https://www.iana.org/domains/root/db/hamburg.html +hamburg + +// hangout : Charleston Road Registry Inc. +// https://www.iana.org/domains/root/db/hangout.html +hangout + +// haus : Dog Beach, LLC +// https://www.iana.org/domains/root/db/haus.html +haus + +// hbo : HBO Registry Services, Inc. +// https://www.iana.org/domains/root/db/hbo.html +hbo + +// hdfc : HDFC BANK LIMITED +// https://www.iana.org/domains/root/db/hdfc.html +hdfc + +// hdfcbank : HDFC BANK LIMITED +// https://www.iana.org/domains/root/db/hdfcbank.html +hdfcbank + +// health : Registry Services, LLC +// https://www.iana.org/domains/root/db/health.html +health + +// healthcare : Binky Moon, LLC +// https://www.iana.org/domains/root/db/healthcare.html +healthcare + +// help : Innovation service Limited +// https://www.iana.org/domains/root/db/help.html +help + +// helsinki : City of Helsinki +// https://www.iana.org/domains/root/db/helsinki.html +helsinki + +// here : Charleston Road Registry Inc. +// https://www.iana.org/domains/root/db/here.html +here + +// hermes : HERMES INTERNATIONAL +// https://www.iana.org/domains/root/db/hermes.html +hermes + +// hiphop : Dot Hip Hop, LLC +// https://www.iana.org/domains/root/db/hiphop.html +hiphop + +// hisamitsu : Hisamitsu Pharmaceutical Co.,Inc. +// https://www.iana.org/domains/root/db/hisamitsu.html +hisamitsu + +// hitachi : Hitachi, Ltd. +// https://www.iana.org/domains/root/db/hitachi.html +hitachi + +// hiv : Internet Naming Company LLC +// https://www.iana.org/domains/root/db/hiv.html +hiv + +// hkt : PCCW-HKT DataCom Services Limited +// https://www.iana.org/domains/root/db/hkt.html +hkt + +// hockey : Binky Moon, LLC +// https://www.iana.org/domains/root/db/hockey.html +hockey + +// holdings : Binky Moon, LLC +// https://www.iana.org/domains/root/db/holdings.html +holdings + +// holiday : Binky Moon, LLC +// https://www.iana.org/domains/root/db/holiday.html +holiday + +// homedepot : Home Depot Product Authority, LLC +// https://www.iana.org/domains/root/db/homedepot.html +homedepot + +// homegoods : The TJX Companies, Inc. +// https://www.iana.org/domains/root/db/homegoods.html +homegoods + +// homes : XYZ.COM LLC +// https://www.iana.org/domains/root/db/homes.html +homes + +// homesense : The TJX Companies, Inc. +// https://www.iana.org/domains/root/db/homesense.html +homesense + +// honda : Honda Motor Co., Ltd. +// https://www.iana.org/domains/root/db/honda.html +honda + +// horse : Registry Services, LLC +// https://www.iana.org/domains/root/db/horse.html +horse + +// hospital : Binky Moon, LLC +// https://www.iana.org/domains/root/db/hospital.html +hospital + +// host : Radix Technologies Inc SEZC +// https://www.iana.org/domains/root/db/host.html +host + +// hosting : XYZ.COM LLC +// https://www.iana.org/domains/root/db/hosting.html +hosting + +// hot : Amazon Registry Services, Inc. +// https://www.iana.org/domains/root/db/hot.html +hot + +// hotel : HOTEL Top-Level-Domain S.a.r.l +// https://www.iana.org/domains/root/db/hotel.html +hotel + +// hotels : Booking.com B.V. +// https://www.iana.org/domains/root/db/hotels.html +hotels + +// hotmail : Microsoft Corporation +// https://www.iana.org/domains/root/db/hotmail.html +hotmail + +// house : Binky Moon, LLC +// https://www.iana.org/domains/root/db/house.html +house + +// how : Charleston Road Registry Inc. +// https://www.iana.org/domains/root/db/how.html +how + +// hsbc : HSBC Global Services (UK) Limited +// https://www.iana.org/domains/root/db/hsbc.html +hsbc + +// hughes : Hughes Satellite Systems Corporation +// https://www.iana.org/domains/root/db/hughes.html +hughes + +// hyatt : Hyatt GTLD, L.L.C. +// https://www.iana.org/domains/root/db/hyatt.html +hyatt + +// hyundai : Hyundai Motor Company +// https://www.iana.org/domains/root/db/hyundai.html +hyundai + +// ibm : International Business Machines Corporation +// https://www.iana.org/domains/root/db/ibm.html +ibm + +// icbc : Industrial and Commercial Bank of China Limited +// https://www.iana.org/domains/root/db/icbc.html +icbc + +// ice : IntercontinentalExchange, Inc. +// https://www.iana.org/domains/root/db/ice.html +ice + +// icu : ShortDot SA +// https://www.iana.org/domains/root/db/icu.html +icu + +// ieee : IEEE Global LLC +// https://www.iana.org/domains/root/db/ieee.html +ieee + +// ifm : ifm electronic gmbh +// https://www.iana.org/domains/root/db/ifm.html +ifm + +// ikano : Ikano S.A. +// https://www.iana.org/domains/root/db/ikano.html +ikano + +// imamat : Fondation Aga Khan (Aga Khan Foundation) +// https://www.iana.org/domains/root/db/imamat.html +imamat + +// imdb : Amazon Registry Services, Inc. +// https://www.iana.org/domains/root/db/imdb.html +imdb + +// immo : Binky Moon, LLC +// https://www.iana.org/domains/root/db/immo.html +immo + +// immobilien : Dog Beach, LLC +// https://www.iana.org/domains/root/db/immobilien.html +immobilien + +// inc : Intercap Registry Inc. +// https://www.iana.org/domains/root/db/inc.html +inc + +// industries : Binky Moon, LLC +// https://www.iana.org/domains/root/db/industries.html +industries + +// infiniti : NISSAN MOTOR CO., LTD. +// https://www.iana.org/domains/root/db/infiniti.html +infiniti + +// ing : Charleston Road Registry Inc. +// https://www.iana.org/domains/root/db/ing.html +ing + +// ink : Registry Services, LLC +// https://www.iana.org/domains/root/db/ink.html +ink + +// institute : Binky Moon, LLC +// https://www.iana.org/domains/root/db/institute.html +institute + +// insurance : fTLD Registry Services LLC +// https://www.iana.org/domains/root/db/insurance.html +insurance + +// insure : Binky Moon, LLC +// https://www.iana.org/domains/root/db/insure.html +insure + +// international : Binky Moon, LLC +// https://www.iana.org/domains/root/db/international.html +international + +// intuit : Intuit Administrative Services, Inc. +// https://www.iana.org/domains/root/db/intuit.html +intuit + +// investments : Binky Moon, LLC +// https://www.iana.org/domains/root/db/investments.html +investments + +// ipiranga : Ipiranga Produtos de Petroleo S.A. +// https://www.iana.org/domains/root/db/ipiranga.html +ipiranga + +// irish : Binky Moon, LLC +// https://www.iana.org/domains/root/db/irish.html +irish + +// ismaili : Fondation Aga Khan (Aga Khan Foundation) +// https://www.iana.org/domains/root/db/ismaili.html +ismaili + +// ist : Istanbul Metropolitan Municipality +// https://www.iana.org/domains/root/db/ist.html +ist + +// istanbul : Istanbul Metropolitan Municipality +// https://www.iana.org/domains/root/db/istanbul.html +istanbul + +// itau : Itau Unibanco Holding S.A. +// https://www.iana.org/domains/root/db/itau.html +itau + +// itv : ITV Services Limited +// https://www.iana.org/domains/root/db/itv.html +itv + +// jaguar : Jaguar Land Rover Ltd +// https://www.iana.org/domains/root/db/jaguar.html +jaguar + +// java : Oracle Corporation +// https://www.iana.org/domains/root/db/java.html +java + +// jcb : JCB Co., Ltd. +// https://www.iana.org/domains/root/db/jcb.html +jcb + +// jeep : FCA US LLC. +// https://www.iana.org/domains/root/db/jeep.html +jeep + +// jetzt : Binky Moon, LLC +// https://www.iana.org/domains/root/db/jetzt.html +jetzt + +// jewelry : Binky Moon, LLC +// https://www.iana.org/domains/root/db/jewelry.html +jewelry + +// jio : Reliance Industries Limited +// https://www.iana.org/domains/root/db/jio.html +jio + +// jll : Jones Lang LaSalle Incorporated +// https://www.iana.org/domains/root/db/jll.html +jll + +// jmp : Matrix IP LLC +// https://www.iana.org/domains/root/db/jmp.html +jmp + +// jnj : Johnson & Johnson Services, Inc. +// https://www.iana.org/domains/root/db/jnj.html +jnj + +// joburg : ZA Central Registry NPC trading as ZA Central Registry +// https://www.iana.org/domains/root/db/joburg.html +joburg + +// jot : Jolly Host, LLC +// https://www.iana.org/domains/root/db/jot.html +jot + +// joy : Amazon Registry Services, Inc. +// https://www.iana.org/domains/root/db/joy.html +joy + +// jpmorgan : JPMorgan Chase Bank, National Association +// https://www.iana.org/domains/root/db/jpmorgan.html +jpmorgan + +// jprs : Japan Registry Services Co., Ltd. +// https://www.iana.org/domains/root/db/jprs.html +jprs + +// juegos : Dog Beach, LLC +// https://www.iana.org/domains/root/db/juegos.html +juegos + +// kaufen : Dog Beach, LLC +// https://www.iana.org/domains/root/db/kaufen.html +kaufen + +// kddi : KDDI CORPORATION +// https://www.iana.org/domains/root/db/kddi.html +kddi + +// kerryhotels : Kerry Trading Co. Limited +// https://www.iana.org/domains/root/db/kerryhotels.html +kerryhotels + +// kerryproperties : Kerry Trading Co. Limited +// https://www.iana.org/domains/root/db/kerryproperties.html +kerryproperties + +// kfh : Kuwait Finance House +// https://www.iana.org/domains/root/db/kfh.html +kfh + +// kia : KIA MOTORS CORPORATION +// https://www.iana.org/domains/root/db/kia.html +kia + +// kids : DotKids Foundation Limited +// https://www.iana.org/domains/root/db/kids.html +kids + +// kim : Identity Digital Domains Limited +// https://www.iana.org/domains/root/db/kim.html +kim + +// kindle : Amazon Registry Services, Inc. +// https://www.iana.org/domains/root/db/kindle.html +kindle + +// kitchen : Binky Moon, LLC +// https://www.iana.org/domains/root/db/kitchen.html +kitchen + +// kiwi : DOT KIWI LIMITED +// https://www.iana.org/domains/root/db/kiwi.html +kiwi + +// koeln : domainworx Service & Management GmbH +// https://www.iana.org/domains/root/db/koeln.html +koeln + +// komatsu : Komatsu Ltd. +// https://www.iana.org/domains/root/db/komatsu.html +komatsu + +// kosher : Kosher Marketing Assets LLC +// https://www.iana.org/domains/root/db/kosher.html +kosher + +// kpmg : KPMG International Cooperative (KPMG International Genossenschaft) +// https://www.iana.org/domains/root/db/kpmg.html +kpmg + +// kpn : Koninklijke KPN N.V. +// https://www.iana.org/domains/root/db/kpn.html +kpn + +// krd : KRG Department of Information Technology +// https://www.iana.org/domains/root/db/krd.html +krd + +// kred : KredTLD Pty Ltd +// https://www.iana.org/domains/root/db/kred.html +kred + +// kuokgroup : Kerry Trading Co. Limited +// https://www.iana.org/domains/root/db/kuokgroup.html +kuokgroup + +// kyoto : Academic Institution: The University of Informatics +// https://www.iana.org/domains/root/db/kyoto.html +kyoto + +// lacaixa : Fundación Bancaria Caixa d’Estalvis i Pensions de Barcelona, “la Caixa” +// https://www.iana.org/domains/root/db/lacaixa.html +lacaixa + +// lamborghini : Automobili Lamborghini S.p.A. +// https://www.iana.org/domains/root/db/lamborghini.html +lamborghini + +// lamer : The Estée Lauder Companies Inc. +// https://www.iana.org/domains/root/db/lamer.html +lamer + +// land : Binky Moon, LLC +// https://www.iana.org/domains/root/db/land.html +land + +// landrover : Jaguar Land Rover Ltd +// https://www.iana.org/domains/root/db/landrover.html +landrover + +// lanxess : LANXESS Corporation +// https://www.iana.org/domains/root/db/lanxess.html +lanxess + +// lasalle : Jones Lang LaSalle Incorporated +// https://www.iana.org/domains/root/db/lasalle.html +lasalle + +// lat : XYZ.COM LLC +// https://www.iana.org/domains/root/db/lat.html +lat + +// latino : Dish DBS Corporation +// https://www.iana.org/domains/root/db/latino.html +latino + +// latrobe : La Trobe University +// https://www.iana.org/domains/root/db/latrobe.html +latrobe + +// law : Registry Services, LLC +// https://www.iana.org/domains/root/db/law.html +law + +// lawyer : Dog Beach, LLC +// https://www.iana.org/domains/root/db/lawyer.html +lawyer + +// lds : IRI Domain Management, LLC +// https://www.iana.org/domains/root/db/lds.html +lds + +// lease : Binky Moon, LLC +// https://www.iana.org/domains/root/db/lease.html +lease + +// leclerc : A.C.D. LEC Association des Centres Distributeurs Edouard Leclerc +// https://www.iana.org/domains/root/db/leclerc.html +leclerc + +// lefrak : LeFrak Organization, Inc. +// https://www.iana.org/domains/root/db/lefrak.html +lefrak + +// legal : Binky Moon, LLC +// https://www.iana.org/domains/root/db/legal.html +legal + +// lego : LEGO Juris A/S +// https://www.iana.org/domains/root/db/lego.html +lego + +// lexus : TOYOTA MOTOR CORPORATION +// https://www.iana.org/domains/root/db/lexus.html +lexus + +// lgbt : Identity Digital Domains Limited +// https://www.iana.org/domains/root/db/lgbt.html +lgbt + +// lidl : Schwarz Domains und Services GmbH & Co. KG +// https://www.iana.org/domains/root/db/lidl.html +lidl + +// life : Binky Moon, LLC +// https://www.iana.org/domains/root/db/life.html +life + +// lifeinsurance : American Council of Life Insurers +// https://www.iana.org/domains/root/db/lifeinsurance.html +lifeinsurance + +// lifestyle : Internet Naming Company LLC +// https://www.iana.org/domains/root/db/lifestyle.html +lifestyle + +// lighting : Binky Moon, LLC +// https://www.iana.org/domains/root/db/lighting.html +lighting + +// like : Amazon Registry Services, Inc. +// https://www.iana.org/domains/root/db/like.html +like + +// lilly : Eli Lilly and Company +// https://www.iana.org/domains/root/db/lilly.html +lilly + +// limited : Binky Moon, LLC +// https://www.iana.org/domains/root/db/limited.html +limited + +// limo : Binky Moon, LLC +// https://www.iana.org/domains/root/db/limo.html +limo + +// lincoln : Ford Motor Company +// https://www.iana.org/domains/root/db/lincoln.html +lincoln + +// link : Nova Registry Ltd +// https://www.iana.org/domains/root/db/link.html +link + +// live : Dog Beach, LLC +// https://www.iana.org/domains/root/db/live.html +live + +// living : Internet Naming Company LLC +// https://www.iana.org/domains/root/db/living.html +living + +// llc : Identity Digital Domains Limited +// https://www.iana.org/domains/root/db/llc.html +llc + +// llp : Intercap Registry Inc. +// https://www.iana.org/domains/root/db/llp.html +llp + +// loan : dot Loan Limited +// https://www.iana.org/domains/root/db/loan.html +loan + +// loans : Binky Moon, LLC +// https://www.iana.org/domains/root/db/loans.html +loans + +// locker : Orange Domains LLC +// https://www.iana.org/domains/root/db/locker.html +locker + +// locus : Locus Analytics LLC +// https://www.iana.org/domains/root/db/locus.html +locus + +// lol : XYZ.COM LLC +// https://www.iana.org/domains/root/db/lol.html +lol + +// london : Dot London Domains Limited +// https://www.iana.org/domains/root/db/london.html +london + +// lotte : Lotte Holdings Co., Ltd. +// https://www.iana.org/domains/root/db/lotte.html +lotte + +// lotto : Identity Digital Domains Limited +// https://www.iana.org/domains/root/db/lotto.html +lotto + +// love : Waterford Limited +// https://www.iana.org/domains/root/db/love.html +love + +// lpl : LPL Holdings, Inc. +// https://www.iana.org/domains/root/db/lpl.html +lpl + +// lplfinancial : LPL Holdings, Inc. +// https://www.iana.org/domains/root/db/lplfinancial.html +lplfinancial + +// ltd : Binky Moon, LLC +// https://www.iana.org/domains/root/db/ltd.html +ltd + +// ltda : InterNetX, Corp +// https://www.iana.org/domains/root/db/ltda.html +ltda + +// lundbeck : H. Lundbeck A/S +// https://www.iana.org/domains/root/db/lundbeck.html +lundbeck + +// luxe : Registry Services, LLC +// https://www.iana.org/domains/root/db/luxe.html +luxe + +// luxury : Luxury Partners, LLC +// https://www.iana.org/domains/root/db/luxury.html +luxury + +// madrid : Comunidad de Madrid +// https://www.iana.org/domains/root/db/madrid.html +madrid + +// maif : Mutuelle Assurance Instituteur France (MAIF) +// https://www.iana.org/domains/root/db/maif.html +maif + +// maison : Binky Moon, LLC +// https://www.iana.org/domains/root/db/maison.html +maison + +// makeup : XYZ.COM LLC +// https://www.iana.org/domains/root/db/makeup.html +makeup + +// man : MAN Truck & Bus SE +// https://www.iana.org/domains/root/db/man.html +man + +// management : Binky Moon, LLC +// https://www.iana.org/domains/root/db/management.html +management + +// mango : PUNTO FA S.L. +// https://www.iana.org/domains/root/db/mango.html +mango + +// map : Charleston Road Registry Inc. +// https://www.iana.org/domains/root/db/map.html +map + +// market : Dog Beach, LLC +// https://www.iana.org/domains/root/db/market.html +market + +// marketing : Binky Moon, LLC +// https://www.iana.org/domains/root/db/marketing.html +marketing + +// markets : Dog Beach, LLC +// https://www.iana.org/domains/root/db/markets.html +markets + +// marriott : Marriott Worldwide Corporation +// https://www.iana.org/domains/root/db/marriott.html +marriott + +// marshalls : The TJX Companies, Inc. +// https://www.iana.org/domains/root/db/marshalls.html +marshalls + +// mattel : Mattel IT Services, Inc. +// https://www.iana.org/domains/root/db/mattel.html +mattel + +// mba : Binky Moon, LLC +// https://www.iana.org/domains/root/db/mba.html +mba + +// mckinsey : McKinsey Holdings, Inc. +// https://www.iana.org/domains/root/db/mckinsey.html +mckinsey + +// med : Medistry LLC +// https://www.iana.org/domains/root/db/med.html +med + +// media : Binky Moon, LLC +// https://www.iana.org/domains/root/db/media.html +media + +// meet : Charleston Road Registry Inc. +// https://www.iana.org/domains/root/db/meet.html +meet + +// melbourne : The Crown in right of the State of Victoria, represented by its Department of State Development, Business and Innovation +// https://www.iana.org/domains/root/db/melbourne.html +melbourne + +// meme : Charleston Road Registry Inc. +// https://www.iana.org/domains/root/db/meme.html +meme + +// memorial : Dog Beach, LLC +// https://www.iana.org/domains/root/db/memorial.html +memorial + +// men : Exclusive Registry Limited +// https://www.iana.org/domains/root/db/men.html +men + +// menu : Dot Menu Registry, LLC +// https://www.iana.org/domains/root/db/menu.html +menu + +// merck : Merck Registry Holdings, Inc. +// https://www.iana.org/domains/root/db/merck.html +merck + +// merckmsd : MSD Registry Holdings, Inc. +// https://www.iana.org/domains/root/db/merckmsd.html +merckmsd + +// miami : Registry Services, LLC +// https://www.iana.org/domains/root/db/miami.html +miami + +// microsoft : Microsoft Corporation +// https://www.iana.org/domains/root/db/microsoft.html +microsoft + +// mini : Bayerische Motoren Werke Aktiengesellschaft +// https://www.iana.org/domains/root/db/mini.html +mini + +// mint : Intuit Administrative Services, Inc. +// https://www.iana.org/domains/root/db/mint.html +mint + +// mit : Massachusetts Institute of Technology +// https://www.iana.org/domains/root/db/mit.html +mit + +// mitsubishi : Mitsubishi Corporation +// https://www.iana.org/domains/root/db/mitsubishi.html +mitsubishi + +// mlb : MLB Advanced Media DH, LLC +// https://www.iana.org/domains/root/db/mlb.html +mlb + +// mls : The Canadian Real Estate Association +// https://www.iana.org/domains/root/db/mls.html +mls + +// mma : MMA IARD +// https://www.iana.org/domains/root/db/mma.html +mma + +// mobile : Dish DBS Corporation +// https://www.iana.org/domains/root/db/mobile.html +mobile + +// moda : Dog Beach, LLC +// https://www.iana.org/domains/root/db/moda.html +moda + +// moe : Interlink Systems Innovation Institute K.K. +// https://www.iana.org/domains/root/db/moe.html +moe + +// moi : Amazon Registry Services, Inc. +// https://www.iana.org/domains/root/db/moi.html +moi + +// mom : XYZ.COM LLC +// https://www.iana.org/domains/root/db/mom.html +mom + +// monash : Monash University +// https://www.iana.org/domains/root/db/monash.html +monash + +// money : Binky Moon, LLC +// https://www.iana.org/domains/root/db/money.html +money + +// monster : XYZ.COM LLC +// https://www.iana.org/domains/root/db/monster.html +monster + +// mormon : IRI Domain Management, LLC +// https://www.iana.org/domains/root/db/mormon.html +mormon + +// mortgage : Dog Beach, LLC +// https://www.iana.org/domains/root/db/mortgage.html +mortgage + +// moscow : Foundation for Assistance for Internet Technologies and Infrastructure Development (FAITID) +// https://www.iana.org/domains/root/db/moscow.html +moscow + +// moto : Motorola Trademark Holdings, LLC +// https://www.iana.org/domains/root/db/moto.html +moto + +// motorcycles : XYZ.COM LLC +// https://www.iana.org/domains/root/db/motorcycles.html +motorcycles + +// mov : Charleston Road Registry Inc. +// https://www.iana.org/domains/root/db/mov.html +mov + +// movie : Binky Moon, LLC +// https://www.iana.org/domains/root/db/movie.html +movie + +// msd : MSD Registry Holdings, Inc. +// https://www.iana.org/domains/root/db/msd.html +msd + +// mtn : MTN Dubai Limited +// https://www.iana.org/domains/root/db/mtn.html +mtn + +// mtr : MTR Corporation Limited +// https://www.iana.org/domains/root/db/mtr.html +mtr + +// music : DotMusic Limited +// https://www.iana.org/domains/root/db/music.html +music + +// nab : National Australia Bank Limited +// https://www.iana.org/domains/root/db/nab.html +nab + +// nagoya : GMO Registry, Inc. +// https://www.iana.org/domains/root/db/nagoya.html +nagoya + +// navy : Dog Beach, LLC +// https://www.iana.org/domains/root/db/navy.html +navy + +// nba : NBA REGISTRY, LLC +// https://www.iana.org/domains/root/db/nba.html +nba + +// nec : NEC Corporation +// https://www.iana.org/domains/root/db/nec.html +nec + +// netbank : COMMONWEALTH BANK OF AUSTRALIA +// https://www.iana.org/domains/root/db/netbank.html +netbank + +// netflix : Netflix, Inc. +// https://www.iana.org/domains/root/db/netflix.html +netflix + +// network : Binky Moon, LLC +// https://www.iana.org/domains/root/db/network.html +network + +// neustar : NeuStar, Inc. +// https://www.iana.org/domains/root/db/neustar.html +neustar + +// new : Charleston Road Registry Inc. +// https://www.iana.org/domains/root/db/new.html +new + +// news : Dog Beach, LLC +// https://www.iana.org/domains/root/db/news.html +news + +// next : Next plc +// https://www.iana.org/domains/root/db/next.html +next + +// nextdirect : Next plc +// https://www.iana.org/domains/root/db/nextdirect.html +nextdirect + +// nexus : Charleston Road Registry Inc. +// https://www.iana.org/domains/root/db/nexus.html +nexus + +// nfl : NFL Reg Ops LLC +// https://www.iana.org/domains/root/db/nfl.html +nfl + +// ngo : Public Interest Registry +// https://www.iana.org/domains/root/db/ngo.html +ngo + +// nhk : Japan Broadcasting Corporation (NHK) +// https://www.iana.org/domains/root/db/nhk.html +nhk + +// nico : DWANGO Co., Ltd. +// https://www.iana.org/domains/root/db/nico.html +nico + +// nike : NIKE, Inc. +// https://www.iana.org/domains/root/db/nike.html +nike + +// nikon : NIKON CORPORATION +// https://www.iana.org/domains/root/db/nikon.html +nikon + +// ninja : Dog Beach, LLC +// https://www.iana.org/domains/root/db/ninja.html +ninja + +// nissan : NISSAN MOTOR CO., LTD. +// https://www.iana.org/domains/root/db/nissan.html +nissan + +// nissay : Nippon Life Insurance Company +// https://www.iana.org/domains/root/db/nissay.html +nissay + +// nokia : Nokia Corporation +// https://www.iana.org/domains/root/db/nokia.html +nokia + +// norton : Gen Digital Inc. +// https://www.iana.org/domains/root/db/norton.html +norton + +// now : Amazon Registry Services, Inc. +// https://www.iana.org/domains/root/db/now.html +now + +// nowruz +// https://www.iana.org/domains/root/db/nowruz.html +nowruz + +// nowtv : Starbucks (HK) Limited +// https://www.iana.org/domains/root/db/nowtv.html +nowtv + +// nra : National Rifle Association of America +// https://www.iana.org/domains/root/db/nra.html +nra + +// nrw : Minds + Machines GmbH +// https://www.iana.org/domains/root/db/nrw.html +nrw + +// ntt : NIPPON TELEGRAPH AND TELEPHONE CORPORATION +// https://www.iana.org/domains/root/db/ntt.html +ntt + +// nyc : The City of New York by and through the New York City Department of Information Technology & Telecommunications +// https://www.iana.org/domains/root/db/nyc.html +nyc + +// obi : OBI Group Holding SE & Co. KGaA +// https://www.iana.org/domains/root/db/obi.html +obi + +// observer : Fegistry, LLC +// https://www.iana.org/domains/root/db/observer.html +observer + +// office : Microsoft Corporation +// https://www.iana.org/domains/root/db/office.html +office + +// okinawa : BRregistry, Inc. +// https://www.iana.org/domains/root/db/okinawa.html +okinawa + +// olayan : Competrol (Luxembourg) Sarl +// https://www.iana.org/domains/root/db/olayan.html +olayan + +// olayangroup : Competrol (Luxembourg) Sarl +// https://www.iana.org/domains/root/db/olayangroup.html +olayangroup + +// ollo : Dish DBS Corporation +// https://www.iana.org/domains/root/db/ollo.html +ollo + +// omega : The Swatch Group Ltd +// https://www.iana.org/domains/root/db/omega.html +omega + +// one : One.com A/S +// https://www.iana.org/domains/root/db/one.html +one + +// ong : Public Interest Registry +// https://www.iana.org/domains/root/db/ong.html +ong + +// onl : Jolly Host, LLC +// https://www.iana.org/domains/root/db/onl.html +onl + +// online : Radix Technologies Inc SEZC +// https://www.iana.org/domains/root/db/online.html +online + +// ooo : INFIBEAM AVENUES LIMITED +// https://www.iana.org/domains/root/db/ooo.html +ooo + +// open : American Express Travel Related Services Company, Inc. +// https://www.iana.org/domains/root/db/open.html +open + +// oracle : Oracle Corporation +// https://www.iana.org/domains/root/db/oracle.html +oracle + +// orange : Orange Brand Services Limited +// https://www.iana.org/domains/root/db/orange.html +orange + +// organic : Identity Digital Domains Limited +// https://www.iana.org/domains/root/db/organic.html +organic + +// origins : The Estée Lauder Companies Inc. +// https://www.iana.org/domains/root/db/origins.html +origins + +// osaka : Osaka Registry Co., Ltd. +// https://www.iana.org/domains/root/db/osaka.html +osaka + +// otsuka : Otsuka Holdings Co., Ltd. +// https://www.iana.org/domains/root/db/otsuka.html +otsuka + +// ott : Dish DBS Corporation +// https://www.iana.org/domains/root/db/ott.html +ott + +// ovh : MédiaBC +// https://www.iana.org/domains/root/db/ovh.html +ovh + +// page : Charleston Road Registry Inc. +// https://www.iana.org/domains/root/db/page.html +page + +// panasonic : Panasonic Holdings Corporation +// https://www.iana.org/domains/root/db/panasonic.html +panasonic + +// paris : City of Paris +// https://www.iana.org/domains/root/db/paris.html +paris + +// pars +// https://www.iana.org/domains/root/db/pars.html +pars + +// partners : Binky Moon, LLC +// https://www.iana.org/domains/root/db/partners.html +partners + +// parts : Binky Moon, LLC +// https://www.iana.org/domains/root/db/parts.html +parts + +// party : Blue Sky Registry Limited +// https://www.iana.org/domains/root/db/party.html +party + +// pay : Amazon Registry Services, Inc. +// https://www.iana.org/domains/root/db/pay.html +pay + +// pccw : PCCW Enterprises Limited +// https://www.iana.org/domains/root/db/pccw.html +pccw + +// pet : Identity Digital Domains Limited +// https://www.iana.org/domains/root/db/pet.html +pet + +// pfizer : Pfizer Inc. +// https://www.iana.org/domains/root/db/pfizer.html +pfizer + +// pharmacy : National Association of Boards of Pharmacy +// https://www.iana.org/domains/root/db/pharmacy.html +pharmacy + +// phd : Charleston Road Registry Inc. +// https://www.iana.org/domains/root/db/phd.html +phd + +// philips : Koninklijke Philips N.V. +// https://www.iana.org/domains/root/db/philips.html +philips + +// phone : Dish DBS Corporation +// https://www.iana.org/domains/root/db/phone.html +phone + +// photo : Registry Services, LLC +// https://www.iana.org/domains/root/db/photo.html +photo + +// photography : Binky Moon, LLC +// https://www.iana.org/domains/root/db/photography.html +photography + +// photos : Binky Moon, LLC +// https://www.iana.org/domains/root/db/photos.html +photos + +// physio : PhysBiz Pty Ltd +// https://www.iana.org/domains/root/db/physio.html +physio + +// pics : XYZ.COM LLC +// https://www.iana.org/domains/root/db/pics.html +pics + +// pictet : Banque Pictet & Cie SA +// https://www.iana.org/domains/root/db/pictet.html +pictet + +// pictures : Binky Moon, LLC +// https://www.iana.org/domains/root/db/pictures.html +pictures + +// pid : Top Level Spectrum, Inc. +// https://www.iana.org/domains/root/db/pid.html +pid + +// pin : Amazon Registry Services, Inc. +// https://www.iana.org/domains/root/db/pin.html +pin + +// ping : Ping Registry Provider, Inc. +// https://www.iana.org/domains/root/db/ping.html +ping + +// pink : Identity Digital Domains Limited +// https://www.iana.org/domains/root/db/pink.html +pink + +// pioneer : Pioneer Corporation +// https://www.iana.org/domains/root/db/pioneer.html +pioneer + +// pizza : Binky Moon, LLC +// https://www.iana.org/domains/root/db/pizza.html +pizza + +// place : Binky Moon, LLC +// https://www.iana.org/domains/root/db/place.html +place + +// play : Charleston Road Registry Inc. +// https://www.iana.org/domains/root/db/play.html +play + +// playstation : Sony Interactive Entertainment Inc. +// https://www.iana.org/domains/root/db/playstation.html +playstation + +// plumbing : Binky Moon, LLC +// https://www.iana.org/domains/root/db/plumbing.html +plumbing + +// plus : Binky Moon, LLC +// https://www.iana.org/domains/root/db/plus.html +plus + +// pnc : PNC Domain Co., LLC +// https://www.iana.org/domains/root/db/pnc.html +pnc + +// pohl : Deutsche Vermögensberatung Aktiengesellschaft DVAG +// https://www.iana.org/domains/root/db/pohl.html +pohl + +// poker : Identity Digital Domains Limited +// https://www.iana.org/domains/root/db/poker.html +poker + +// politie : Politie Nederland +// https://www.iana.org/domains/root/db/politie.html +politie + +// porn : ICM Registry PN LLC +// https://www.iana.org/domains/root/db/porn.html +porn + +// praxi : Praxi S.p.A. +// https://www.iana.org/domains/root/db/praxi.html +praxi + +// press : Radix Technologies Inc SEZC +// https://www.iana.org/domains/root/db/press.html +press + +// prime : Amazon Registry Services, Inc. +// https://www.iana.org/domains/root/db/prime.html +prime + +// prod : Charleston Road Registry Inc. +// https://www.iana.org/domains/root/db/prod.html +prod + +// productions : Binky Moon, LLC +// https://www.iana.org/domains/root/db/productions.html +productions + +// prof : Charleston Road Registry Inc. +// https://www.iana.org/domains/root/db/prof.html +prof + +// progressive : Progressive Casualty Insurance Company +// https://www.iana.org/domains/root/db/progressive.html +progressive + +// promo : Identity Digital Domains Limited +// https://www.iana.org/domains/root/db/promo.html +promo + +// properties : Binky Moon, LLC +// https://www.iana.org/domains/root/db/properties.html +properties + +// property : Digital Property Infrastructure Limited +// https://www.iana.org/domains/root/db/property.html +property + +// protection : XYZ.COM LLC +// https://www.iana.org/domains/root/db/protection.html +protection + +// pru : Prudential Financial, Inc. +// https://www.iana.org/domains/root/db/pru.html +pru + +// prudential : Prudential Financial, Inc. +// https://www.iana.org/domains/root/db/prudential.html +prudential + +// pub : Dog Beach, LLC +// https://www.iana.org/domains/root/db/pub.html +pub + +// pwc : PricewaterhouseCoopers LLP +// https://www.iana.org/domains/root/db/pwc.html +pwc + +// qpon : dotQPON LLC +// https://www.iana.org/domains/root/db/qpon.html +qpon + +// quebec : PointQuébec Inc +// https://www.iana.org/domains/root/db/quebec.html +quebec + +// quest : XYZ.COM LLC +// https://www.iana.org/domains/root/db/quest.html +quest + +// racing : Premier Registry Limited +// https://www.iana.org/domains/root/db/racing.html +racing + +// radio : Digity, LLC +// https://www.iana.org/domains/root/db/radio.html +radio + +// read : Amazon Registry Services, Inc. +// https://www.iana.org/domains/root/db/read.html +read + +// realestate : dotRealEstate LLC +// https://www.iana.org/domains/root/db/realestate.html +realestate + +// realtor : Real Estate Domains LLC +// https://www.iana.org/domains/root/db/realtor.html +realtor + +// realty : Waterford Limited +// https://www.iana.org/domains/root/db/realty.html +realty + +// recipes : Binky Moon, LLC +// https://www.iana.org/domains/root/db/recipes.html +recipes + +// red : Identity Digital Domains Limited +// https://www.iana.org/domains/root/db/red.html +red + +// redumbrella : Travelers TLD, LLC +// https://www.iana.org/domains/root/db/redumbrella.html +redumbrella + +// rehab : Dog Beach, LLC +// https://www.iana.org/domains/root/db/rehab.html +rehab + +// reise : Binky Moon, LLC +// https://www.iana.org/domains/root/db/reise.html +reise + +// reisen : Binky Moon, LLC +// https://www.iana.org/domains/root/db/reisen.html +reisen + +// reit : National Association of Real Estate Investment Trusts, Inc. +// https://www.iana.org/domains/root/db/reit.html +reit + +// reliance : Reliance Industries Limited +// https://www.iana.org/domains/root/db/reliance.html +reliance + +// ren : ZDNS International Limited +// https://www.iana.org/domains/root/db/ren.html +ren + +// rent : XYZ.COM LLC +// https://www.iana.org/domains/root/db/rent.html +rent + +// rentals : Binky Moon, LLC +// https://www.iana.org/domains/root/db/rentals.html +rentals + +// repair : Binky Moon, LLC +// https://www.iana.org/domains/root/db/repair.html +repair + +// report : Binky Moon, LLC +// https://www.iana.org/domains/root/db/report.html +report + +// republican : Dog Beach, LLC +// https://www.iana.org/domains/root/db/republican.html +republican + +// rest : Punto 2012 Sociedad Anonima Promotora de Inversion de Capital Variable +// https://www.iana.org/domains/root/db/rest.html +rest + +// restaurant : Binky Moon, LLC +// https://www.iana.org/domains/root/db/restaurant.html +restaurant + +// review : dot Review Limited +// https://www.iana.org/domains/root/db/review.html +review + +// reviews : Dog Beach, LLC +// https://www.iana.org/domains/root/db/reviews.html +reviews + +// rexroth : Robert Bosch GMBH +// https://www.iana.org/domains/root/db/rexroth.html +rexroth + +// rich : iRegistry GmbH +// https://www.iana.org/domains/root/db/rich.html +rich + +// richardli : Pacific Century Asset Management (HK) Limited +// https://www.iana.org/domains/root/db/richardli.html +richardli + +// ricoh : Ricoh Company, Ltd. +// https://www.iana.org/domains/root/db/ricoh.html +ricoh + +// ril : Reliance Industries Limited +// https://www.iana.org/domains/root/db/ril.html +ril + +// rio : Empresa Municipal de Informática SA - IPLANRIO +// https://www.iana.org/domains/root/db/rio.html +rio + +// rip : Dog Beach, LLC +// https://www.iana.org/domains/root/db/rip.html +rip + +// rocks : Dog Beach, LLC +// https://www.iana.org/domains/root/db/rocks.html +rocks + +// rodeo : Registry Services, LLC +// https://www.iana.org/domains/root/db/rodeo.html +rodeo + +// rogers : Rogers Communications Canada Inc. +// https://www.iana.org/domains/root/db/rogers.html +rogers + +// room : Amazon Registry Services, Inc. +// https://www.iana.org/domains/root/db/room.html +room + +// rsvp : Charleston Road Registry Inc. +// https://www.iana.org/domains/root/db/rsvp.html +rsvp + +// rugby : World Rugby Strategic Developments Limited +// https://www.iana.org/domains/root/db/rugby.html +rugby + +// ruhr : dotSaarland GmbH +// https://www.iana.org/domains/root/db/ruhr.html +ruhr + +// run : Binky Moon, LLC +// https://www.iana.org/domains/root/db/run.html +run + +// rwe : RWE AG +// https://www.iana.org/domains/root/db/rwe.html +rwe + +// ryukyu : BRregistry, Inc. +// https://www.iana.org/domains/root/db/ryukyu.html +ryukyu + +// saarland : dotSaarland GmbH +// https://www.iana.org/domains/root/db/saarland.html +saarland + +// safe : Amazon Registry Services, Inc. +// https://www.iana.org/domains/root/db/safe.html +safe + +// safety : Jolly Host, LLC +// https://www.iana.org/domains/root/db/safety.html +safety + +// sakura : SAKURA Internet Inc. +// https://www.iana.org/domains/root/db/sakura.html +sakura + +// sale : Dog Beach, LLC +// https://www.iana.org/domains/root/db/sale.html +sale + +// salon : Binky Moon, LLC +// https://www.iana.org/domains/root/db/salon.html +salon + +// samsclub : Wal-Mart Stores, Inc. +// https://www.iana.org/domains/root/db/samsclub.html +samsclub + +// samsung : SAMSUNG SDS CO., LTD +// https://www.iana.org/domains/root/db/samsung.html +samsung + +// sandvik : Sandvik AB +// https://www.iana.org/domains/root/db/sandvik.html +sandvik + +// sandvikcoromant : Sandvik AB +// https://www.iana.org/domains/root/db/sandvikcoromant.html +sandvikcoromant + +// sanofi : Sanofi +// https://www.iana.org/domains/root/db/sanofi.html +sanofi + +// sap : SAP AG +// https://www.iana.org/domains/root/db/sap.html +sap + +// sarl : Binky Moon, LLC +// https://www.iana.org/domains/root/db/sarl.html +sarl + +// sas : Research IP LLC +// https://www.iana.org/domains/root/db/sas.html +sas + +// save : Amazon Registry Services, Inc. +// https://www.iana.org/domains/root/db/save.html +save + +// saxo : Saxo Bank A/S +// https://www.iana.org/domains/root/db/saxo.html +saxo + +// sbi : STATE BANK OF INDIA +// https://www.iana.org/domains/root/db/sbi.html +sbi + +// sbs : ShortDot SA +// https://www.iana.org/domains/root/db/sbs.html +sbs + +// scb : The Siam Commercial Bank Public Company Limited ("SCB") +// https://www.iana.org/domains/root/db/scb.html +scb + +// schaeffler : Schaeffler Technologies AG & Co. KG +// https://www.iana.org/domains/root/db/schaeffler.html +schaeffler + +// schmidt : SCHMIDT GROUPE S.A.S. +// https://www.iana.org/domains/root/db/schmidt.html +schmidt + +// scholarships : Scholarships.com, LLC +// https://www.iana.org/domains/root/db/scholarships.html +scholarships + +// school : Binky Moon, LLC +// https://www.iana.org/domains/root/db/school.html +school + +// schule : Binky Moon, LLC +// https://www.iana.org/domains/root/db/schule.html +schule + +// schwarz : Schwarz Domains und Services GmbH & Co. KG +// https://www.iana.org/domains/root/db/schwarz.html +schwarz + +// science : dot Science Limited +// https://www.iana.org/domains/root/db/science.html +science + +// scot : Dot Scot Registry Limited +// https://www.iana.org/domains/root/db/scot.html +scot + +// search : Charleston Road Registry Inc. +// https://www.iana.org/domains/root/db/search.html +search + +// seat : SEAT, S.A. (Sociedad Unipersonal) +// https://www.iana.org/domains/root/db/seat.html +seat + +// secure : Amazon Registry Services, Inc. +// https://www.iana.org/domains/root/db/secure.html +secure + +// security : XYZ.COM LLC +// https://www.iana.org/domains/root/db/security.html +security + +// seek : Seek Limited +// https://www.iana.org/domains/root/db/seek.html +seek + +// select : Registry Services, LLC +// https://www.iana.org/domains/root/db/select.html +select + +// sener : Sener Ingeniería y Sistemas, S.A. +// https://www.iana.org/domains/root/db/sener.html +sener + +// services : Binky Moon, LLC +// https://www.iana.org/domains/root/db/services.html +services + +// seven : Seven West Media Ltd +// https://www.iana.org/domains/root/db/seven.html +seven + +// sew : SEW-EURODRIVE GmbH & Co KG +// https://www.iana.org/domains/root/db/sew.html +sew + +// sex : ICM Registry SX LLC +// https://www.iana.org/domains/root/db/sex.html +sex + +// sexy : Internet Naming Company LLC +// https://www.iana.org/domains/root/db/sexy.html +sexy + +// sfr : Societe Francaise du Radiotelephone - SFR +// https://www.iana.org/domains/root/db/sfr.html +sfr + +// shangrila : Shangri‐La International Hotel Management Limited +// https://www.iana.org/domains/root/db/shangrila.html +shangrila + +// sharp : Sharp Corporation +// https://www.iana.org/domains/root/db/sharp.html +sharp + +// shell : Shell Information Technology International Inc +// https://www.iana.org/domains/root/db/shell.html +shell + +// shia +// https://www.iana.org/domains/root/db/shia.html +shia + +// shiksha : Identity Digital Domains Limited +// https://www.iana.org/domains/root/db/shiksha.html +shiksha + +// shoes : Binky Moon, LLC +// https://www.iana.org/domains/root/db/shoes.html +shoes + +// shop : GMO Registry, Inc. +// https://www.iana.org/domains/root/db/shop.html +shop + +// shopping : Binky Moon, LLC +// https://www.iana.org/domains/root/db/shopping.html +shopping + +// shouji : Beijing Qihu Keji Co., Ltd. +// https://www.iana.org/domains/root/db/shouji.html +shouji + +// show : Binky Moon, LLC +// https://www.iana.org/domains/root/db/show.html +show + +// silk : Amazon Registry Services, Inc. +// https://www.iana.org/domains/root/db/silk.html +silk + +// sina : Sina Corporation +// https://www.iana.org/domains/root/db/sina.html +sina + +// singles : Binky Moon, LLC +// https://www.iana.org/domains/root/db/singles.html +singles + +// site : Radix Technologies Inc SEZC +// https://www.iana.org/domains/root/db/site.html +site + +// ski : Identity Digital Domains Limited +// https://www.iana.org/domains/root/db/ski.html +ski + +// skin : XYZ.COM LLC +// https://www.iana.org/domains/root/db/skin.html +skin + +// sky : Sky UK Limited +// https://www.iana.org/domains/root/db/sky.html +sky + +// skype : Microsoft Corporation +// https://www.iana.org/domains/root/db/skype.html +skype + +// sling : DISH Technologies L.L.C. +// https://www.iana.org/domains/root/db/sling.html +sling + +// smart : Smart Communications, Inc. (SMART) +// https://www.iana.org/domains/root/db/smart.html +smart + +// smile : Amazon Registry Services, Inc. +// https://www.iana.org/domains/root/db/smile.html +smile + +// sncf : Société Nationale SNCF +// https://www.iana.org/domains/root/db/sncf.html +sncf + +// soccer : Binky Moon, LLC +// https://www.iana.org/domains/root/db/soccer.html +soccer + +// social : Dog Beach, LLC +// https://www.iana.org/domains/root/db/social.html +social + +// softbank : SoftBank Group Corp. +// https://www.iana.org/domains/root/db/softbank.html +softbank + +// software : Dog Beach, LLC +// https://www.iana.org/domains/root/db/software.html +software + +// sohu : Sohu.com Limited +// https://www.iana.org/domains/root/db/sohu.html +sohu + +// solar : Binky Moon, LLC +// https://www.iana.org/domains/root/db/solar.html +solar + +// solutions : Binky Moon, LLC +// https://www.iana.org/domains/root/db/solutions.html +solutions + +// song : Amazon Registry Services, Inc. +// https://www.iana.org/domains/root/db/song.html +song + +// sony : Sony Group Corporation +// https://www.iana.org/domains/root/db/sony.html +sony + +// soy : Charleston Road Registry Inc. +// https://www.iana.org/domains/root/db/soy.html +soy + +// spa : Asia Spa and Wellness Promotion Council Limited +// https://www.iana.org/domains/root/db/spa.html +spa + +// space : Radix Technologies Inc SEZC +// https://www.iana.org/domains/root/db/space.html +space + +// sport : SportAccord +// https://www.iana.org/domains/root/db/sport.html +sport + +// spot : Amazon Registry Services, Inc. +// https://www.iana.org/domains/root/db/spot.html +spot + +// srl : InterNetX, Corp +// https://www.iana.org/domains/root/db/srl.html +srl + +// stada : STADA Arzneimittel AG +// https://www.iana.org/domains/root/db/stada.html +stada + +// staples : Staples, Inc. +// https://www.iana.org/domains/root/db/staples.html +staples + +// star : Star India Private Limited +// https://www.iana.org/domains/root/db/star.html +star + +// statebank : STATE BANK OF INDIA +// https://www.iana.org/domains/root/db/statebank.html +statebank + +// statefarm : State Farm Mutual Automobile Insurance Company +// https://www.iana.org/domains/root/db/statefarm.html +statefarm + +// stc : Saudi Telecom Company +// https://www.iana.org/domains/root/db/stc.html +stc + +// stcgroup : Saudi Telecom Company +// https://www.iana.org/domains/root/db/stcgroup.html +stcgroup + +// stockholm : Stockholms kommun +// https://www.iana.org/domains/root/db/stockholm.html +stockholm + +// storage : XYZ.COM LLC +// https://www.iana.org/domains/root/db/storage.html +storage + +// store : Radix Technologies Inc SEZC +// https://www.iana.org/domains/root/db/store.html +store + +// stream : dot Stream Limited +// https://www.iana.org/domains/root/db/stream.html +stream + +// studio : Dog Beach, LLC +// https://www.iana.org/domains/root/db/studio.html +studio + +// study : Registry Services, LLC +// https://www.iana.org/domains/root/db/study.html +study + +// style : Binky Moon, LLC +// https://www.iana.org/domains/root/db/style.html +style + +// sucks : Vox Populi Registry Ltd. +// https://www.iana.org/domains/root/db/sucks.html +sucks + +// supplies : Binky Moon, LLC +// https://www.iana.org/domains/root/db/supplies.html +supplies + +// supply : Binky Moon, LLC +// https://www.iana.org/domains/root/db/supply.html +supply + +// support : Binky Moon, LLC +// https://www.iana.org/domains/root/db/support.html +support + +// surf : Registry Services, LLC +// https://www.iana.org/domains/root/db/surf.html +surf + +// surgery : Binky Moon, LLC +// https://www.iana.org/domains/root/db/surgery.html +surgery + +// suzuki : SUZUKI MOTOR CORPORATION +// https://www.iana.org/domains/root/db/suzuki.html +suzuki + +// swatch : The Swatch Group Ltd +// https://www.iana.org/domains/root/db/swatch.html +swatch + +// swiss : Swiss Confederation +// https://www.iana.org/domains/root/db/swiss.html +swiss + +// sydney : State of New South Wales, Department of Premier and Cabinet +// https://www.iana.org/domains/root/db/sydney.html +sydney + +// systems : Binky Moon, LLC +// https://www.iana.org/domains/root/db/systems.html +systems + +// tab : Tabcorp Holdings Limited +// https://www.iana.org/domains/root/db/tab.html +tab + +// taipei : Taipei City Government +// https://www.iana.org/domains/root/db/taipei.html +taipei + +// talk : Amazon Registry Services, Inc. +// https://www.iana.org/domains/root/db/talk.html +talk + +// taobao : Alibaba Group Holding Limited +// https://www.iana.org/domains/root/db/taobao.html +taobao + +// target : Target Domain Holdings, LLC +// https://www.iana.org/domains/root/db/target.html +target + +// tatamotors : Tata Motors Ltd +// https://www.iana.org/domains/root/db/tatamotors.html +tatamotors + +// tatar : Limited Liability Company "Coordination Center of Regional Domain of Tatarstan Republic" +// https://www.iana.org/domains/root/db/tatar.html +tatar + +// tattoo : Registry Services, LLC +// https://www.iana.org/domains/root/db/tattoo.html +tattoo + +// tax : Binky Moon, LLC +// https://www.iana.org/domains/root/db/tax.html +tax + +// taxi : Binky Moon, LLC +// https://www.iana.org/domains/root/db/taxi.html +taxi + +// tci +// https://www.iana.org/domains/root/db/tci.html +tci + +// tdk : TDK Corporation +// https://www.iana.org/domains/root/db/tdk.html +tdk + +// team : Binky Moon, LLC +// https://www.iana.org/domains/root/db/team.html +team + +// tech : Radix Technologies Inc SEZC +// https://www.iana.org/domains/root/db/tech.html +tech + +// technology : Binky Moon, LLC +// https://www.iana.org/domains/root/db/technology.html +technology + +// temasek : Temasek Holdings (Private) Limited +// https://www.iana.org/domains/root/db/temasek.html +temasek + +// tennis : Binky Moon, LLC +// https://www.iana.org/domains/root/db/tennis.html +tennis + +// teva : Teva Pharmaceutical Industries Limited +// https://www.iana.org/domains/root/db/teva.html +teva + +// thd : Home Depot Product Authority, LLC +// https://www.iana.org/domains/root/db/thd.html +thd + +// theater : Binky Moon, LLC +// https://www.iana.org/domains/root/db/theater.html +theater + +// theatre : XYZ.COM LLC +// https://www.iana.org/domains/root/db/theatre.html +theatre + +// tiaa : Teachers Insurance and Annuity Association of America +// https://www.iana.org/domains/root/db/tiaa.html +tiaa + +// tickets : XYZ.COM LLC +// https://www.iana.org/domains/root/db/tickets.html +tickets + +// tienda : Binky Moon, LLC +// https://www.iana.org/domains/root/db/tienda.html +tienda + +// tips : Binky Moon, LLC +// https://www.iana.org/domains/root/db/tips.html +tips + +// tires : Binky Moon, LLC +// https://www.iana.org/domains/root/db/tires.html +tires + +// tirol : punkt Tirol GmbH +// https://www.iana.org/domains/root/db/tirol.html +tirol + +// tjmaxx : The TJX Companies, Inc. +// https://www.iana.org/domains/root/db/tjmaxx.html +tjmaxx + +// tjx : The TJX Companies, Inc. +// https://www.iana.org/domains/root/db/tjx.html +tjx + +// tkmaxx : The TJX Companies, Inc. +// https://www.iana.org/domains/root/db/tkmaxx.html +tkmaxx + +// tmall : Alibaba Group Holding Limited +// https://www.iana.org/domains/root/db/tmall.html +tmall + +// today : Binky Moon, LLC +// https://www.iana.org/domains/root/db/today.html +today + +// tokyo : GMO Registry, Inc. +// https://www.iana.org/domains/root/db/tokyo.html +tokyo + +// tools : Binky Moon, LLC +// https://www.iana.org/domains/root/db/tools.html +tools + +// top : Hong Kong Zhongze International Limited +// https://www.iana.org/domains/root/db/top.html +top + +// toray : Toray Industries, Inc. +// https://www.iana.org/domains/root/db/toray.html +toray + +// toshiba : TOSHIBA Corporation +// https://www.iana.org/domains/root/db/toshiba.html +toshiba + +// total : TotalEnergies SE +// https://www.iana.org/domains/root/db/total.html +total + +// tours : Binky Moon, LLC +// https://www.iana.org/domains/root/db/tours.html +tours + +// town : Binky Moon, LLC +// https://www.iana.org/domains/root/db/town.html +town + +// toyota : TOYOTA MOTOR CORPORATION +// https://www.iana.org/domains/root/db/toyota.html +toyota + +// toys : Binky Moon, LLC +// https://www.iana.org/domains/root/db/toys.html +toys + +// trade : Elite Registry Limited +// https://www.iana.org/domains/root/db/trade.html +trade + +// trading : Dog Beach, LLC +// https://www.iana.org/domains/root/db/trading.html +trading + +// training : Binky Moon, LLC +// https://www.iana.org/domains/root/db/training.html +training + +// travel : Dog Beach, LLC +// https://www.iana.org/domains/root/db/travel.html +travel + +// travelers : Travelers TLD, LLC +// https://www.iana.org/domains/root/db/travelers.html +travelers + +// travelersinsurance : Travelers TLD, LLC +// https://www.iana.org/domains/root/db/travelersinsurance.html +travelersinsurance + +// trust : Internet Naming Company LLC +// https://www.iana.org/domains/root/db/trust.html +trust + +// trv : Travelers TLD, LLC +// https://www.iana.org/domains/root/db/trv.html +trv + +// tube : Latin American Telecom LLC +// https://www.iana.org/domains/root/db/tube.html +tube + +// tui : TUI AG +// https://www.iana.org/domains/root/db/tui.html +tui + +// tunes : Amazon Registry Services, Inc. +// https://www.iana.org/domains/root/db/tunes.html +tunes + +// tushu : Amazon Registry Services, Inc. +// https://www.iana.org/domains/root/db/tushu.html +tushu + +// tvs : T V SUNDRAM IYENGAR & SONS LIMITED +// https://www.iana.org/domains/root/db/tvs.html +tvs + +// ubank : National Australia Bank Limited +// https://www.iana.org/domains/root/db/ubank.html +ubank + +// ubs : UBS AG +// https://www.iana.org/domains/root/db/ubs.html +ubs + +// unicom : China United Network Communications Corporation Limited +// https://www.iana.org/domains/root/db/unicom.html +unicom + +// university : Binky Moon, LLC +// https://www.iana.org/domains/root/db/university.html +university + +// uno : Radix Technologies Inc SEZC +// https://www.iana.org/domains/root/db/uno.html +uno + +// uol : UBN INTERNET LTDA. +// https://www.iana.org/domains/root/db/uol.html +uol + +// ups : UPS Market Driver, Inc. +// https://www.iana.org/domains/root/db/ups.html +ups + +// vacations : Binky Moon, LLC +// https://www.iana.org/domains/root/db/vacations.html +vacations + +// vana : D3 Registry LLC +// https://www.iana.org/domains/root/db/vana.html +vana + +// vanguard : The Vanguard Group, Inc. +// https://www.iana.org/domains/root/db/vanguard.html +vanguard + +// vegas : Dot Vegas, Inc. +// https://www.iana.org/domains/root/db/vegas.html +vegas + +// ventures : Binky Moon, LLC +// https://www.iana.org/domains/root/db/ventures.html +ventures + +// verisign : VeriSign, Inc. +// https://www.iana.org/domains/root/db/verisign.html +verisign + +// versicherung : tldbox GmbH +// https://www.iana.org/domains/root/db/versicherung.html +versicherung + +// vet : Dog Beach, LLC +// https://www.iana.org/domains/root/db/vet.html +vet + +// viajes : Binky Moon, LLC +// https://www.iana.org/domains/root/db/viajes.html +viajes + +// video : Dog Beach, LLC +// https://www.iana.org/domains/root/db/video.html +video + +// vig : VIENNA INSURANCE GROUP AG Wiener Versicherung Gruppe +// https://www.iana.org/domains/root/db/vig.html +vig + +// viking : Viking River Cruises (Bermuda) Ltd. +// https://www.iana.org/domains/root/db/viking.html +viking + +// villas : Binky Moon, LLC +// https://www.iana.org/domains/root/db/villas.html +villas + +// vin : Binky Moon, LLC +// https://www.iana.org/domains/root/db/vin.html +vin + +// vip : Registry Services, LLC +// https://www.iana.org/domains/root/db/vip.html +vip + +// virgin : Virgin Enterprises Limited +// https://www.iana.org/domains/root/db/virgin.html +virgin + +// visa : Visa Worldwide Pte. Limited +// https://www.iana.org/domains/root/db/visa.html +visa + +// vision : Binky Moon, LLC +// https://www.iana.org/domains/root/db/vision.html +vision + +// viva : Saudi Telecom Company +// https://www.iana.org/domains/root/db/viva.html +viva + +// vivo : Telefonica Brasil S.A. +// https://www.iana.org/domains/root/db/vivo.html +vivo + +// vlaanderen : DNS.be vzw +// https://www.iana.org/domains/root/db/vlaanderen.html +vlaanderen + +// vodka : Registry Services, LLC +// https://www.iana.org/domains/root/db/vodka.html +vodka + +// volvo : Volvo Holding Sverige Aktiebolag +// https://www.iana.org/domains/root/db/volvo.html +volvo + +// vote : Monolith Registry LLC +// https://www.iana.org/domains/root/db/vote.html +vote + +// voting : Valuetainment Corp. +// https://www.iana.org/domains/root/db/voting.html +voting + +// voto : Monolith Registry LLC +// https://www.iana.org/domains/root/db/voto.html +voto + +// voyage : Binky Moon, LLC +// https://www.iana.org/domains/root/db/voyage.html +voyage + +// wales : Nominet UK +// https://www.iana.org/domains/root/db/wales.html +wales + +// walmart : Wal-Mart Stores, Inc. +// https://www.iana.org/domains/root/db/walmart.html +walmart + +// walter : Sandvik AB +// https://www.iana.org/domains/root/db/walter.html +walter + +// wang : Zodiac Wang Limited +// https://www.iana.org/domains/root/db/wang.html +wang + +// wanggou : Amazon Registry Services, Inc. +// https://www.iana.org/domains/root/db/wanggou.html +wanggou + +// watch : Binky Moon, LLC +// https://www.iana.org/domains/root/db/watch.html +watch + +// watches : Identity Digital Domains Limited +// https://www.iana.org/domains/root/db/watches.html +watches + +// weather : The Weather Company, LLC +// https://www.iana.org/domains/root/db/weather.html +weather + +// weatherchannel : The Weather Company, LLC +// https://www.iana.org/domains/root/db/weatherchannel.html +weatherchannel + +// web : VeriSign, Inc. +// https://www.iana.org/domains/root/db/web.html +web + +// webcam : dot Webcam Limited +// https://www.iana.org/domains/root/db/webcam.html +webcam + +// weber : Saint-Gobain Weber SA +// https://www.iana.org/domains/root/db/weber.html +weber + +// website : Radix Technologies Inc SEZC +// https://www.iana.org/domains/root/db/website.html +website + +// wed +// https://www.iana.org/domains/root/db/wed.html +wed + +// wedding : Registry Services, LLC +// https://www.iana.org/domains/root/db/wedding.html +wedding + +// weibo : Sina Corporation +// https://www.iana.org/domains/root/db/weibo.html +weibo + +// weir : Weir Group IP Limited +// https://www.iana.org/domains/root/db/weir.html +weir + +// whoswho : Who's Who Registry +// https://www.iana.org/domains/root/db/whoswho.html +whoswho + +// wien : domainworx Service & Management GmbH +// https://www.iana.org/domains/root/db/wien.html +wien + +// wiki : Registry Services, LLC +// https://www.iana.org/domains/root/db/wiki.html +wiki + +// williamhill : William Hill Organization Limited +// https://www.iana.org/domains/root/db/williamhill.html +williamhill + +// win : First Registry Limited +// https://www.iana.org/domains/root/db/win.html +win + +// windows : Microsoft Corporation +// https://www.iana.org/domains/root/db/windows.html +windows + +// wine : Binky Moon, LLC +// https://www.iana.org/domains/root/db/wine.html +wine + +// winners : The TJX Companies, Inc. +// https://www.iana.org/domains/root/db/winners.html +winners + +// wme : William Morris Endeavor Entertainment, LLC +// https://www.iana.org/domains/root/db/wme.html +wme + +// woodside : Woodside Petroleum Limited +// https://www.iana.org/domains/root/db/woodside.html +woodside + +// work : Registry Services, LLC +// https://www.iana.org/domains/root/db/work.html +work + +// works : Binky Moon, LLC +// https://www.iana.org/domains/root/db/works.html +works + +// world : Binky Moon, LLC +// https://www.iana.org/domains/root/db/world.html +world + +// wow : Amazon Registry Services, Inc. +// https://www.iana.org/domains/root/db/wow.html +wow + +// wtc : World Trade Centers Association, Inc. +// https://www.iana.org/domains/root/db/wtc.html +wtc + +// wtf : Binky Moon, LLC +// https://www.iana.org/domains/root/db/wtf.html +wtf + +// xbox : Microsoft Corporation +// https://www.iana.org/domains/root/db/xbox.html +xbox + +// xerox : Xerox DNHC LLC +// https://www.iana.org/domains/root/db/xerox.html +xerox + +// xihuan : Beijing Qihu Keji Co., Ltd. +// https://www.iana.org/domains/root/db/xihuan.html +xihuan + +// xin : Elegant Leader Limited +// https://www.iana.org/domains/root/db/xin.html +xin + +// xn--11b4c3d : VeriSign Sarl +// https://www.iana.org/domains/root/db/xn--11b4c3d.html +कॉम + +// xn--1ck2e1b : Amazon Registry Services, Inc. +// https://www.iana.org/domains/root/db/xn--1ck2e1b.html +セール + +// xn--1qqw23a : Guangzhou YU Wei Information Technology Co., Ltd. +// https://www.iana.org/domains/root/db/xn--1qqw23a.html +佛山 + +// xn--30rr7y : Excellent First Limited +// https://www.iana.org/domains/root/db/xn--30rr7y.html +慈善 + +// xn--3bst00m : Eagle Horizon Limited +// https://www.iana.org/domains/root/db/xn--3bst00m.html +集团 + +// xn--3ds443g : Beijing TLD Registry Technology Limited +// https://www.iana.org/domains/root/db/xn--3ds443g.html +在线 + +// xn--3pxu8k : VeriSign Sarl +// https://www.iana.org/domains/root/db/xn--3pxu8k.html +点看 + +// xn--42c2d9a : VeriSign Sarl +// https://www.iana.org/domains/root/db/xn--42c2d9a.html +คอม + +// xn--45q11c : Zodiac Gemini Ltd +// https://www.iana.org/domains/root/db/xn--45q11c.html +八卦 + +// xn--4gbrim : Helium TLDs Ltd +// https://www.iana.org/domains/root/db/xn--4gbrim.html +موقع + +// xn--55qw42g : China Organizational Name Administration Center +// https://www.iana.org/domains/root/db/xn--55qw42g.html +公益 + +// xn--55qx5d : China Internet Network Information Center (CNNIC) +// https://www.iana.org/domains/root/db/xn--55qx5d.html +公司 + +// xn--5su34j936bgsg : Shangri‐La International Hotel Management Limited +// https://www.iana.org/domains/root/db/xn--5su34j936bgsg.html +香格里拉 + +// xn--5tzm5g : Jolly Host, LLC +// https://www.iana.org/domains/root/db/xn--5tzm5g.html +网站 + +// xn--6frz82g : Identity Digital Domains Limited +// https://www.iana.org/domains/root/db/xn--6frz82g.html +移动 + +// xn--6qq986b3xl : Tycoon Treasure Limited +// https://www.iana.org/domains/root/db/xn--6qq986b3xl.html +我爱你 + +// xn--80adxhks : Foundation for Assistance for Internet Technologies and Infrastructure Development (FAITID) +// https://www.iana.org/domains/root/db/xn--80adxhks.html +москва + +// xn--80aqecdr1a : Pontificium Consilium de Comunicationibus Socialibus (PCCS) (Pontifical Council for Social Communication) +// https://www.iana.org/domains/root/db/xn--80aqecdr1a.html +католик + +// xn--80asehdb : CORE Association +// https://www.iana.org/domains/root/db/xn--80asehdb.html +онлайн + +// xn--80aswg : CORE Association +// https://www.iana.org/domains/root/db/xn--80aswg.html +сайт + +// xn--8y0a063a : China United Network Communications Corporation Limited +// https://www.iana.org/domains/root/db/xn--8y0a063a.html +联通 + +// xn--9dbq2a : VeriSign Sarl +// https://www.iana.org/domains/root/db/xn--9dbq2a.html +קום + +// xn--9et52u : RISE VICTORY LIMITED +// https://www.iana.org/domains/root/db/xn--9et52u.html +时尚 + +// xn--9krt00a : Sina Corporation +// https://www.iana.org/domains/root/db/xn--9krt00a.html +微博 + +// xn--b4w605ferd : Temasek Holdings (Private) Limited +// https://www.iana.org/domains/root/db/xn--b4w605ferd.html +淡马锡 + +// xn--bck1b9a5dre4c : Amazon Registry Services, Inc. +// https://www.iana.org/domains/root/db/xn--bck1b9a5dre4c.html +ファッション + +// xn--c1avg : Public Interest Registry +// https://www.iana.org/domains/root/db/xn--c1avg.html +орг + +// xn--c2br7g : VeriSign Sarl +// https://www.iana.org/domains/root/db/xn--c2br7g.html +नेट + +// xn--cck2b3b : Amazon Registry Services, Inc. +// https://www.iana.org/domains/root/db/xn--cck2b3b.html +ストア + +// xn--cckwcxetd : Amazon Registry Services, Inc. +// https://www.iana.org/domains/root/db/xn--cckwcxetd.html +アマゾン + +// xn--cg4bki : SAMSUNG SDS CO., LTD +// https://www.iana.org/domains/root/db/xn--cg4bki.html +삼성 + +// xn--czr694b : Internet DotTrademark Organisation Limited +// https://www.iana.org/domains/root/db/xn--czr694b.html +商标 + +// xn--czrs0t : Binky Moon, LLC +// https://www.iana.org/domains/root/db/xn--czrs0t.html +商店 + +// xn--czru2d : Zodiac Aquarius Limited +// https://www.iana.org/domains/root/db/xn--czru2d.html +商城 + +// xn--d1acj3b : The Foundation for Network Initiatives “The Smart Internet” +// https://www.iana.org/domains/root/db/xn--d1acj3b.html +дети + +// xn--eckvdtc9d : Amazon Registry Services, Inc. +// https://www.iana.org/domains/root/db/xn--eckvdtc9d.html +ポイント + +// xn--efvy88h : Guangzhou YU Wei Information Technology Co., Ltd. +// https://www.iana.org/domains/root/db/xn--efvy88h.html +新闻 + +// xn--fct429k : Amazon Registry Services, Inc. +// https://www.iana.org/domains/root/db/xn--fct429k.html +家電 + +// xn--fhbei : VeriSign Sarl +// https://www.iana.org/domains/root/db/xn--fhbei.html +كوم + +// xn--fiq228c5hs : Beijing TLD Registry Technology Limited +// https://www.iana.org/domains/root/db/xn--fiq228c5hs.html +中文网 + +// xn--fiq64b : CITIC Group Corporation +// https://www.iana.org/domains/root/db/xn--fiq64b.html +中信 + +// xn--fjq720a : Binky Moon, LLC +// https://www.iana.org/domains/root/db/xn--fjq720a.html +娱乐 + +// xn--flw351e : Charleston Road Registry Inc. +// https://www.iana.org/domains/root/db/xn--flw351e.html +谷歌 + +// xn--fzys8d69uvgm : PCCW Enterprises Limited +// https://www.iana.org/domains/root/db/xn--fzys8d69uvgm.html +電訊盈科 + +// xn--g2xx48c : Nawang Heli(Xiamen) Network Service Co., LTD. +// https://www.iana.org/domains/root/db/xn--g2xx48c.html +购物 + +// xn--gckr3f0f : Amazon Registry Services, Inc. +// https://www.iana.org/domains/root/db/xn--gckr3f0f.html +クラウド + +// xn--gk3at1e : Amazon Registry Services, Inc. +// https://www.iana.org/domains/root/db/xn--gk3at1e.html +通販 + +// xn--hxt814e : Zodiac Taurus Limited +// https://www.iana.org/domains/root/db/xn--hxt814e.html +网店 + +// xn--i1b6b1a6a2e : Public Interest Registry +// https://www.iana.org/domains/root/db/xn--i1b6b1a6a2e.html +संगठन + +// xn--imr513n : Internet DotTrademark Organisation Limited +// https://www.iana.org/domains/root/db/xn--imr513n.html +餐厅 + +// xn--io0a7i : China Internet Network Information Center (CNNIC) +// https://www.iana.org/domains/root/db/xn--io0a7i.html +网络 + +// xn--j1aef : VeriSign Sarl +// https://www.iana.org/domains/root/db/xn--j1aef.html +ком + +// xn--jlq480n2rg : Amazon Registry Services, Inc. +// https://www.iana.org/domains/root/db/xn--jlq480n2rg.html +亚马逊 + +// xn--jvr189m : Amazon Registry Services, Inc. +// https://www.iana.org/domains/root/db/xn--jvr189m.html +食品 + +// xn--kcrx77d1x4a : Koninklijke Philips N.V. +// https://www.iana.org/domains/root/db/xn--kcrx77d1x4a.html +飞利浦 + +// xn--kput3i : Beijing RITT-Net Technology Development Co., Ltd +// https://www.iana.org/domains/root/db/xn--kput3i.html +手机 + +// xn--mgba3a3ejt : Aramco Services Company +// https://www.iana.org/domains/root/db/xn--mgba3a3ejt.html +ارامكو + +// xn--mgba7c0bbn0a : Competrol (Luxembourg) Sarl +// https://www.iana.org/domains/root/db/xn--mgba7c0bbn0a.html +العليان + +// xn--mgbab2bd : CORE Association +// https://www.iana.org/domains/root/db/xn--mgbab2bd.html +بازار + +// xn--mgbca7dzdo : Abu Dhabi Systems and Information Centre +// https://www.iana.org/domains/root/db/xn--mgbca7dzdo.html +ابوظبي + +// xn--mgbi4ecexp : Pontificium Consilium de Comunicationibus Socialibus (PCCS) (Pontifical Council for Social Communication) +// https://www.iana.org/domains/root/db/xn--mgbi4ecexp.html +كاثوليك + +// xn--mgbt3dhd +// https://www.iana.org/domains/root/db/xn--mgbt3dhd.html +همراه + +// xn--mk1bu44c : VeriSign Sarl +// https://www.iana.org/domains/root/db/xn--mk1bu44c.html +닷컴 + +// xn--mxtq1m : Net-Chinese Co., Ltd. +// https://www.iana.org/domains/root/db/xn--mxtq1m.html +政府 + +// xn--ngbc5azd : International Domain Registry Pty. Ltd. +// https://www.iana.org/domains/root/db/xn--ngbc5azd.html +شبكة + +// xn--ngbe9e0a : Kuwait Finance House +// https://www.iana.org/domains/root/db/xn--ngbe9e0a.html +بيتك + +// xn--ngbrx : League of Arab States +// https://www.iana.org/domains/root/db/xn--ngbrx.html +عرب + +// xn--nqv7f : Public Interest Registry +// https://www.iana.org/domains/root/db/xn--nqv7f.html +机构 + +// xn--nqv7fs00ema : Public Interest Registry +// https://www.iana.org/domains/root/db/xn--nqv7fs00ema.html +组织机构 + +// xn--nyqy26a : Stable Tone Limited +// https://www.iana.org/domains/root/db/xn--nyqy26a.html +健康 + +// xn--otu796d : Jiang Yu Liang Cai Technology Company Limited +// https://www.iana.org/domains/root/db/xn--otu796d.html +招聘 + +// xn--p1acf : Rusnames Limited +// https://www.iana.org/domains/root/db/xn--p1acf.html +рус + +// xn--pssy2u : VeriSign Sarl +// https://www.iana.org/domains/root/db/xn--pssy2u.html +大拿 + +// xn--q9jyb4c : Charleston Road Registry Inc. +// https://www.iana.org/domains/root/db/xn--q9jyb4c.html +みんな + +// xn--qcka1pmc : Charleston Road Registry Inc. +// https://www.iana.org/domains/root/db/xn--qcka1pmc.html +グーグル + +// xn--rhqv96g : Stable Tone Limited +// https://www.iana.org/domains/root/db/xn--rhqv96g.html +世界 + +// xn--rovu88b : Amazon Registry Services, Inc. +// https://www.iana.org/domains/root/db/xn--rovu88b.html +書籍 + +// xn--ses554g : KNET Co., Ltd. +// https://www.iana.org/domains/root/db/xn--ses554g.html +网址 + +// xn--t60b56a : VeriSign Sarl +// https://www.iana.org/domains/root/db/xn--t60b56a.html +닷넷 + +// xn--tckwe : VeriSign Sarl +// https://www.iana.org/domains/root/db/xn--tckwe.html +コム + +// xn--tiq49xqyj : Pontificium Consilium de Comunicationibus Socialibus (PCCS) (Pontifical Council for Social Communication) +// https://www.iana.org/domains/root/db/xn--tiq49xqyj.html +天主教 + +// xn--unup4y : Binky Moon, LLC +// https://www.iana.org/domains/root/db/xn--unup4y.html +游戏 + +// xn--vermgensberater-ctb : Deutsche Vermögensberatung Aktiengesellschaft DVAG +// https://www.iana.org/domains/root/db/xn--vermgensberater-ctb.html +vermögensberater + +// xn--vermgensberatung-pwb : Deutsche Vermögensberatung Aktiengesellschaft DVAG +// https://www.iana.org/domains/root/db/xn--vermgensberatung-pwb.html +vermögensberatung + +// xn--vhquv : Binky Moon, LLC +// https://www.iana.org/domains/root/db/xn--vhquv.html +企业 + +// xn--vuq861b : Beijing Tele-info Technology Co., Ltd. +// https://www.iana.org/domains/root/db/xn--vuq861b.html +信息 + +// xn--w4r85el8fhu5dnra : Kerry Trading Co. Limited +// https://www.iana.org/domains/root/db/xn--w4r85el8fhu5dnra.html +嘉里大酒店 + +// xn--w4rs40l : Kerry Trading Co. Limited +// https://www.iana.org/domains/root/db/xn--w4rs40l.html +嘉里 + +// xn--xhq521b : Guangzhou YU Wei Information Technology Co., Ltd. +// https://www.iana.org/domains/root/db/xn--xhq521b.html +广东 + +// xn--zfr164b : China Organizational Name Administration Center +// https://www.iana.org/domains/root/db/xn--zfr164b.html +政务 + +// xyz : XYZ.COM LLC +// https://www.iana.org/domains/root/db/xyz.html +xyz + +// yachts : XYZ.COM LLC +// https://www.iana.org/domains/root/db/yachts.html +yachts + +// yahoo : Yahoo Inc. +// https://www.iana.org/domains/root/db/yahoo.html +yahoo + +// yamaxun : Amazon Registry Services, Inc. +// https://www.iana.org/domains/root/db/yamaxun.html +yamaxun + +// yandex : YANDEX, LLC +// https://www.iana.org/domains/root/db/yandex.html +yandex + +// yodobashi : YODOBASHI CAMERA CO.,LTD. +// https://www.iana.org/domains/root/db/yodobashi.html +yodobashi + +// yoga : Registry Services, LLC +// https://www.iana.org/domains/root/db/yoga.html +yoga + +// yokohama : GMO Registry, Inc. +// https://www.iana.org/domains/root/db/yokohama.html +yokohama + +// you : Amazon Registry Services, Inc. +// https://www.iana.org/domains/root/db/you.html +you + +// youtube : Charleston Road Registry Inc. +// https://www.iana.org/domains/root/db/youtube.html +youtube + +// yun : Beijing Qihu Keji Co., Ltd. +// https://www.iana.org/domains/root/db/yun.html +yun + +// zappos : Amazon Registry Services, Inc. +// https://www.iana.org/domains/root/db/zappos.html +zappos + +// zara : Industria de Diseño Textil, S.A. (INDITEX, S.A.) +// https://www.iana.org/domains/root/db/zara.html +zara + +// zero : Amazon Registry Services, Inc. +// https://www.iana.org/domains/root/db/zero.html +zero + +// zip : Charleston Road Registry Inc. +// https://www.iana.org/domains/root/db/zip.html +zip + +// zone : Binky Moon, LLC +// https://www.iana.org/domains/root/db/zone.html +zone + +// zuerich : Kanton Zürich (Canton of Zurich) +// https://www.iana.org/domains/root/db/zuerich.html +zuerich + +// ===END ICANN DOMAINS=== diff --git a/DiscordBot/DiscordBot.csproj b/DiscordBot/DiscordBot.csproj index c5ad05c2..83af7711 100644 --- a/DiscordBot/DiscordBot.csproj +++ b/DiscordBot/DiscordBot.csproj @@ -23,6 +23,7 @@ + diff --git a/DiscordBot/Modules/Recruitment/RecruitmentModule.cs b/DiscordBot/Modules/Recruitment/RecruitmentModule.cs index 3c663e1d..0891abad 100644 --- a/DiscordBot/Modules/Recruitment/RecruitmentModule.cs +++ b/DiscordBot/Modules/Recruitment/RecruitmentModule.cs @@ -47,6 +47,15 @@ public Task Manage( [SlashCommand("waive-cooldown", "Waive an author's creation cooldown without granting a second slot")] public Task Waive(IUser author, string reason) => Staff("waive-cooldown", author.Id.ToString(), reason); + [SlashCommand("portfolio-sites", "List or change recognized portfolio sites (administrator only)")] + [RequireUserPermission(GuildPermission.Administrator)] + public async Task PortfolioSites([Choice("List", "list"), Choice("Add", "add"), Choice("Remove", "remove")] string operation = "list", + string domain = "", string reason = "") + { + await DeferAsync(ephemeral: true); + await Reply(() => Recruitment.RunAsync((engine, ct) => engine.PortfolioSitesAsync(Guild, Actor, operation, domain, reason, ct))); + } + private async Task Staff(string operation, string target = "", string reason = "") { await DeferAsync(ephemeral: true); diff --git a/DiscordBot/Services/Recruitment/DiscordRecruitmentAdapter.cs b/DiscordBot/Services/Recruitment/DiscordRecruitmentAdapter.cs index 6cf2a087..bf70e921 100644 --- a/DiscordBot/Services/Recruitment/DiscordRecruitmentAdapter.cs +++ b/DiscordBot/Services/Recruitment/DiscordRecruitmentAdapter.cs @@ -87,6 +87,13 @@ public async Task IsMemberAsync(string guildId, string userId, Cancellatio user.GuildPermissions.Administrator || user.RoleIds.Contains(_settings.Roles.Moderator)); } + public async Task IsAdministratorAsync(string guildId, string userId, CancellationToken ct) + { + if (guildId != _settings.GuildId.ToString() || !ulong.TryParse(userId, out var id)) return false; + var user = await MemberAsync(id, ct); + return user?.GuildPermissions.Administrator == true; + } + public async Task IsModeratorAsync(string guildId, string userId, CancellationToken ct) { if (guildId != _settings.GuildId.ToString() || !ulong.TryParse(userId, out var id)) return false; diff --git a/DiscordBot/Services/Recruitment/IRecruitmentDiscord.cs b/DiscordBot/Services/Recruitment/IRecruitmentDiscord.cs index b9535623..1806df74 100644 --- a/DiscordBot/Services/Recruitment/IRecruitmentDiscord.cs +++ b/DiscordBot/Services/Recruitment/IRecruitmentDiscord.cs @@ -24,6 +24,7 @@ public interface IRecruitmentDiscord Task ValidateFeedAsync(CancellationToken ct); Task IsMemberAsync(string guildId, string userId, CancellationToken ct); Task GetAuthorAsync(string userId, CancellationToken ct); + Task IsAdministratorAsync(string guildId, string userId, CancellationToken ct); Task IsModeratorAsync(string guildId, string userId, CancellationToken ct); bool IsExpectedForumUpdate(ForumSnapshot snapshot); Task GetForumAsync(string forumId, CancellationToken ct); diff --git a/DiscordBot/Services/Recruitment/RecruitmentContent.cs b/DiscordBot/Services/Recruitment/RecruitmentContent.cs index 188eb6d7..cbc1ab5d 100644 --- a/DiscordBot/Services/Recruitment/RecruitmentContent.cs +++ b/DiscordBot/Services/Recruitment/RecruitmentContent.cs @@ -5,7 +5,7 @@ namespace DiscordBot.Services.Recruitment; public sealed record ContentObservation(string Code, string Description, string Evidence); public enum CompensationKind { Unavailable, Missing, MonetaryPromise, Rate, Mixed, Unpaid, RevenueShare } public sealed record PaymentObservation(string Summary, string Evidence, CompensationKind Kind = CompensationKind.Missing); -public sealed record PortfolioCandidate(string Url, string Host, bool Specific, bool Supported, bool WorkContext, bool Negated); +public sealed record PortfolioCandidate(string Url, string Host, bool Specific, bool Supported, bool WorkContext, bool Negated, string? ReuseUrl = null); public sealed record PortfolioEvidence(bool Available, bool MediaSample, IReadOnlyList Candidates); public sealed record StarterAttachment(string Id, string Name, string? ContentType); @@ -58,14 +58,12 @@ public static PortfolioEvidence ReadPortfolio(string? content, IReadOnlyList 0 && !contact, - (supportedHosts ?? SeedPortfolioHosts).Any(d => Domain(uri.IdnHost, d)), WorkLabel().IsMatch(line), NoWork().IsMatch(line))); + candidates.Add(new(identity.DisplayUrl, identity.Host, identity.Specific, + (supportedHosts ?? SeedPortfolioHosts).Any(d => Domain(identity.Host, d)), WorkLabel().IsMatch(line), NoWork().IsMatch(line), identity.ReuseUrl)); if (candidates.Count == 8) break; } return new(true, media, candidates); @@ -142,18 +140,9 @@ public static string CompensationTerms(string content) catch (RegexMatchTimeoutException) { return ""; } } - public static List ContactLinks(string? content) - { - if (content is null) return new(); - try - { - return Links().Matches(Bounded(content)).Select(m => TryLink(m.Value, out var uri) ? uri : null) - .Where(u => u is not null && u.AbsolutePath.Length > 1) - .Select(u => u!.GetLeftPart(UriPartial.Path).TrimEnd('/')) - .Where(u => u.Length <= 300).Distinct(StringComparer.OrdinalIgnoreCase).Take(8).ToList(); - } - catch (RegexMatchTimeoutException) { return new(); } - } + public static List ContactLinks(string? content) => ReadPortfolio(content).Candidates + .Where(c => c.Specific && !c.Negated && c.ReuseUrl is not null).Select(c => c.ReuseUrl!) + .Distinct(StringComparer.Ordinal).Take(8).ToList(); private static bool TryLink(string value, out Uri? uri) => Uri.TryCreate(value.TrimEnd('.', ',', ';', '!', '?'), UriKind.Absolute, out uri) && uri.Scheme is "https" or "http" && uri.HostNameType == UriHostNameType.Dns; private static bool Domain(string host, string domain) => host.Equals(domain, StringComparison.OrdinalIgnoreCase) || host.EndsWith("." + domain, StringComparison.OrdinalIgnoreCase); diff --git a/DiscordBot/Services/Recruitment/RecruitmentEngine.cs b/DiscordBot/Services/Recruitment/RecruitmentEngine.cs index 86ceceb7..283431b5 100644 --- a/DiscordBot/Services/Recruitment/RecruitmentEngine.cs +++ b/DiscordBot/Services/Recruitment/RecruitmentEngine.cs @@ -357,7 +357,8 @@ private async Task PublishAsync(Listing post, CancellationToken ct) if (post.Status is ListingStatus.Deleted or ListingStatus.Reference) { post.PublicationPending = false; post.PublicSendPending = false; Save(); return; } var message = RecruitmentPresentation.Public(State!, post, _settings, Now); - if (!post.PublicSendPending && !post.PublicationPending && post.PublicMessageId is not null && _publicRenders.GetValueOrDefault(post.Id) == message.RenderKey) return; + var renderKey = message.RenderKey + "\nPortfolio registry " + State!.PortfolioSites.Version; + if (!post.PublicSendPending && !post.PublicationPending && post.PublicMessageId is not null && _publicRenders.GetValueOrDefault(post.Id) == renderKey) return; var recovering = post.PublicSendPending; var delivery = await _discord.UpsertAsync(post.Id, post.PublicMessageId, recovering, post.PublicSendStartedAt ?? post.CreatedAt, message with @@ -368,7 +369,7 @@ message with }, ct); var changed = post.PublicMessageId != delivery.Id || post.PublicSendPending || post.PublicationPending; post.PublicMessageId = delivery.Id; post.PublicSendPending = false; post.PublicationPending = false; - _publicRenders[post.Id] = message.RenderKey; + _publicRenders[post.Id] = renderKey; if (delivery.NewlySent && post.Deadline.HasValue && !post.AcceptedAt.HasValue) { post.PromptDeliveredAt = null; post.Deadline = null; post.Version++; changed = true; } if (changed) Save(); diff --git a/DiscordBot/Services/Recruitment/RecruitmentEngineScheduling.cs b/DiscordBot/Services/Recruitment/RecruitmentEngineScheduling.cs index 3a6f95ad..751597cd 100644 --- a/DiscordBot/Services/Recruitment/RecruitmentEngineScheduling.cs +++ b/DiscordBot/Services/Recruitment/RecruitmentEngineScheduling.cs @@ -53,6 +53,7 @@ private DateTimeOffset? NextWorkAt var attention = post.Attention.Where(n => n.MessageId is null || n.SendPending) .Select(n => n.Fault is null ? (DateTimeOffset?)Now : n.Fault.RetryAt).Where(t => t.HasValue).Min(); if (post.Status != ListingStatus.Open) return attention; + if (post.PortfolioRegistryVersion != State!.PortfolioSites.Version) return Now; if (_recoverWindows.Contains(post.Id)) return Now; if (!post.PromptDeliveredAt.HasValue && !post.AcceptedAt.HasValue) return Now; var dates = new List(); diff --git a/DiscordBot/Services/Recruitment/RecruitmentEvidence.cs b/DiscordBot/Services/Recruitment/RecruitmentEvidence.cs index 3b39bc4c..b58b5bf4 100644 --- a/DiscordBot/Services/Recruitment/RecruitmentEvidence.cs +++ b/DiscordBot/Services/Recruitment/RecruitmentEvidence.cs @@ -9,8 +9,9 @@ private void RefreshFacts(Listing post, ThreadSnapshot live) { var payment = RecruitmentContent.ReadPayment(live.Starter); var purpose = State!.Forums.First(f => f.Value.ForumId == post.ForumId).Key; - post.Facts.Checks = RecruitmentSignals.Evaluate(live, purpose); - post.Facts.PortfolioCandidates = RecruitmentContent.ReadPortfolio(live.Starter, live.Attachments); + post.Facts.Checks = RecruitmentSignals.Evaluate(live, purpose, State.PortfolioSites.EffectiveHosts()); + post.PortfolioRegistryVersion = State.PortfolioSites.Version; + post.Facts.PortfolioCandidates = RecruitmentContent.ReadPortfolio(live.Starter, live.Attachments, State.PortfolioSites.EffectiveHosts()); post.Facts.ServerNickname = RecruitmentSignals.SafeIdentity(live.ServerNickname); post.Facts.GlobalName = RecruitmentSignals.SafeIdentity(live.GlobalName); post.Facts.Username = RecruitmentSignals.SafeIdentity(live.Username); @@ -23,7 +24,7 @@ private void RefreshFacts(Listing post, ThreadSnapshot live) post.Facts.Observations = RecruitmentContent.Observations(live.Starter); if (live.Starter is null) return; // Missing data must not overwrite previously observed evidence. var attachments = live.Attachments?.Take(10).ToList() ?? new(); - var normalized = string.Join(' ', live.Starter.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries)).ToLowerInvariant(); + var normalized = string.Join(' ', live.Starter.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries)); var fingerprint = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(normalized + "\n" + string.Join('|', attachments.Select(a => a.Id))))); if (post.LatestStarter?.Fingerprint == fingerprint) return; var version = new StarterVersion(Now, live.Starter[..Math.Min(2000, live.Starter.Length)], live.Starter.Length > 2000, @@ -58,8 +59,11 @@ private string EvidenceDetails(Listing post) } if (post.LatestStarter is { } latest) { - var related = State!.Listings.Values.Where(p => p.Id != post.Id && p.OwnerId != post.OwnerId && p.LatestStarter is { } other && - (latest.Content.Length >= 120 && latest.Fingerprint == other.Fingerprint || latest.Links.Intersect(other.Links, StringComparer.OrdinalIgnoreCase).Any())) + static IEnumerable Retained(Listing p) => new[] { p.OriginalStarter }.Concat(p.EarlierMaterialVersions) + .Append(p.LatestStarter).OfType(); + var links = Retained(post).SelectMany(v => v.Links).ToHashSet(StringComparer.Ordinal); + var related = State!.Listings.Values.Where(p => p.Id != post.Id && p.OwnerId != post.OwnerId && + Retained(p).Any(other => latest.Content.Length >= 120 && latest.Fingerprint == other.Fingerprint || links.Overlaps(other.Links))) .OrderByDescending(p => p.CreatedAt).Take(5).Select(p => $"<#{p.Id}> (author `{p.OwnerId}`)").ToArray(); if (related.Length > 0) text += "\nShared starter text or link with: " + string.Join(", ", related) + ". Review context; shared projects can be legitimate.\n"; } diff --git a/DiscordBot/Services/Recruitment/RecruitmentPortfolioRegistry.cs b/DiscordBot/Services/Recruitment/RecruitmentPortfolioRegistry.cs new file mode 100644 index 00000000..23ce4d53 --- /dev/null +++ b/DiscordBot/Services/Recruitment/RecruitmentPortfolioRegistry.cs @@ -0,0 +1,98 @@ +using System.Globalization; +using System.IO; + +namespace DiscordBot.Services.Recruitment; + +/// The effective set is persisted, including an intentionally empty set. +public sealed class PortfolioRegistry +{ + public long Version { get; set; } = 1; + [Newtonsoft.Json.JsonProperty(ObjectCreationHandling = Newtonsoft.Json.ObjectCreationHandling.Replace)] + public List Hosts { get; set; } = RecruitmentContent.SeedPortfolioHosts.Order(StringComparer.Ordinal).ToList(); + public List Audit { get; set; } = new(); + public IReadOnlySet EffectiveHosts() => new HashSet(Hosts, StringComparer.OrdinalIgnoreCase); +} + +public static class RecruitmentPortfolioDomains +{ + private static readonly IdnMapping Idn = new(); + private static readonly Lazy> Rules = new(() => + { + using var stream = typeof(RecruitmentPortfolioDomains).Assembly.GetManifestResourceStream("Recruitment.PublicSuffixIcann") + ?? throw new InvalidDataException("Bundled ICANN public suffix rules are missing."); + using var reader = new StreamReader(stream); + var rules = new HashSet(StringComparer.Ordinal); + while (reader.ReadLine() is { } line) + { + line = line.Trim(); + if (line.Length == 0 || line.StartsWith("//", StringComparison.Ordinal)) continue; + var prefix = line.StartsWith('!') ? "!" : line.StartsWith("*.", StringComparison.Ordinal) ? "*." : ""; + rules.Add(prefix + Idn.GetAscii(line[prefix.Length..]).ToLowerInvariant()); + } + return rules; + }); + + private static readonly Lazy> Tlds = new(() => Rules.Value + .Select(r => r.Split('.').Last().TrimStart('!')).ToHashSet(StringComparer.Ordinal)); + + public static bool TryNormalize(string? input, out string host) + { + host = ""; + if (string.IsNullOrWhiteSpace(input)) return false; + var value = input.Trim(); + // Only hostnames, never an authority, URL, wildcard, port or trailing root dot. + if (value.IndexOfAny(['/', '\\', ':', '@', '*', '?', '#', '%']) >= 0 || value.EndsWith('.')) return false; + try { value = Idn.GetAscii(value).ToLowerInvariant(); } + catch (ArgumentException) { return false; } + if (value.Length > 253 || Uri.CheckHostName(value) != UriHostNameType.Dns || !value.Contains('.')) return false; + var labels = value.Split('.'); + if (labels.Any(l => l.Length is < 1 or > 63 || l[0] == '-' || l[^1] == '-' || l.Any(c => !char.IsAsciiLetterOrDigit(c) && c != '-'))) return false; + var rules = Rules.Value; + // An unlisted TLD is not an Internet registry boundary (e.g. a local search suffix). + if (!Tlds.Value.Contains(labels[^1])) return false; + var suffixLabels = 1; + for (var i = 0; i < labels.Length; i++) + { + var suffix = string.Join('.', labels[i..]); + if (rules.Contains("!" + suffix)) { suffixLabels = labels.Length - i - 1; break; } + if (rules.Contains(suffix)) suffixLabels = Math.Max(suffixLabels, labels.Length - i); + if (i + 1 < labels.Length && rules.Contains("*." + string.Join('.', labels[(i + 1)..]))) + suffixLabels = Math.Max(suffixLabels, labels.Length - i); + } + if (labels.Length <= suffixLabels) return false; + host = value; + return true; + } +} + +public sealed partial class RecruitmentEngine +{ + public async Task PortfolioSitesAsync(string guild, string actor, string operation, string domain, string reason, CancellationToken ct) + { + if (guild != _bot.GuildId.ToString() || !await _discord.IsAdministratorAsync(guild, actor, ct)) + return "Administrator access is required in the configured server."; + if (State is null || !_settings.Enabled) return "Recruitment is unavailable."; + var registry = State.PortfolioSites; + if (operation == "list") + return "Recognized portfolio sites (recognition is not endorsement):\n" + + (registry.Hosts.Count == 0 ? "None configured." : string.Join("\n", registry.Hosts.Order(StringComparer.Ordinal))) + + $"\nRegistry version {registry.Version}. Subdomains match at a dot boundary."; + if (operation is not ("add" or "remove")) return "Choose list, add or remove."; + reason = RecruitmentPresentation.Safe(reason, 300).Trim(); + if (string.IsNullOrWhiteSpace(reason)) return "A reason is required for this change."; + if (!RecruitmentPortfolioDomains.TryNormalize(domain, out var host)) + return "Enter a DNS hostname below a public suffix, without a URL, path, port, credentials or wildcard."; + var exists = registry.Hosts.Contains(host, StringComparer.Ordinal); + if (exists == (operation == "add")) return exists ? "That site is already configured." : "That site is not configured."; + if (operation == "add" && registry.Hosts.Count >= 100) return "The portfolio-site limit is 100 domains. Remove a site first."; + if (registry.Version == long.MaxValue) return "Registry version limit reached; operator review required."; + if (operation == "add") registry.Hosts.Add(host); else registry.Hosts.Remove(host); + registry.Version++; + registry.Audit.Add(new RecruitmentAudit(Now, actor, $"{operation} {host}: {reason}")); + if (registry.Audit.Count > 100) registry.Audit.RemoveRange(0, registry.Audit.Count - 100); + // Version mismatch makes open listings due, using the existing bounded scheduling/retry path. + // This policy update does not change starter versions or manufacture member edits. + Save(); + return $"Portfolio site {host} {(operation == "add" ? "added" : "removed")}. Retained open listings will refresh in bounded batches; recognition is not endorsement."; + } +} diff --git a/DiscordBot/Services/Recruitment/RecruitmentPortfolioUrls.cs b/DiscordBot/Services/Recruitment/RecruitmentPortfolioUrls.cs new file mode 100644 index 00000000..11082d7a --- /dev/null +++ b/DiscordBot/Services/Recruitment/RecruitmentPortfolioUrls.cs @@ -0,0 +1,77 @@ +using System.Text.RegularExpressions; + +namespace DiscordBot.Services.Recruitment; + +/// Offline URL observations. Unknown query identity is scrubbed and never reused for matching. +public static class RecruitmentPortfolioUrls +{ + public sealed record Identity(string DisplayUrl, string Host, bool Specific, string? ReuseUrl); + public static bool Domain(string host, string domain) => host.Equals(domain, StringComparison.OrdinalIgnoreCase) || + host.EndsWith("." + domain, StringComparison.OrdinalIgnoreCase); + + public static Identity? Read(Uri uri) + { + var host = uri.IdnHost.ToLowerInvariant(); + if (uri.UserInfo.Length != 0 || Uri.CheckHostName(host) != UriHostNameType.Dns || !host.Contains('.')) return null; + var path = uri.AbsolutePath; + var route = path.Trim('/'); + var parts = route.Split('/', StringSplitOptions.RemoveEmptyEntries); + if (Domain(host, "discord.com") || Domain(host, "discord.gg") || Domain(host, "discordapp.com") || + Domain(host, "t.me") || Domain(host, "telegram.me") || Domain(host, "wa.me") || + parts.Any(p => p.ToLowerInvariant() is "contact" or "login" or "signin" or "invite" or "join")) return null; + var specific = parts.Length > 0; + // Selected providers have creator-owned subdomains; their root is already a profile/project. + if (new[] { "itch.io", "artstation.com", "deviantart.com" }.Any(d => Domain(host, d) && host != d && host != "www." + d)) + specific = true; + // Shared platform entry points, discovery and authentication pages are not work samples. + if (parts.Length > 0 && parts[0].ToLowerInvariant() is "explore" or "discover" or "search" or "marketplace" or "pricing" or "about" or "features" or "topics" or "trending" or "settings" or "notifications" or "issues" or "pulls" or "dashboard" or "help" or "support" or "feed" or "subscriptions") + specific = false; + var builder = new UriBuilder(uri) { Host = host, Fragment = "", Query = "", UserName = "", Password = "" }; + if (builder.Uri.AbsoluteUri.Length > 300) return null; + var query = ParseQuery(uri.Query); + if (query is null) return new(builder.Uri.AbsoluteUri, host, false, null); + var identityQuery = query.Where(q => !Tracking(q.Key)).ToArray(); + string? reuse = null; + if (Domain(host, "youtube.com") || host is "youtu.be" or "www.youtu.be") + { + var videos = identityQuery.Where(q => q.Key == "v").ToArray(); + var video = host.EndsWith("youtu.be", StringComparison.Ordinal) && parts.Length == 1 ? parts[0] : + route == "watch" && videos.Length == 1 ? videos[0].Value : + parts.Length == 2 && parts[0] is "shorts" or "embed" ? parts[1] : null; + if (video is not null && Regex.IsMatch(video, "^[A-Za-z0-9_-]{11}$", RegexOptions.None, TimeSpan.FromMilliseconds(100))) + { + builder = new UriBuilder("https", "youtube.com", uri.IsDefaultPort ? -1 : uri.Port) { Path = "/watch", Query = "v=" + video }; + specific = true; + reuse = builder.Uri.AbsoluteUri; + } + else + { + // /watch with no usable ID, feed pages, and bare domains cannot identify work. + specific = parts.Length > 0 && (parts[0].StartsWith('@') || parts.Length >= 2 && parts[0] is "channel" or "c" or "user"); + if (specific && identityQuery.Length == 0) reuse = builder.Uri.AbsoluteUri; + } + } + else if (specific && identityQuery.Length == 0) reuse = builder.Uri.AbsoluteUri; + if (builder.Uri.AbsoluteUri.Length > 300) return null; + return new(builder.Uri.AbsoluteUri, host, specific, reuse); + } + + private static bool Tracking(string key) => key.StartsWith("utm_", StringComparison.OrdinalIgnoreCase) || + key.ToLowerInvariant() is "fbclid" or "gclid" or "dclid" or "msclkid" or "mc_cid" or "mc_eid"; + + private static List>? ParseQuery(string query) + { + var result = new List>(); + if (query.Length > 2000) return null; + try + { + foreach (var pair in query.TrimStart('?').Split('&', StringSplitOptions.RemoveEmptyEntries)) + { + var fields = pair.Split('=', 2); + result.Add(new(Uri.UnescapeDataString(fields[0]), fields.Length == 2 ? Uri.UnescapeDataString(fields[1]) : "")); + } + } + catch (UriFormatException) { return null; } + return result; + } +} diff --git a/DiscordBot/Services/Recruitment/RecruitmentState.cs b/DiscordBot/Services/Recruitment/RecruitmentState.cs index 5d78086c..cbd9d129 100644 --- a/DiscordBot/Services/Recruitment/RecruitmentState.cs +++ b/DiscordBot/Services/Recruitment/RecruitmentState.cs @@ -21,6 +21,7 @@ public sealed class RecruitmentState public DateTimeOffset InitializedAt { get; set; } [JsonProperty(Required = Required.AllowNull)] public DateTimeOffset DiscoveryFloor { get; set; } + public PortfolioRegistry PortfolioSites { get; set; } = new(); public OverviewReceipt Overview { get; set; } = new(); public RecruitmentFault? RecoveryFault { get; set; } public List SetupAudit { get; set; } = new(); @@ -89,6 +90,7 @@ public sealed class Listing [JsonProperty(Required = Required.AllowNull)] public ListingStatus Status { get; set; } public long Version { get; set; } + public long PortfolioRegistryVersion { get; set; } [JsonProperty(Required = Required.AllowNull)] public DateTimeOffset? AcceptedAt { get; set; } public DateTimeOffset? AcknowledgedAt { get; set; } diff --git a/DiscordBot/Services/Recruitment/RecruitmentStateStore.cs b/DiscordBot/Services/Recruitment/RecruitmentStateStore.cs index 1f582026..74577ec1 100644 --- a/DiscordBot/Services/Recruitment/RecruitmentStateStore.cs +++ b/DiscordBot/Services/Recruitment/RecruitmentStateStore.cs @@ -104,6 +104,13 @@ state.Forums is null || state.Listings is null || state.Authors is null || state state.Forums.Count != 4 || forums.Count != 4 || forums.Values.Distinct().Count() != 4 || forums.Any(f => !Id(f.Value) || !state.Forums.TryGetValue(f.Key, out var saved) || saved.ForumId != f.Value)) throw new InvalidDataException("Recruitment state schema, identity or forum mapping is invalid."); + var registry = state.PortfolioSites; + if (registry is null || registry.Version < 1 || registry.Hosts is null || registry.Hosts.Count > 100 || + registry.Hosts.Distinct(StringComparer.Ordinal).Count() != registry.Hosts.Count || + registry.Hosts.Any(h => !RecruitmentPortfolioDomains.TryNormalize(h, out var normalized) || h != normalized) || + registry.Audit is null || registry.Audit.Count > 100 || registry.Audit.Any(a => a is null || a.At == default || + !Id(a.Actor) || string.IsNullOrWhiteSpace(a.Reason) || a.Reason.Length > 570)) + throw new InvalidDataException("Recruitment portfolio-site registry is invalid."); var overview = state.Overview; if (overview.Sequence < 0 || overview.MessageId is not null && (!Id(overview.MessageId) || overview.Marker is null || !overview.Marker.StartsWith($"recruitment:overview:{state.InstanceId}:")) || @@ -118,6 +125,7 @@ state.Forums is null || state.Listings is null || state.Authors is null || state throw new InvalidDataException("Recruitment forum receipt is invalid."); foreach (var (id, p) in state.Listings) if (p is null || id != p.Id || !Id(id) || !Id(p.OwnerId) || !forums.Values.Contains(p.ForumId) || + p.PortfolioRegistryVersion < 0 || p.PortfolioRegistryVersion > registry.Version || p.CreatedAt == default || p.CreatedAt.Offset != TimeSpan.Zero || p.UpdatedAt == default || !Enum.IsDefined(p.Status) || p.ValidCodes is null || p.Facts is null || p.Audit is null || !state.Authors.ContainsKey(p.OwnerId) || p.Facts.Payment is null || p.Facts.Portfolio is null || @@ -147,7 +155,7 @@ private static bool FactsValid(InitialFacts f) => f.Checks.All(c => c is not null && c.Id is { Length: <= 30 } && c.Label is { Length: <= 30 } && c.Value is { Length: <= 100 } && c.Evidence is null or { Length: <= 370 } && Enum.IsDefined(c.Severity) && Enum.IsDefined(c.Applicability)) && f.PortfolioCandidates is { Candidates.Count: <= 8 } && f.PortfolioCandidates.Candidates.All(c => - c is not null && c.Url is { Length: <= 300 } && c.Host is { Length: <= 253 }); + c is not null && c.Url is { Length: <= 300 } && c.Host is { Length: <= 253 } && c.ReuseUrl is null or { Length: <= 300 }); private static bool VersionValid(StarterVersion? version) => version is null || version.ObservedAt != default && version.Content is { Length: <= 2000 } && version.Fingerprint is { Length: 64 } && diff --git a/docs/recruitment.md b/docs/recruitment.md index 41e4e676..e2dd7c18 100644 --- a/docs/recruitment.md +++ b/docs/recruitment.md @@ -1,6 +1,6 @@ # Recruitment management -Recruitment is disabled in the example, development and production settings. It uses four **new** forums, one staff text feed, and the existing configured moderator role. Settings and asset changes take effect after a restart. There is no migration from the old single-forum babysitter or `feat/babysitter` state. +Recruitment is disabled in the example, development and production settings. It uses four **new** forums, one staff text feed, and the existing configured moderator role. Settings and asset changes take effect after a restart. The administrator portfolio-site registry changes immediately without rewriting settings. There is no migration from the old single-forum babysitter or `feat/babysitter` state. ## Member behavior @@ -95,6 +95,31 @@ The staff feed keeps one concise entry per attempt, with full details in lookup. Reports and versions follow listing retention; active listings retain their bounded evidence. Access to the persistent volume/backups should remain restricted to operators. The bot adds no external classifier, link crawler, private-conversation scraper or public reputation database. +## Administrator portfolio sites + +`/recruitment portfolio-sites operation [domain] [reason]` lists, adds or removes recognized portfolio hostnames. All replies are ephemeral. This command requires a current administrator in the configured guild; the moderator role alone does not grant access. Both command preconditions and the serialized engine enforce authorization. Add/remove require a nonempty audit reason. Recognition is an observation, never endorsement or proof of authorship. + +The effective registry starts with `github.com`, `gitlab.com`, `artstation.com`, `behance.net`, `dribbble.com`, `itch.io`, `vimeo.com`, `youtube.com`, `youtu.be` and `deviantart.com`. It persists independently of settings, including an intentionally empty set, with at most 100 domains and the last 100 changes (actor, UTC time, operation, hostname and reason). Duplicate adds/removes are idempotent. Restarting does not restore removed defaults. Changes make retained open listings due for normal bounded reclassification (at most 20 per scheduled batch), with persisted per-listing registry versions for restart recovery. They do not create starter versions, material-edit notices, slot exemptions or enforcement decisions. + +Enter a DNS hostname, such as `portfolio.example.org`, without a scheme, path, port, credentials or wildcard. Names are trimmed, lowercased and normalized to ASCII IDN form. Matching includes explicit subdomains at a dot boundary; `github.com.evil.org` and `evilgithub.com` do not match `github.com`. IP addresses, local/unlisted TLD names and bare ICANN public suffixes (including multi-label and wildcard rules) are rejected. Provider-private suffixes do not invalidate recognized creator platforms. + +Initial URL rules are deliberately conservative and offline: + +| URL form | Portfolio observation | +| --- | --- | +| Root of a supported platform | Ambiguous external link (yellow), not a work/profile link | +| Non-navigation profile/project path on GitHub, GitLab, ArtStation, Behance, Dribbble, itch.io, Vimeo or DeviantArt | Specific platform link (green), unless the work claim is negated | +| Creator subdomain root on itch.io, ArtStation or DeviantArt (excluding `www`) | Specific platform link | +| YouTube `/watch?v=<11-character ID>`, youtu.be video, `/shorts/` or `/embed/` | Specific video; video ID is preserved with case | +| YouTube `/@creator`, `/channel/`, `/c/` or `/user/` | Specific creator link | +| Shared navigation such as `/explore`, `/search`, `/issues`, `/settings` or `/feed` | Ambiguous external link | +| Personal/unrecognized site, including its root; attached media without a supported link | Yellow clarification | +| Invite/contact-only links and negated work claims | Do not establish a portfolio; invites/contact-only links are excluded | + +New registry domains use the same conservative path/navigation rules. This is not link crawling. The ICANN validator embeds `DiscordBot/Assets/recruitment/public-suffix-icann.dat`, copied from [the official Public Suffix List](https://publicsuffix.org/list/public_suffix_list.dat), upstream version `2026-10-07_07-28-19_UTC`, commit `3929462652695bad04f0a27afb600974014a3c8b`. Its upstream MPL-2.0 license notice is preserved. Only the ICANN section is bundled; runtime does not fetch it. Update this snapshot deliberately when registry boundaries change. + +Reuse evidence retains at most eight canonical specific candidates per observed starter version within existing version/retention limits. Host case and default ports are normalized, fragments and known `utm_*`/click-tracking parameters are removed, while path case and YouTube video identity remain significant. Unknown query-dependent URLs have their entire query scrubbed in URL observations and are explicitly unsuitable for duplicate matching; they are never collapsed into a reusable base-path identity. Stored starter excerpts remain observed member content. Root landing pages and contact/invite links do not produce URL matches. Moderator lookup alone may show up to five different-author same-server matches against retained versions, with a reminder that shared projects can be legitimate. These hints never enter public output, Legend/member Details, reports, colors or enforcement; pruning listing evidence removes its hints. + ## Persistence and recovery State is stored in `{ServerRootPath}/recruitment/`: From 1bfc4b2fbf298d8d8d7e4d6615e3e89faeb78f10 Mon Sep 17 00:00:00 2001 From: James Kellie Date: Sat, 17 Oct 2026 14:06:04 +1000 Subject: [PATCH 19/31] Ignore zero-valued compensation claims --- DiscordBot.Tests/Recruitment/RecruitmentSignalTests.cs | 3 +++ DiscordBot/Services/Recruitment/RecruitmentContent.cs | 3 ++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/DiscordBot.Tests/Recruitment/RecruitmentSignalTests.cs b/DiscordBot.Tests/Recruitment/RecruitmentSignalTests.cs index 93184206..00b97045 100644 --- a/DiscordBot.Tests/Recruitment/RecruitmentSignalTests.cs +++ b/DiscordBot.Tests/Recruitment/RecruitmentSignalTests.cs @@ -35,6 +35,9 @@ public void StableNineChecksUseUtcCalendarAnniversariesIncludingLeapDates() [InlineData(null, ObservationApplicability.Unavailable, ObservationSeverity.Grey)] [InlineData("", ObservationApplicability.Available, ObservationSeverity.Red)] [InlineData("Budget $500", ObservationApplicability.Available, ObservationSeverity.Green)] + [InlineData("Budget $0", ObservationApplicability.Available, ObservationSeverity.Red)] + [InlineData("My rate is USD 0.00/hour", ObservationApplicability.Available, ObservationSeverity.Red)] + [InlineData("My rate is $0.50/hour", ObservationApplicability.Available, ObservationSeverity.Green)] [InlineData("Payment will be discussed", ObservationApplicability.Available, ObservationSeverity.Yellow)] [InlineData("Paid in exposure", ObservationApplicability.Available, ObservationSeverity.Red)] [InlineData("Budget $500. Never pay an application fee", ObservationApplicability.Available, ObservationSeverity.Green)] diff --git a/DiscordBot/Services/Recruitment/RecruitmentContent.cs b/DiscordBot/Services/Recruitment/RecruitmentContent.cs index cbc1ab5d..2f6a28ba 100644 --- a/DiscordBot/Services/Recruitment/RecruitmentContent.cs +++ b/DiscordBot/Services/Recruitment/RecruitmentContent.cs @@ -23,7 +23,8 @@ public static PaymentObservation ReadPayment(string? content) try { var sentences = Sentences().Split(text); - var rate = sentences.FirstOrDefault(s => Concrete().IsMatch(s) && + // A monetary literal containing only zeroes does not promise monetary pay. + var rate = sentences.FirstOrDefault(s => Concrete().Matches(s).Any(amount => amount.Value.Any(c => c is >= '1' and <= '9')) && (PaymentLabel().IsMatch(s) || RateUnit().IsMatch(s)) && !OtherPrice().IsMatch(s) && !ApplicantFee().IsMatch(s) && !NegatedMoney().IsMatch(s)); var unpaid = sentences.FirstOrDefault(s => Unpaid().IsMatch(s) && !NegatedUnpaid().IsMatch(s) && !(ApplicantFee().IsMatch(s) && NoFee().IsMatch(s))); var share = sentences.FirstOrDefault(s => RevenueShare().IsMatch(s) && !NegatedShare().IsMatch(s)); From 8f24992b99a75fa386a18426e20eb2350bb00829 Mon Sep 17 00:00:00 2001 From: James Kellie Date: Sat, 17 Oct 2026 14:06:11 +1000 Subject: [PATCH 20/31] Render recruitment observation cards and legends --- .../Recruitment/RecruitmentBannerTests.cs | 84 +++++++++++++++++++ .../Recruitment/DiscordRecruitmentAdapter.cs | 2 +- .../Recruitment/IRecruitmentDiscord.cs | 7 +- .../Services/Recruitment/RecruitmentBanner.cs | 66 +++++++++++++++ .../Recruitment/RecruitmentPresentation.cs | 63 +++++++++++++- 5 files changed, 216 insertions(+), 6 deletions(-) create mode 100644 DiscordBot.Tests/Recruitment/RecruitmentBannerTests.cs create mode 100644 DiscordBot/Services/Recruitment/RecruitmentBanner.cs diff --git a/DiscordBot.Tests/Recruitment/RecruitmentBannerTests.cs b/DiscordBot.Tests/Recruitment/RecruitmentBannerTests.cs new file mode 100644 index 00000000..b07c0425 --- /dev/null +++ b/DiscordBot.Tests/Recruitment/RecruitmentBannerTests.cs @@ -0,0 +1,84 @@ +using DiscordBot.Services.Recruitment; +using ImageMagick; +using Newtonsoft.Json; + +namespace DiscordBot.Tests.Recruitment; + +public class RecruitmentBannerTests +{ + [Fact] + public async Task NineValuesMediaAndFallbackKeepOperationsAndPrivateEvidenceSeparate() + { + using var rig = new RecruitmentRig(); await rig.Start(); var post = await rig.Post(forum: "12"); + post.Facts.ServerNickname = "**<@123> Name**"; post.Facts.Username = "user"; + post.Facts.AvatarUrl = "https://cdn.discordapp.com/avatars/123/avatar.png?ex=123"; + post.OwnerId = "18446744073709551615"; + post.Hold = "Recovery"; + post.Facts.Observations = new() { new("fee", "Applicant fee", "pay $50 today") }; + var message = RecruitmentPresentation.PublicV2(rig.State, post, rig.Settings.Recruitment, rig.Clock.Now); + Assert.Equal(RecruitmentMessageFormat.ComponentsV2, message.Format); + Assert.Null(message.Embed); Assert.Equal("", message.Content); + Assert.NotNull(message.Media); Assert.InRange(message.Media.Description.Length, 1, 1024); + foreach (var c in post.Facts.Checks) Assert.Contains(c.Label + ": " + c.Value, message.Media.Description); + Assert.InRange(RecruitmentBanner.GalleryAlt(post.Facts.Checks).Length, 1, 256); + foreach (var c in post.Facts.Checks) Assert.Contains(c.Value, RecruitmentBanner.GalleryAlt(post.Facts.Checks)); + var json = JsonConvert.SerializeObject(message.Components); + Assert.Contains("Legend", json); Assert.Contains("recruit:details:100", json); + Assert.Contains("Acknowledge", json); Assert.Contains("Due", json); Assert.Contains("paused", json); + Assert.Contains("Applicant fee", json); Assert.Contains("18446744073709551615", json); + Assert.DoesNotContain("<@123>", json); Assert.Contains("\"Type\":11", json); + var fallback = JsonConvert.SerializeObject(message.FallbackComponents); + Assert.DoesNotContain("attachment://", fallback); + foreach (var c in post.Facts.Checks) Assert.Contains(c.Value, fallback); + post.Facts.AvatarUrl = "https://cdn.discordapp.com/avatars/123/avatar.png?ex=456"; + Assert.Equal(message.RenderKey, RecruitmentPresentation.PublicV2(rig.State, post, rig.Settings.Recruitment, rig.Clock.Now).RenderKey); + rig.State.PortfolioSites.Version++; + Assert.NotEqual(message.RenderKey, RecruitmentPresentation.PublicV2(rig.State, post, rig.Settings.Recruitment, rig.Clock.Now).RenderKey); + var failed = RecruitmentPresentation.PublicV2(rig.State, post, rig.Settings.Recruitment, rig.Clock.Now, + c => RecruitmentBanner.Render(c, "/tmp/nonexistent-recruitment-font.ttf")); + Assert.Null(failed.Media); Assert.DoesNotContain("attachment://", JsonConvert.SerializeObject(failed.Components)); + post.Facts.ServerNickname = " "; post.Facts.GlobalName = "Global Name"; + post.Facts.AvatarUrl = null; post.AvailabilityUnknown = true; + var unknown = JsonConvert.SerializeObject(RecruitmentPresentation.PublicV2(rig.State, post, rig.Settings.Recruitment, rig.Clock.Now).Components); + Assert.Contains("Global Name", unknown); Assert.Contains("Avatar unavailable", unknown); Assert.Contains("last observed", unknown); + post.Facts.GlobalName = null; post.AcceptedAt = rig.Clock.Now; + var accepted = JsonConvert.SerializeObject(RecruitmentPresentation.PublicV2(rig.State, post, rig.Settings.Recruitment, rig.Clock.Now).Components); + Assert.Contains("**user**", accepted); Assert.DoesNotContain("recruit:ack:", accepted); + post.Status = ListingStatus.Closed; post.LastOutcome = "Closed by author"; + var closed = JsonConvert.SerializeObject(RecruitmentPresentation.PublicV2(rig.State, post, rig.Settings.Recruitment, rig.Clock.Now).Components); + Assert.Contains("Closed by author", closed); Assert.DoesNotContain("recruit:ack:", closed); + Assert.DoesNotContain("reuse", RecruitmentPresentation.Details(post), StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task RenderIsBoundedDeterministicAndReadableWithoutColor() + { + using var rig = new RecruitmentRig(); await rig.Start(); var post = await rig.Post(forum: "12"); + var checks = post.Facts.Checks.Select((c,i) => c with { + Severity = (ObservationSeverity)(i % 4), + Applicability = i == 3 ? ObservationApplicability.NotApplicable : i == 8 ? ObservationApplicability.Unavailable : ObservationApplicability.Available, + Value = i == 3 ? "Not Applicable" : i == 8 ? "Unavailable" : c.Value }).ToArray(); + var bytes = RecruitmentBanner.Render(checks); + Assert.Equal(bytes, RecruitmentBanner.Render(checks)); + using var image = new MagickImage(bytes); + Assert.Equal(MagickFormat.Png, image.Format); + Assert.Equal((uint)RecruitmentBanner.Width, image.Width); Assert.Equal((uint)RecruitmentBanner.Height, image.Height); + Assert.InRange(bytes.Length, 1000, 500000); + Assert.Throws(() => RecruitmentBanner.Render(checks.Take(8).ToArray())); + var longChecks = checks.Select(c => c with { Value = new string('x', 500) }).ToArray(); + Assert.NotEmpty(RecruitmentBanner.Render(longChecks)); + // Local review fixtures, intentionally outside repository discovery. + Directory.CreateDirectory("/tmp/udc-t04-previews"); + image.Write("/tmp/udc-t04-previews/grid.png"); + foreach (var width in new uint[] { 320, 360, 640 }) + foreach (var light in new[] { false, true }) + foreach (var gray in new[] { false, true }) + { + using var preview = image.Clone(); preview.Resize(width, 0); + if (gray) preview.ColorSpace = ColorSpace.Gray; + preview.BackgroundColor = light ? MagickColors.White : new MagickColor("#313338"); + preview.Extent(width + 24, preview.Height + 24, Gravity.Center); + preview.Write($"/tmp/udc-t04-previews/{width}-{(light ? "light" : "dark")}-{(gray ? "gray" : "color")}.png"); + } + } +} diff --git a/DiscordBot/Services/Recruitment/DiscordRecruitmentAdapter.cs b/DiscordBot/Services/Recruitment/DiscordRecruitmentAdapter.cs index bf70e921..cb8d63c4 100644 --- a/DiscordBot/Services/Recruitment/DiscordRecruitmentAdapter.cs +++ b/DiscordBot/Services/Recruitment/DiscordRecruitmentAdapter.cs @@ -289,7 +289,7 @@ public async Task UpsertAsync(string channelId, string? messageId, boo } var builder = new ComponentBuilder(); if (message.AcknowledgementRequired) builder.WithButton("Acknowledge", $"recruit:ack:{channelId}", ButtonStyle.Primary); - if (message.ReaderControls) builder.WithButton("Details", $"recruit:details:{channelId}", ButtonStyle.Secondary) + if (message.ReaderControls) builder.WithButton("Legend", $"recruit:details:{channelId}", ButtonStyle.Secondary) .WithButton("Report concern", $"recruit:report:{channelId}", ButtonStyle.Secondary); if (message.OwnerControls) builder.WithButton("Your listing", $"recruit:owner:{channelId}", ButtonStyle.Secondary); var components = builder.Build(); diff --git a/DiscordBot/Services/Recruitment/IRecruitmentDiscord.cs b/DiscordBot/Services/Recruitment/IRecruitmentDiscord.cs index 1806df74..c777cb90 100644 --- a/DiscordBot/Services/Recruitment/IRecruitmentDiscord.cs +++ b/DiscordBot/Services/Recruitment/IRecruitmentDiscord.cs @@ -10,9 +10,12 @@ public sealed record ThreadSnapshot(string Id, string ForumId, string OwnerId, s string? ServerNickname = null, string? GlobalName = null, string? Username = null, string? AvatarUrl = null, string? AvatarIdentity = null); public sealed record ArchiveCursor(DateTimeOffset Since, DateTimeOffset? Before = null, DateTimeOffset? StartedAt = null); public sealed record ThreadInventory(IReadOnlyList Threads, bool Complete, IReadOnlyDictionary? Cursors = null); -public sealed record RecruitmentMessage(string Content, string Marker, bool OwnerControls, bool RestoreArchive = false, Embed? Embed = null, string? TextFallback = null, bool AcknowledgementRequired = false, bool ReaderControls = false) +public enum RecruitmentMessageFormat { Standard, ComponentsV2 } +public sealed record RecruitmentMedia(string FileName, byte[] Bytes, string Description); +public sealed record RecruitmentMessage(string Content, string Marker, bool OwnerControls, bool RestoreArchive = false, Embed? Embed = null, string? TextFallback = null, bool AcknowledgementRequired = false, bool ReaderControls = false, RecruitmentMessageFormat Format = RecruitmentMessageFormat.Standard, + string? RenderIdentity = null, MessageComponent? Components = null, MessageComponent? FallbackComponents = null, RecruitmentMedia? Media = null) { - public string RenderKey => Content + "\n" + OwnerControls + "\n" + AcknowledgementRequired + "\n" + ReaderControls + "\n" + EmbedKey(Embed); + public string RenderKey => RenderIdentity ?? Content + "\n" + OwnerControls + "\n" + AcknowledgementRequired + "\n" + ReaderControls + "\n" + EmbedKey(Embed); public static string EmbedKey(IEmbed? embed) => embed is null ? "" : Newtonsoft.Json.JsonConvert.SerializeObject(embed.ToEmbedBuilder().Build()); } public sealed record Delivery(string Id, bool NewlySent); diff --git a/DiscordBot/Services/Recruitment/RecruitmentBanner.cs b/DiscordBot/Services/Recruitment/RecruitmentBanner.cs new file mode 100644 index 00000000..9efe64d3 --- /dev/null +++ b/DiscordBot/Services/Recruitment/RecruitmentBanner.cs @@ -0,0 +1,66 @@ +using ImageMagick; +using ImageMagick.Drawing; +using System.IO; +using System.Security.Cryptography; +using System.Text; + +namespace DiscordBot.Services.Recruitment; + +/// Offline, bounded renderer. No author identifiers or downloaded content enter the image. +public static class RecruitmentBanner +{ + public const string Version = "observations-grid-2"; + public const int Width = 1200, Height = 840; + private static readonly object Gate = new(); + private static readonly Dictionary Cache = new(); + public static string Identity(string value) => Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(value))); + public static string Status(RecruitmentObservation c) => c.Applicability != ObservationApplicability.Available ? "Grey" : c.Severity.ToString(); + public static string Alt(IReadOnlyList checks) => string.Join("; ", checks.Select(c => $"{c.Label}: {RecruitmentPresentation.Safe(c.Value, 60)} ({Status(c)})")); + public static string GalleryAlt(IReadOnlyList checks) + { + var labels = new[] { "Age", "Joined", "Roles", "Pay", "Duration", "Portfolio", "DM", "Avatar", "Detail" }; + var text = string.Join("; ", checks.Select((c, i) => $"{labels[i]}: {RecruitmentPresentation.Safe(c.Value, 60)}")); + // The installed Discord.Net gallery builder caps descriptions at 256. The attachment + // description and Legend retain full labels/statuses even for unexpected long values. + return text.Length <= 256 ? text : "Nine automated observations. Complete values and explanations: Legend."; + } + public static byte[] Render(IReadOnlyList checks, string? fontPath = null) + { + if (checks.Count != 9) throw new ArgumentException("The banner requires nine observations.", nameof(checks)); + fontPath ??= Path.Combine(AppContext.BaseDirectory, "Assets", "fonts", "OpenSans-Regular.ttf"); + if (!File.Exists(fontPath)) throw new FileNotFoundException("Recruitment banner font unavailable.", fontPath); + var key = Identity(Version + fontPath + Alt(checks)); + lock (Gate) + { + if (Cache.TryGetValue(key, out var cached)) return cached.ToArray(); + using var image = new MagickImage(new MagickColor("#20242B"), Width, Height); + for (var i = 0; i < 9; i++) + { + var c = checks[i]; var x = i % 3 * 400 + 12; var y = i / 3 * 280 + 12; + var status = Status(c); + var color = status switch { "Green" => "#66D5A0", "Yellow" => "#F1CD68", "Red" => "#FF9494", _ => "#BAC2CE" }; + var d = new Drawables().FillColor(new MagickColor("#2C323B")).Rectangle(x, y, x + 376, y + 256) + .Font(fontPath).FontPointSize(40).FillColor(MagickColors.White).Text(x + 16, y + 48, c.Label) + .FillColor(new MagickColor(color)); + // Shapes and explicit words preserve meaning without color. + if (status == "Green") d.Circle(x + 26, y + 229, x + 36, y + 229); + else if (status == "Yellow") d.Polygon(new PointD(x + 16, y + 239), new PointD(x + 26, y + 217), new PointD(x + 36, y + 239)); + else if (status == "Red") d.Rectangle(x + 16, y + 219, x + 36, y + 239); + else d.FillColor(MagickColors.Transparent).StrokeColor(new MagickColor(color)).StrokeWidth(3).Circle(x + 26, y + 229, x + 36, y + 229).StrokeWidth(0); + d.FillColor(MagickColors.White).FontPointSize(40).Text(x + 48, y + 240, status); + var words = RecruitmentPresentation.Safe(c.Value, 60).Split(' ', StringSplitOptions.RemoveEmptyEntries); + var lines = new List(); var line = ""; + foreach (var word in words) { if (line.Length + word.Length > 16 && line.Length > 0) { lines.Add(line); line = ""; } line += (line.Length > 0 ? " " : "") + word; } + lines.Add(line); + d.FontPointSize(40); + for (var n = 0; n < Math.Min(3, lines.Count); n++) d.Text(x + 16, y + 100 + n * 44, lines[n].Length > 17 ? lines[n][..16] + "…" : lines[n]); + d.Draw(image); + } + image.Format = MagickFormat.Png; image.Strip(); + var bytes = image.ToByteArray(); + if (Cache.Count >= 32) Cache.Clear(); + Cache[key] = bytes; + return bytes.ToArray(); + } + } +} diff --git a/DiscordBot/Services/Recruitment/RecruitmentPresentation.cs b/DiscordBot/Services/Recruitment/RecruitmentPresentation.cs index a7c0041b..1038955a 100644 --- a/DiscordBot/Services/Recruitment/RecruitmentPresentation.cs +++ b/DiscordBot/Services/Recruitment/RecruitmentPresentation.cs @@ -1,4 +1,5 @@ using DiscordBot.Settings; +using System.IO; namespace DiscordBot.Services.Recruitment; @@ -33,11 +34,67 @@ public static RecruitmentMessage Public(RecruitmentState state, Listing post, Re AcknowledgementRequired: needsAck, ReaderControls: true); } + public const string Heading = "Recruitment · Automated Observations"; + public const string Guidance = "Automated observations; neither endorsement nor judgment. Verify claims. Keep first contact and key terms here; use DMs only for private information."; + + // Final public model. T05 switches Engine.PublishAsync/ExecuteActionAsync to this method + // and deletes the interim embed Public method above. + public static RecruitmentMessage PublicV2(RecruitmentState state, Listing post, RecruitmentSettings settings, + DateTimeOffset now, Func, byte[]>? render = null) + { + var checks = post.Facts.Checks; + var alt = RecruitmentBanner.Alt(checks); + var needsAck = post.Status == ListingStatus.Open && !post.AcceptedAt.HasValue && !post.AcknowledgedAt.HasValue; + var operations = new List(); + if (needsAck) operations.Add("**Author:** Read this forum's Guidelines and enter its code with **Acknowledge**." + + (post.Deadline is { } deadline ? $" Due {Timestamp(deadline)}." : " Your window starts when these controls are available.")); + if (post.Status == ListingStatus.Closed) operations.Add("**Closed:** " + Safe(post.LastOutcome, 220)); + if (post.Hold is not null || post.AvailabilityUnknown) operations.Add("Bot processing is paused; listing details may be incomplete."); + var warnings = string.Join("\n", post.Facts.Observations.Take(4).Select(o => $"• {Safe(o.Description, 100)}: “{Safe(o.Evidence, 65)}”.")); + var name = new[] { post.Facts.ServerNickname, post.Facts.GlobalName, post.Facts.Username } + .Select(v => RecruitmentSignals.SafeIdentity(v)).FirstOrDefault(v => v is not null) ?? "Author unavailable"; + var identity = $"**{name}**\nUsername: {Safe(post.Facts.Username ?? "unavailable", 100)} · ID: {Safe(post.OwnerId, 30)}"; + var avatar = RecruitmentSignals.SafeAvatarUrl(post.Facts.AvatarUrl); + if (post.AvailabilityUnknown) identity += "\nIdentity last observed; current membership unavailable."; + var marker = $"recruitment:{post.Id}:public"; + var visible = $"## {Heading}\n*{Guidance}*\n"; + var foot = string.Join("\n", operations.Append(warnings).Where(v => v.Length > 0)); + MessageComponent Build(bool image) + { + var children = new List { new TextDisplayBuilder($"## {Heading}") }; + if (image) children.Add(new MediaGalleryBuilder().AddItem("attachment://recruitment-observations.png", RecruitmentBanner.GalleryAlt(checks))); + else children.Add(new TextDisplayBuilder(string.Join("\n", checks.Select(c => $"**{c.Label}:** {Safe(c.Value, 60)} ({RecruitmentBanner.Status(c)})")))); + children.Add(new TextDisplayBuilder($"*{Guidance}*")); + if (foot.Length > 0) children.Add(new TextDisplayBuilder(foot)); + if (avatar is not null) children.Add(new SectionBuilder(new ThumbnailBuilder(new UnfurledMediaItemProperties(avatar), "Author avatar"), new[] { new TextDisplayBuilder(identity) })); + else children.Add(new TextDisplayBuilder(identity + "\nAvatar unavailable.")); + var row = new ActionRowBuilder().WithButton("Legend", $"recruit:details:{post.Id}", ButtonStyle.Secondary) + .WithButton("Report concern", $"recruit:report:{post.Id}", ButtonStyle.Secondary) + .WithButton("Your listing", $"recruit:owner:{post.Id}", ButtonStyle.Secondary); + if (needsAck) row.WithButton("Acknowledge", $"recruit:ack:{post.Id}", ButtonStyle.Primary); + children.Add(row); + children.Add(new TextDisplayBuilder($"||{marker}||")); + return new ComponentBuilderV2(new[] { new ContainerBuilder(children) }).Build(); + } + RecruitmentMedia? media = null; + try { media = new("recruitment-observations.png", (render ?? (c => RecruitmentBanner.Render(c)))(checks), alt); } + catch (Exception ex) when (ex is ImageMagick.MagickException or IOException or ArgumentException or InvalidOperationException) { /* Text remains complete and operable. */ } + // Deliberately excludes CDN signatures and SDK-generated component IDs. + var avatarKey = avatar is null ? "" : new Uri(avatar).GetLeftPart(UriPartial.Path); + var key = RecruitmentBanner.Identity(RecruitmentBanner.Version + "\n" + state.PortfolioSites.Version + "\n" + + alt + "\n" + visible + foot + identity + avatarKey + "\n" + needsAck + "\n" + marker + "\n" + (media is not null)); + var fallback = Build(false); + return new RecruitmentMessage("", marker, true, AcknowledgementRequired: needsAck, ReaderControls: true, + Format: RecruitmentMessageFormat.ComponentsV2, RenderIdentity: key, Components: media is null ? fallback : Build(true), + FallbackComponents: fallback, Media: media); + } + public static string Details(Listing post) { - return "**Listing details**\n" + string.Join("\n", post.Facts.Checks.Select(c => - $"{c.Label}: {c.Value}. {c.Applicability} / {c.Severity}." + (string.IsNullOrEmpty(c.Evidence) ? "" : $" {Safe(c.Evidence, 370)}"))) + - "\nObservations use starter and membership data; unavailable data is not evidence against the author. Roles, avatars, links and length do not establish trust or authorship. Reports go privately to moderators."; + return "**Legend · Recruitment observations**\n" + string.Join("\n", post.Facts.Checks.Select(c => + $"{c.Label}: {Safe(c.Value, 60)}. {c.Applicability} / {c.Severity}." + (string.IsNullOrEmpty(c.Evidence) ? "" : $" {Safe(c.Evidence, 370)}"))) + + (post.Facts.Observations.Count == 0 ? "" : "\n**Additional evidence**\n" + string.Join("\n", post.Facts.Observations.Take(4).Select(o => $"• {Safe(o.Description, 140)}: “{Safe(o.Evidence, 180)}”."))) + + "\nGreen describes an observed fact; yellow asks for clarification; red highlights a missing expected detail or concern. Grey means Unavailable or Not Applicable. Colors never establish trust.\nAccount Age: at posting, green ≥2 years; yellow ≥6 months. Join Date: at posting, green ≥1 year; yellow ≥2 months; later joins are rejoined, with no invented tenure. Server Roles excludes everyone.\nCompensation: monetary rate/budget, unclear or mixed terms, or no monetary pay; hobby is Not Applicable. Duration seeks an explicit work window/availability. Portfolio recognizes specific supported-platform work links, without endorsement; recruiting is Not Applicable; attached samples alone request clarification for paid for-hire.\nDM Requests flags affirmative DM-first instructions, more strongly below 120 body characters. Profile Image describes custom/default appearance only. Post Detail: green ≥300, yellow 120–299, red <120 normalized characters; URLs and markup excluded.\nObservations use starter and membership data; unavailable data is not evidence against the author. Roles, avatars, links and length do not establish trust or authorship. Reports go privately to moderators."; } public static RecruitmentMessage Staff(RecruitmentState state, Listing post, RecruitmentSettings settings) From ea75fdf57917e36fefcc24e7878deb615fcea26e Mon Sep 17 00:00:00 2001 From: James Kellie Date: Sat, 17 Oct 2026 14:16:26 +1000 Subject: [PATCH 21/31] Deliver recoverable recruitment cards with retained media --- .../Recruitment/RecruitmentBannerTests.cs | 12 + .../RecruitmentPresentationTests.cs | 60 +--- .../RecruitmentPublicationTests.cs | 283 +++++++++++++++++- .../Recruitment/RecruitmentRecoveryTests.cs | 12 + .../RecruitmentStorageAndSetupTests.cs | 22 ++ .../Recruitment/DiscordRecruitmentAdapter.cs | 14 +- .../Recruitment/DiscordRecruitmentDelivery.cs | 132 ++++++++ .../Recruitment/IRecruitmentDiscord.cs | 4 +- .../Services/Recruitment/RecruitmentEngine.cs | 20 +- .../Recruitment/RecruitmentForumTransport.cs | 22 +- .../RecruitmentMessageTransport.cs | 86 ++++++ .../Recruitment/RecruitmentPresentation.cs | 43 +-- .../Services/Recruitment/RecruitmentState.cs | 3 + .../Recruitment/RecruitmentStateStore.cs | 4 + docs/recruitment.md | 21 +- 15 files changed, 632 insertions(+), 106 deletions(-) create mode 100644 DiscordBot/Services/Recruitment/DiscordRecruitmentDelivery.cs create mode 100644 DiscordBot/Services/Recruitment/RecruitmentMessageTransport.cs diff --git a/DiscordBot.Tests/Recruitment/RecruitmentBannerTests.cs b/DiscordBot.Tests/Recruitment/RecruitmentBannerTests.cs index b07c0425..1f9352f9 100644 --- a/DiscordBot.Tests/Recruitment/RecruitmentBannerTests.cs +++ b/DiscordBot.Tests/Recruitment/RecruitmentBannerTests.cs @@ -50,6 +50,18 @@ public async Task NineValuesMediaAndFallbackKeepOperationsAndPrivateEvidenceSepa Assert.DoesNotContain("reuse", RecruitmentPresentation.Details(post), StringComparison.OrdinalIgnoreCase); } + [Fact] + public async Task MediaDigestChangesWithBytesAndNamesDoNotChangeImageIdentity() + { + using var rig = new RecruitmentRig(); await rig.Start(); var post = await rig.Post(); + var first = RecruitmentPresentation.PublicV2(rig.State, post, rig.Settings.Recruitment, rig.Clock.Now, _ => new byte[] { 1 }); + var changed = RecruitmentPresentation.PublicV2(rig.State, post, rig.Settings.Recruitment, rig.Clock.Now, _ => new byte[] { 2 }); + Assert.NotEqual(first.Media!.FileName, changed.Media!.FileName); Assert.NotEqual(first.RenderKey, changed.RenderKey); + post.Facts.ServerNickname = "New name"; + var renamed = RecruitmentPresentation.PublicV2(rig.State, post, rig.Settings.Recruitment, rig.Clock.Now, _ => new byte[] { 1 }); + Assert.Equal(first.Media.FileName, renamed.Media!.FileName); Assert.NotEqual(first.RenderKey, renamed.RenderKey); + } + [Fact] public async Task RenderIsBoundedDeterministicAndReadableWithoutColor() { diff --git a/DiscordBot.Tests/Recruitment/RecruitmentPresentationTests.cs b/DiscordBot.Tests/Recruitment/RecruitmentPresentationTests.cs index e830116f..4005d611 100644 --- a/DiscordBot.Tests/Recruitment/RecruitmentPresentationTests.cs +++ b/DiscordBot.Tests/Recruitment/RecruitmentPresentationTests.cs @@ -16,57 +16,29 @@ public class RecruitmentPresentationTests public void WorkSampleDetectionDistinguishesInvitesAndUnlabelledLinks(string? content, string expected) => Assert.Equal(expected, RecruitmentContent.Portfolio(content)); - [Fact] - public async Task MaximumReaderNotesFitTextFallbackAndDoNotImplyClearance() - { - using var rig = new RecruitmentRig(); await rig.Start(); var post = await rig.Post(forum: "12"); - post.Facts.Payment = "mixed compensation terms; clarify what is guaranteed"; - post.Facts.PaymentEvidence = new string('x', 363); - post.Facts.Observations = new List { - new("private-terms", "Post directs readers to private messages for terms", new string('x', 180)), - new("applicant-fee", "Post mentions an applicant payment or fee", new string('x', 180)), - new("payment-pressure", "Post combines payment with urgency", new string('x', 180)) }; - post.Hold = "Recovery needed"; - var message = RecruitmentPresentation.Public(rig.State, post, rig.Settings.Recruitment, rig.Clock.Now); - Assert.InRange(message.TextFallback!.Length, 1, 2000); - var color = message.Embed!.Color; - post.Facts.Observations.Clear(); post.Facts.Payment = "concrete rate or budget stated"; post.Facts.Portfolio = "work-sample link detected"; - var quiet = RecruitmentPresentation.Public(rig.State, post, rig.Settings.Recruitment, rig.Clock.Now); - Assert.Equal(color, quiet.Embed!.Color); - Assert.DoesNotContain("trusted", quiet.TextFallback); Assert.DoesNotContain("no red flags", quiet.TextFallback); - } + private static string Text(RecruitmentMessage message) => Newtonsoft.Json.JsonConvert.SerializeObject(message.FallbackComponents); [Fact] - public async Task ForHireEmbedHighlightsMissingEvidenceWithoutImplyingEndorsement() + public async Task PublicFallbackKeepsWarningsInstructionsAndAllObservations() { using var rig = new RecruitmentRig(); await rig.Start(); var post = await rig.Post(forum: "12"); - post.Facts.AccountCreatedAt = post.CreatedAt.AddDays(-2); post.Facts.JoinedAt = post.CreatedAt; - post.Facts.MemberRoleCount = 0; post.Facts.Payment = RecruitmentContent.Payment("DM for rates"); - var message = RecruitmentPresentation.Public(rig.State, post, rig.Settings.Recruitment, rig.Clock.Now); - var embed = Assert.IsType(message.Embed); - Assert.Equal(new Discord.Color(0x747F8D), embed.Color); - Assert.Contains("Account Age", embed.Description); - Assert.Contains("Clarify scope", embed.Description); - Assert.Contains("No Samples Found", embed.Description); - Assert.Contains("Server Roles", embed.Description); - Assert.True(message.AcknowledgementRequired); - Assert.DoesNotContain("Initial snapshot", message.TextFallback); - Assert.DoesNotContain("Community activity", message.TextFallback); - Assert.DoesNotContain("Protected from", message.TextFallback); - Assert.Contains("identity and work are unverified", embed.Footer!.Value.Text); - Assert.True(message.TextFallback!.Length < 2000); + post.Facts.Observations = new List { new("applicant-fee", "Applicant fee", "fee requested") }; + post.Hold = "Recovery needed"; + var message = RecruitmentPresentation.PublicV2(rig.State, post, rig.Settings.Recruitment, rig.Clock.Now); + var text = Text(message); + Assert.Null(message.Embed); Assert.Empty(message.Content); + Assert.Contains("Applicant fee", text); Assert.Contains("fee requested", text); + Assert.Contains("Acknowledge", text); Assert.Contains("processing is paused", text); + Assert.All(post.Facts.Checks, check => Assert.Contains(check.Label, text)); + Assert.DoesNotContain("trusted", text); Assert.DoesNotContain("no red flags", text); } [Fact] - public async Task HobbyRecruitingDoesNotDemandPaymentOrAForHirePortfolio() + public async Task HobbyRecruitingKeepsNotApplicableFacts() { using var rig = new RecruitmentRig(); await rig.Start(); var post = await rig.Post(forum: "13"); - post.Facts.MemberRoleCount = 2; post.Facts.Payment = RecruitmentContent.Payment("Unpaid volunteer project"); - var embed = RecruitmentPresentation.Public(rig.State, post, rig.Settings.Recruitment, rig.Clock.Now).Embed!; - Assert.DoesNotContain(embed.Fields, f => f.Name.StartsWith("⚠") || f.Name.Contains("Portfolio")); - Assert.Equal(new Discord.Color(0x747F8D), embed.Color); - post.Facts.Payment = RecruitmentContent.Payment("Revenue share"); - Assert.Contains("Not Applicable", RecruitmentPresentation.Public(rig.State, post, rig.Settings.Recruitment, rig.Clock.Now).Embed!.Description); + var message = RecruitmentPresentation.PublicV2(rig.State, post, rig.Settings.Recruitment, rig.Clock.Now); + Assert.Contains("Not Applicable", Text(message)); } [Fact] @@ -77,7 +49,7 @@ public async Task StarterAndRoleCorrectionsRefreshEmbedFieldsWithoutScanningForu rig.Discord.Threads[post.Id] = rig.Discord.Threads[post.Id] with { Starter = "Portfolio https://example.org/works. Rates USD 50/hour", MemberRoleCount = 3 }; await rig.Engine.FactsChangedAsync(post.Id, default); var message = rig.Discord.Messages["100:recruitment:100:public"].Message; - Assert.DoesNotContain("No portfolio", message.Embed!.Description); + Assert.DoesNotContain("No portfolio", Text(message)); Assert.False(message.AcknowledgementRequired); Assert.True(message.ReaderControls); Assert.Contains("3 Roles", RecruitmentPresentation.Details(post)); Assert.Equal(messageId, post.PublicMessageId); Assert.Equal(inventories, rig.Discord.Inventories); @@ -87,6 +59,6 @@ public async Task StarterAndRoleCorrectionsRefreshEmbedFieldsWithoutScanningForu rig.Discord.Threads[post.Id] = rig.Discord.Threads[post.Id] with { MemberRoleCount = 0 }; await rig.Engine.FactsChangedAsync(post.Id, default); Assert.Contains("0 Roles", RecruitmentPresentation.Details(post)); - Assert.Contains("0 Roles", rig.Discord.Messages["100:recruitment:100:public"].Message.Embed!.Description); + Assert.Contains("0 Roles", Text(rig.Discord.Messages["100:recruitment:100:public"].Message)); } } diff --git a/DiscordBot.Tests/Recruitment/RecruitmentPublicationTests.cs b/DiscordBot.Tests/Recruitment/RecruitmentPublicationTests.cs index b6593ca7..9376b3ae 100644 --- a/DiscordBot.Tests/Recruitment/RecruitmentPublicationTests.cs +++ b/DiscordBot.Tests/Recruitment/RecruitmentPublicationTests.cs @@ -129,6 +129,221 @@ public async Task DepartedMemberLeavesProfileFactsUnavailableWithoutLosingStarte Assert.False(await adapter.IsMemberAsync("1", "200", default)); } + private static RecruitmentMessage V2(string text = "Facts", bool ack = true, bool media = true) + { + MessageComponent Build(bool image) + { + var children = new List { new TextDisplayBuilder(text), new TextDisplayBuilder("||recruitment:100:public||") }; + if (image) children.Add(new MediaGalleryBuilder().AddItem("attachment://facts-" + text + ".png", "All nine facts")); + if (ack) children.Add(new ActionRowBuilder().WithButton("Acknowledge", "recruit:ack:100")); + return new ComponentBuilderV2(new[] { new ContainerBuilder(children) }).Build(); + } + return new RecruitmentMessage("", "recruitment:100:public", true, Format: RecruitmentMessageFormat.ComponentsV2, + Components: Build(media), FallbackComponents: Build(false), Media: media ? new("facts-" + text + ".png", new byte[] { 1, 2, 3 }, "All nine facts") : null); + } + + [Fact] + public async Task V2InstalledSdkCreatesRetainsAndReplacesMediaWithoutAccumulation() + { + var transport = new PublicationTransport { MessageMissing = true }; using var client = await Client(transport); + var channel = (IMessageChannel)(await client.Rest.GetChannelAsync(100))!; + var writes = 0; + var first = await DiscordRecruitmentAdapter.UpsertV2Async(channel, 999, null, false, DateTimeOffset.UtcNow, V2(), true, () => writes++, default); + Assert.True(first.NewlySent); Assert.Equal("901", first.MediaAttachmentId); + Assert.Equal(1, transport.Uploads); + var create = Assert.Single(transport.MessageWrites); + Assert.Equal(32768, (int)create["flags"]!); + Assert.Null(create["content"]); + Assert.Null(create["embeds"]); + Assert.True(create["allowed_mentions"]!["parse"] is null or { Type: JTokenType.Null } || !create["allowed_mentions"]!["parse"]!.Any()); + var same = await DiscordRecruitmentAdapter.UpsertV2Async(channel, 999, first.Id, false, DateTimeOffset.UtcNow, V2(), true, () => writes++, default); + Assert.False(same.NewlySent); Assert.Equal(1, writes); Assert.Equal(1, transport.Uploads); + await DiscordRecruitmentAdapter.UpsertV2Async(channel, 999, first.Id, false, DateTimeOffset.UtcNow, V2(ack: false), true, () => writes++, default); + Assert.Equal(2, writes); Assert.Equal(1, transport.Uploads); + var retain = transport.MessageWrites.Last(); + Assert.Equal("901", (string?)retain["attachments"]![0]!["id"]); + Assert.True(retain["allowed_mentions"]!["parse"] is null or { Type: JTokenType.Null } || !retain["allowed_mentions"]!["parse"]!.Any()); + Assert.DoesNotContain("Acknowledge", retain["components"]!.ToString()); + await DiscordRecruitmentAdapter.UpsertV2Async(channel, 999, first.Id, false, DateTimeOffset.UtcNow, V2("Changed"), true, () => writes++, default); + Assert.Equal(2, transport.Uploads); Assert.Single((JArray)transport.Message["attachments"]!); + Assert.Equal("902", (string?)transport.Message["attachments"]![0]!["id"]); + } + + [Theory] + [InlineData(false, true)] + [InlineData(true, false)] + [InlineData(false, false)] + public async Task V2OptionalMediaUnavailableKeepsTextAndControls(bool mediaAvailable, bool renderSucceeded) + { + var transport = new PublicationTransport { MessageMissing = true }; using var client = await Client(transport); + var channel = (IMessageChannel)(await client.Rest.GetChannelAsync(100))!; + await DiscordRecruitmentAdapter.UpsertV2Async(channel, 999, null, false, DateTimeOffset.UtcNow, V2(media: renderSucceeded), mediaAvailable, () => { }, default); + Assert.Equal(0, transport.Uploads); + var create = Assert.Single(transport.MessageWrites); + Assert.Equal(32768, (int)create["flags"]!); + Assert.Contains("Acknowledge", create["components"]!.ToString()); Assert.Contains("Facts", create["components"]!.ToString()); + Assert.DoesNotContain("attachment://", create.ToString()); + } + + [Fact] + public async Task V2RecoveryUsesExactOwnedMarkerAndHoldsOnIncompleteScan() + { + var transport = new PublicationTransport { MessageMissing = true }; using var client = await Client(transport); + var channel = (IMessageChannel)(await client.Rest.GetChannelAsync(100))!; + transport.LoseCreateResponse = true; + await Assert.ThrowsAsync(() => DiscordRecruitmentAdapter.UpsertV2Async(channel, 999, null, false, DateTimeOffset.UtcNow, V2(media: false), true, () => { }, default)); + // Restart/lost create response: no stored ID, recover from component marker. + var recovered = await DiscordRecruitmentAdapter.UpsertV2Async(channel, 999, null, true, DateTimeOffset.UtcNow, V2(media: false), true, () => { }, default); + Assert.Equal("101", recovered.Id); Assert.False(recovered.NewlySent); Assert.Single(transport.MessageWrites); + transport.Message["author"]!["id"] = "200"; + await Assert.ThrowsAsync(() => DiscordRecruitmentAdapter.UpsertV2Async(channel, 999, "101", false, + DateTimeOffset.UtcNow, V2(media: false), true, () => { }, default)); + transport.LongHistory = true; transport.Message["timestamp"] = DateTimeOffset.UtcNow.ToString("O"); + await Assert.ThrowsAsync(() => DiscordRecruitmentAdapter.UpsertV2Async(channel, 999, null, true, + DateTimeOffset.UtcNow.AddDays(-1), V2(media: false), true, () => { }, default)); + Assert.Single(transport.MessageWrites); + } + + [Fact] + public async Task V2LostEditResponseRestoresArchiveAndRetainsOneUploadAfterRestart() + { + var transport = new PublicationTransport { MessageMissing = true }; using var client = await Client(transport); + var channel = (IMessageChannel)(await client.Rest.GetChannelAsync(100))!; + await DiscordRecruitmentAdapter.UpsertV2Async(channel, 999, null, false, DateTimeOffset.UtcNow, V2(), true, () => { }, default); + transport.Thread["thread_metadata"]!["archived"] = true; transport.Thread["thread_metadata"]!["locked"] = true; + channel = (IMessageChannel)(await client.Rest.GetChannelAsync(100))!; + transport.LoseEditResponse = true; + await Assert.ThrowsAsync(() => DiscordRecruitmentAdapter.UpsertV2Async(channel, 999, "101", false, + DateTimeOffset.UtcNow, V2("Changed") with { RestoreArchive = true, RestoreLocked = true }, true, () => { }, default)); + channel = (IMessageChannel)(await client.Rest.GetChannelAsync(100))!; + var recovered = await DiscordRecruitmentAdapter.UpsertV2Async(channel, 999, "101", true, DateTimeOffset.UtcNow, + V2("Changed") with { RestoreArchive = true, RestoreLocked = true }, true, () => { }, default); + Assert.False(recovered.NewlySent); Assert.Equal(2, transport.Uploads); Assert.Equal(2, transport.MessageWrites.Count); + Assert.True((bool)transport.Thread["thread_metadata"]!["archived"]!); Assert.True((bool)transport.Thread["thread_metadata"]!["locked"]!); + } + + [Fact] + public async Task V2RejectedMediaWriteFallsBackAndRestoresArchivedLock() + { + var transport = new PublicationTransport { MessageMissing = true }; using var client = await Client(transport); + var channel = (IMessageChannel)(await client.Rest.GetChannelAsync(100))!; + await DiscordRecruitmentAdapter.UpsertV2Async(channel, 999, null, false, DateTimeOffset.UtcNow, V2(), true, () => { }, default); + transport.Thread["thread_metadata"]!["archived"] = true; transport.Thread["thread_metadata"]!["locked"] = true; + transport.RejectUpload = true; + channel = (IMessageChannel)(await client.Rest.GetChannelAsync(100))!; + var delivery = await DiscordRecruitmentAdapter.UpsertV2Async(channel, 999, "101", false, DateTimeOffset.UtcNow, + V2("Changed") with { RestoreArchive = true, RestoreLocked = true }, true, () => { }, default); + Assert.Null(delivery.MediaAttachmentId); Assert.False(delivery.NewlySent); + Assert.True((bool)transport.Thread["thread_metadata"]!["archived"]!); Assert.True((bool)transport.Thread["thread_metadata"]!["locked"]!); + Assert.Empty((JArray)transport.Message["attachments"]!); + Assert.Contains("Acknowledge", transport.Message["components"]!.ToString()); Assert.DoesNotContain("attachment://", transport.Message["components"]!.ToString()); + } + + [Fact] + public async Task V2OwnershipRequiresExactRecursiveMarkerBotChannelAndFlag() + { + var transport = new PublicationTransport { MessageMissing = true }; using var client = await Client(transport); + var adapter = new DiscordRecruitmentAdapter(client, Settings()); + var channel = (IMessageChannel)(await client.Rest.GetChannelAsync(100))!; + await DiscordRecruitmentAdapter.UpsertV2Async(channel, 999, null, false, DateTimeOffset.UtcNow, V2(media: false), true, () => { }, default); + var owned = (IUserMessage)(await channel.GetMessageAsync(101))!; + Assert.True(DiscordRecruitmentAdapter.OwnsMessage(owned, 999, 100, "recruitment:100:public", RecruitmentMessageFormat.ComponentsV2)); + Assert.False(DiscordRecruitmentAdapter.OwnsMessage(owned, 200, 100, "recruitment:100:public", RecruitmentMessageFormat.ComponentsV2)); + Assert.False(DiscordRecruitmentAdapter.OwnsMessage(owned, 999, 200, "recruitment:100:public", RecruitmentMessageFormat.ComponentsV2)); + Assert.False(DiscordRecruitmentAdapter.OwnsMessage(owned, 999, 100, "recruitment:100:pub", RecruitmentMessageFormat.ComponentsV2)); + transport.Message["flags"] = 0; + Assert.False(DiscordRecruitmentAdapter.OwnsMessage((IUserMessage)(await channel.GetMessageAsync(101))!, 999, 100, "recruitment:100:public", RecruitmentMessageFormat.ComponentsV2)); + transport.Message["flags"] = 32768; + await adapter.DeleteAdvisoryAsync("100", "101", default, "recruitment:100:public"); + Assert.Equal(1, transport.Deletes); + } + + [Fact] + public async Task V2CancellationDoesNotWriteAndUnscopedSdkMessagesStayUntouched() + { + var transport = new PublicationTransport { MessageMissing = true }; using var client = await Client(transport); + var channel = (IMessageChannel)(await client.Rest.GetChannelAsync(100))!; + using var cancellation = new CancellationTokenSource(); cancellation.Cancel(); + await Assert.ThrowsAnyAsync(() => DiscordRecruitmentAdapter.UpsertV2Async(channel, 999, null, false, + DateTimeOffset.UtcNow, V2(), true, () => { }, cancellation.Token)); + Assert.Empty(transport.MessageWrites); + await channel.SendMessageAsync("Ordinary content"); + Assert.Equal("Ordinary content", (string?)transport.MessageWrites.Single()["content"]); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task V2RecoversComponentOnlyAttachmentIdentityThroughInstalledSdk(bool cdnUrl) + { + var transport = new PublicationTransport { MessageMissing = true, ComponentOnlyMedia = true, CdnMediaUrl = cdnUrl }; + using var client = await Client(transport); + var channel = (IMessageChannel)(await client.Rest.GetChannelAsync(100))!; + var delivered = await DiscordRecruitmentAdapter.UpsertV2Async(channel, 999, null, false, DateTimeOffset.UtcNow, V2(), true, () => { }, default); + Assert.Equal("901", delivered.MediaAttachmentId); + using var restarted = await Client(transport); + channel = (IMessageChannel)(await restarted.Rest.GetChannelAsync(100))!; + var recovered = await DiscordRecruitmentAdapter.UpsertV2Async(channel, 999, null, true, DateTimeOffset.UtcNow, V2(), true, () => { }, default); + Assert.Equal("901", recovered.MediaAttachmentId); Assert.Single(transport.MessageWrites); Assert.Equal(1, transport.Uploads); + await DiscordRecruitmentAdapter.UpsertV2Async(channel, 999, recovered.Id, false, DateTimeOffset.UtcNow, V2(ack: false), true, () => { }, default); + Assert.Equal(1, transport.Uploads); Assert.Equal("901", (string?)transport.MessageWrites.Last()["attachments"]![0]!["id"]); + } + + [Fact] + public async Task V2DeniedMediaPayloadOmitsAvatarThumbnailToo() + { + using var rig = new RecruitmentRig(); await rig.Start(); var post = await rig.Post(); + post.Facts.AvatarUrl = "https://cdn.discordapp.com/avatars/200/avatar.png"; post.Facts.ServerNickname = "Safe name"; + var message = RecruitmentPresentation.PublicV2(rig.State, post, rig.Settings.Recruitment, rig.Clock.Now); + var transport = new PublicationTransport { MessageMissing = true }; using var client = await Client(transport); + var channel = (IMessageChannel)(await client.Rest.GetChannelAsync(100))!; + await DiscordRecruitmentAdapter.UpsertV2Async(channel, 999, null, false, DateTimeOffset.UtcNow, message, false, () => { }, default); + var payload = Assert.Single(transport.MessageWrites); + Assert.DoesNotContain("cdn.discordapp.com", payload.ToString()); Assert.DoesNotContain("attachment://", payload.ToString()); + Assert.DoesNotContain(payload["components"]!.SelectTokens("$..type"), t => (int)t is 11 or 12); + Assert.Contains("Safe name", payload.ToString()); Assert.Contains("Acknowledge", payload.ToString()); + } + + [Fact] + public async Task V2AuthorNameOnlyEditRetainsUploadedImageAndCannotPing() + { + using var rig = new RecruitmentRig(); await rig.Start(); var post = await rig.Post(); + post.Facts.ServerNickname = "Original name"; + var message = RecruitmentPresentation.PublicV2(rig.State, post, rig.Settings.Recruitment, rig.Clock.Now); + var transport = new PublicationTransport { MessageMissing = true }; using var client = await Client(transport); + var channel = (IMessageChannel)(await client.Rest.GetChannelAsync(100))!; + var sent = await DiscordRecruitmentAdapter.UpsertV2Async(channel, 999, null, false, DateTimeOffset.UtcNow, message, true, () => { }, default); + post.Facts.ServerNickname = "**<@123> New name**"; + var renamed = RecruitmentPresentation.PublicV2(rig.State, post, rig.Settings.Recruitment, rig.Clock.Now); + await DiscordRecruitmentAdapter.UpsertV2Async(channel, 999, sent.Id, false, DateTimeOffset.UtcNow, renamed, true, () => { }, default); + Assert.Equal(1, transport.Uploads); Assert.Equal(2, transport.MessageWrites.Count); + Assert.DoesNotContain("<@123>", transport.MessageWrites.Last().ToString()); Assert.Contains("New name", transport.MessageWrites.Last().ToString()); + Assert.Equal("901", (string?)transport.MessageWrites.Last()["attachments"]![0]!["id"]); + } + + [Fact] + public async Task V2RepairsInterruptedLockRestorationWithoutMessageRewrite() + { + var transport = new PublicationTransport { MessageMissing = true }; using var client = await Client(transport); + var channel = (IMessageChannel)(await client.Rest.GetChannelAsync(100))!; + await DiscordRecruitmentAdapter.UpsertV2Async(channel, 999, null, false, DateTimeOffset.UtcNow, V2(media: false), true, () => { }, default); + transport.Thread["thread_metadata"]!["archived"] = true; transport.Thread["thread_metadata"]!["locked"] = false; + channel = (IMessageChannel)(await client.Rest.GetChannelAsync(100))!; + var saves = 0; + await DiscordRecruitmentAdapter.UpsertV2Async(channel, 999, "101", true, DateTimeOffset.UtcNow, + V2(media: false) with { RestoreArchive = true, RestoreLocked = true }, true, () => saves++, default); + Assert.Equal(1, saves); Assert.Single(transport.MessageWrites); Assert.True((bool)transport.Thread["thread_metadata"]!["locked"]!); + } + + [Fact] + public async Task V2ConfirmedMissingMessageIsRecreatedWithUsableControls() + { + var transport = new PublicationTransport { MessageMissing = true }; using var client = await Client(transport); + var channel = (IMessageChannel)(await client.Rest.GetChannelAsync(100))!; + var sent = await DiscordRecruitmentAdapter.UpsertV2Async(channel, 999, "101", false, DateTimeOffset.UtcNow, V2(media: false), true, () => { }, default); + Assert.True(sent.NewlySent); Assert.Single(transport.MessageWrites); Assert.Contains("Acknowledge", transport.Message.ToString()); + } + private static BotSettings Settings() => new() { GuildId = 1, Recruitment = new RecruitmentSettings { Forums = new RecruitmentForums { PaidRecruitingId = 11, PaidForHireId = 12, HobbyRecruitingId = 13, HobbyForHireId = 14 } }, Roles = new() { Moderator = 50 } }; @@ -161,13 +376,60 @@ private sealed class PublicationTransport : IRestClient public List ChannelWrites { get; } = new(); public List MessageWrites { get; } = new(); public JObject Thread { get; } = JObject.Parse("""{"id":"100","type":11,"guild_id":"1","parent_id":"11","owner_id":"200","name":"Example","flags":32768,"applied_tags":[],"thread_metadata":{"archived":false,"locked":false,"auto_archive_duration":10080,"archive_timestamp":"2026-10-14T12:00:00Z"}}"""); + public bool MessageMissing { get; set; } + public bool LongHistory { get; set; } + public bool LoseEditResponse { get; set; } + public bool LoseCreateResponse { get; set; } + public bool RejectUpload { get; set; } + public bool ComponentOnlyMedia { get; set; } + public bool CdnMediaUrl { get; set; } + public int Deletes { get; private set; } + public int Uploads { get; private set; } + public JObject Message => _message; private readonly JObject _message = JObject.Parse("""{"id":"101","channel_id":"100","type":0,"content":"||recruitment:100:public||","author":{"id":"999","username":"offline","discriminator":"0","bot":true},"timestamp":"2026-10-14T12:00:00Z","embeds":[{"type":"rich","title":"Recruitment","description":"Listing active","fields":[{"name":"Payment","value":"Unknown","inline":false}]}],"attachments":[{"id":"900","filename":"recruitment.png","size":10,"url":"https://cdn.discordapp.com/example.png","proxy_url":"https://cdn.discordapp.com/example.png"}],"components":[]}"""); public void Dispose() { } public void SetHeader(string key, string value) { } public void SetCancelToken(CancellationToken token) { } public Task SendAsync(string method, string endpoint, CancellationToken cancelToken, bool headerOnly, string reason, IEnumerable>> headers) => Respond(method, endpoint, null); public Task SendAsync(string method, string endpoint, string json, CancellationToken cancelToken, bool headerOnly, string reason, IEnumerable>> headers) => Respond(method, endpoint, JObject.Parse(json)); - public Task SendAsync(string method, string endpoint, IReadOnlyDictionary multipart, CancellationToken cancelToken, bool headerOnly, string reason, IEnumerable>> headers) => Respond(method, endpoint, JObject.Parse((string)multipart["payload_json"])); + public Task SendAsync(string method, string endpoint, IReadOnlyDictionary multipart, CancellationToken cancelToken, bool headerOnly, string reason, IEnumerable>> headers) { + if (RejectUpload) return Task.FromResult(new RestResponse(HttpStatusCode.Forbidden, new Dictionary(), + new MemoryStream(Encoding.UTF8.GetBytes("{\"code\":50013,\"message\":\"Missing Permissions\"}")))); + Uploads++; + var patch = JObject.Parse((string)multipart["payload_json"]); + return Respond(method, endpoint, patch); + } + private JObject MessageResponse() + { + var response = (JObject)_message.DeepClone(); + if (!ComponentOnlyMedia) return response; + foreach (var media in response["components"]!.SelectTokens("$..media").OfType()) + { + var fileName = ((string?)media["url"])?.Replace("attachment://", ""); + var attachment = ((JArray)_message["attachments"]!).FirstOrDefault(a => (string?)a["filename"] == fileName); + if (attachment is null) continue; + media["attachment_id"] = attachment["id"]!.DeepClone(); + if (CdnMediaUrl) media["url"] = "https://cdn.discordapp.com/attachments/100/" + (string?)attachment["id"] + "/" + fileName + "?ex=123"; + media["proxy_url"] = "https://media.discordapp.net/attachments/100/" + (string?)attachment["id"] + "/" + fileName; + media["width"] = 1200; media["height"] = 840; media["content_type"] = "image/png"; + } + response["attachments"] = new JArray(); + return response; + } + private void ApplyMessage(JObject patch) + { + MessageWrites.Add((JObject)patch.DeepClone()); + foreach (var property in patch.Properties()) _message[property.Name] = property.Value.DeepClone(); + if (_message["content"] is null or { Type: JTokenType.Null }) _message["content"] = ""; + if (_message["embeds"] is null or { Type: JTokenType.Null }) _message["embeds"] = new JArray(); + foreach (var property in _message.Properties().Where(p => p.Value.Type == JTokenType.Null).ToArray()) property.Remove(); + foreach (var attachment in (JArray)_message["attachments"]!) + { + if ((string?)attachment["id"] == "0") attachment["id"] = (900 + Uploads).ToString(); + attachment["size"] = 3; attachment["url"] = "https://cdn.discordapp.com/" + (string?)attachment["filename"]; + attachment["proxy_url"] = attachment["url"]!.DeepClone(); + } + } private Task Respond(string method, string endpoint, JObject? patch) { string body; @@ -200,10 +462,25 @@ private Task Respond(string method, string endpoint, JObject? patc } else if (endpoint == "channels/100/messages/100") body = new JObject { ["id"] = "100", ["channel_id"] = "100", ["type"] = 0, ["content"] = StarterContent, ["timestamp"] = "2026-10-14T12:00:00Z", ["author"] = new JObject { ["id"] = Thread["owner_id"]!.ToString(), ["username"] = "owner", ["discriminator"] = "0", ["bot"] = (string?)Thread["owner_id"] == "999" || OwnerBot }, ["embeds"] = new JArray(), ["attachments"] = Attachments }.ToString(); + else if (endpoint.StartsWith("channels/100/messages?")) + body = new JArray(LongHistory ? Enumerable.Range(0, 100).Select(_ => _message.DeepClone()) : MessageMissing ? Array.Empty() : new[] { MessageResponse() }).ToString(); + else if (endpoint == "channels/100/messages" && method == "POST") + { + MessageMissing = false; _message["content"] = ""; _message["embeds"] = new JArray(); _message["attachments"] = new JArray(); + ApplyMessage(patch!); + if (LoseCreateResponse) { LoseCreateResponse = false; throw new InvalidOperationException("Lost create response"); } + body = MessageResponse().ToString(); + } else if (endpoint == "channels/100/messages/101") { - if (method == "PATCH") { MessageWrites.Add(patch!); foreach (var property in patch!.Properties()) _message[property.Name] = property.Value.DeepClone(); } - body = _message.ToString(); + if (method == "DELETE") { Deletes++; return Task.FromResult(new RestResponse(HttpStatusCode.NoContent, new Dictionary(), new MemoryStream())); } + if (MessageMissing) return Task.FromResult(new RestResponse(HttpStatusCode.NotFound, new Dictionary(), new MemoryStream(Encoding.UTF8.GetBytes("{\"code\":10008,\"message\":\"Unknown Message\"}")))); + if (method == "PATCH") + { + ApplyMessage(patch!); + if (LoseEditResponse) { LoseEditResponse = false; throw new InvalidOperationException("Lost edit response"); } + } + body = MessageResponse().ToString(); } else throw new InvalidOperationException($"Unexpected offline route: {method} {endpoint}"); return Task.FromResult(new RestResponse(HttpStatusCode.OK, new Dictionary(), new MemoryStream(Encoding.UTF8.GetBytes(body)))); diff --git a/DiscordBot.Tests/Recruitment/RecruitmentRecoveryTests.cs b/DiscordBot.Tests/Recruitment/RecruitmentRecoveryTests.cs index 79504e83..ed551aeb 100644 --- a/DiscordBot.Tests/Recruitment/RecruitmentRecoveryTests.cs +++ b/DiscordBot.Tests/Recruitment/RecruitmentRecoveryTests.cs @@ -73,6 +73,18 @@ public async Task RecreatedPromptGetsFreshUsableWindow() Assert.Empty(rig.Discord.Actions); } + [Fact] + public async Task CosmeticNameRefreshPreservesPendingWindowMessageAndCapacity() + { + using var rig = new RecruitmentRig(); await rig.Start(); var post = await rig.Post(); + var deadline = post.Deadline; var prompt = post.PromptDeliveredAt; var messageId = post.PublicMessageId; + rig.Clock.Advance(TimeSpan.FromMinutes(5)); + rig.Discord.Threads[post.Id] = rig.Discord.Threads[post.Id] with { ServerNickname = "New display name" }; + await rig.Engine.FactsChangedAsync(post.Id, default); + Assert.Equal(deadline, post.Deadline); Assert.Equal(prompt, post.PromptDeliveredAt); Assert.Equal(messageId, post.PublicMessageId); + Assert.Single(rig.State.Listings); Assert.Null(post.AcceptedAt); Assert.Equal(ListingStatus.Open, post.Status); + } + [Fact] public async Task FourthTimeoutDoesNotSendAnotherThresholdAlert() { diff --git a/DiscordBot.Tests/Recruitment/RecruitmentStorageAndSetupTests.cs b/DiscordBot.Tests/Recruitment/RecruitmentStorageAndSetupTests.cs index 72a35c3b..8b07f1ef 100644 --- a/DiscordBot.Tests/Recruitment/RecruitmentStorageAndSetupTests.cs +++ b/DiscordBot.Tests/Recruitment/RecruitmentStorageAndSetupTests.cs @@ -45,6 +45,28 @@ public async Task StoreRoundTripsCooldownAndRefusesSecondWriter() Assert.NotNull(rig.State.Listings["100"].AcceptedAt); } + [Fact] + public async Task CurrentStateRoundTripsV2DeliveryAndArchiveIntentWithHistory() + { + using var rig = new RecruitmentRig(); await rig.Start(); var post = await rig.Post(); await rig.Ack(); + rig.Engine.Dispose(); + var forums = rig.Settings.Recruitment.Forums.Mapping().ToDictionary(x => x.Key, x => x.Value.ToString()); + var fileName = "recruitment-observations-" + new string('a', 64) + ".png"; + using (var writer = new RecruitmentStateStore(rig.Root, "1", forums, rig.Clock.Now)) + { + var pending = writer.State.Listings["100"]; + pending.PublicMediaAttachmentId = "901"; pending.PublicMediaFileName = fileName; + pending.PublicRestoreArchive = true; pending.PublicRestoreLocked = true; pending.PublicSendPending = true; + pending.PublicSendStartedAt = rig.Clock.Now; writer.Save(); + } + using var store = new RecruitmentStateStore(rig.Root, "1", forums, rig.Clock.Now); + Assert.Equal("901", store.State.Listings["100"].PublicMediaAttachmentId); + Assert.Equal(fileName, store.State.Listings["100"].PublicMediaFileName); + Assert.True(store.State.Listings["100"].PublicRestoreLocked); Assert.True(store.State.Listings["100"].PublicSendPending); + Assert.Equal(post.AcceptedAt, store.State.Listings["100"].AcceptedAt); + Assert.Equal(post.CreatedAt, store.State.Authors["200"].LastAcceptedCreation); + } + [Theory] [InlineData("corrupt")] [InlineData("missing")] diff --git a/DiscordBot/Services/Recruitment/DiscordRecruitmentAdapter.cs b/DiscordBot/Services/Recruitment/DiscordRecruitmentAdapter.cs index cb8d63c4..c75a7bfd 100644 --- a/DiscordBot/Services/Recruitment/DiscordRecruitmentAdapter.cs +++ b/DiscordBot/Services/Recruitment/DiscordRecruitmentAdapter.cs @@ -265,12 +265,20 @@ public async Task UpsertAsync(string channelId, string? messageId, boo { var channel = await MessageChannelAsync(channelId, ct); var botUserId = BotUserId; + if (message.Format == RecruitmentMessageFormat.ComponentsV2) + { + var permissions = channel is RestThreadChannel listingThread + ? _client.GetGuild(_settings.GuildId)?.CurrentUser?.GetPermissions(_client.GetGuild(_settings.GuildId)?.GetChannel(listingThread.ParentChannelId)) + : null; + return await UpsertV2Async(channel, botUserId, messageId, recovering, since, message, + channel is not IThreadChannel || permissions is { AttachFiles: true, EmbedLinks: true }, beforeWrite, ct); + } if (message.Embed is not null && message.TextFallback is not null && channel is RestThreadChannel listing && !Guild.CurrentUser.GetPermissions(Guild.GetChannel(listing.ParentChannelId)).EmbedLinks) message = message with { Content = message.TextFallback, Embed = null }; IUserMessage? existing = null; if (messageId is not null) existing = await channel.GetMessageAsync(ulong.Parse(messageId), CacheMode.AllowDownload, Options(ct)) as IUserMessage; - if (existing is not null && (existing.Author.Id != botUserId || !existing.Content.Contains(message.Marker))) + if (existing is not null && (!OwnsMessage(existing, botUserId, channel.Id, message.Marker, message.Format))) throw new InvalidOperationException("Stored recruitment message identity does not match."); if (existing is null && recovering) { @@ -280,7 +288,7 @@ public async Task UpsertAsync(string channelId, string? messageId, boo { var messages = (before.HasValue ? await channel.GetMessagesAsync(before.Value, Direction.Before, 100, CacheMode.AllowDownload, Options(ct)).FlattenAsync() : await channel.GetMessagesAsync(100, CacheMode.AllowDownload, Options(ct)).FlattenAsync()).ToArray(); - existing = messages.OfType().FirstOrDefault(m => m.Author.Id == botUserId && m.Content.Contains(message.Marker)); + existing = messages.OfType().FirstOrDefault(m => OwnsMessage(m, botUserId, channel.Id, message.Marker, message.Format)); if (existing is not null) break; if (messages.Length < 100 || messages.Min(m => m.Timestamp) <= since.AddMinutes(-5)) { covered = true; break; } before = messages.Min(m => m.Id); @@ -379,7 +387,7 @@ private void GuardWrite(RestThreadChannel live, ThreadSnapshot expected, bool pr public async Task DeleteAdvisoryAsync(string threadId, string messageId, CancellationToken ct, string? marker = null) { var channel = await MessageChannelAsync(threadId, ct); - if (await channel.GetMessageAsync(ulong.Parse(messageId), CacheMode.AllowDownload, Options(ct)) is IUserMessage message && message.Author.Id == BotUserId && (marker is null || message.Content.Contains(marker))) + if (await channel.GetMessageAsync(ulong.Parse(messageId), CacheMode.AllowDownload, Options(ct)) is IUserMessage message && marker is not null && OwnsMessage(message, BotUserId, channel.Id, marker, marker.EndsWith(":public", StringComparison.Ordinal) ? RecruitmentMessageFormat.ComponentsV2 : RecruitmentMessageFormat.Standard)) await message.DeleteAsync(Options(ct)); } } diff --git a/DiscordBot/Services/Recruitment/DiscordRecruitmentDelivery.cs b/DiscordBot/Services/Recruitment/DiscordRecruitmentDelivery.cs new file mode 100644 index 00000000..9cb85b74 --- /dev/null +++ b/DiscordBot/Services/Recruitment/DiscordRecruitmentDelivery.cs @@ -0,0 +1,132 @@ +using Discord.Net; +using Newtonsoft.Json.Linq; + +namespace DiscordBot.Services.Recruitment; + +public sealed partial class DiscordRecruitmentAdapter +{ + // One predicate covers stored IDs, interrupted-send discovery and advisory deletion. + internal static bool OwnsMessage(IUserMessage message, ulong botId, ulong channelId, string marker, RecruitmentMessageFormat format) => + message.Author.Id == botId && message.Channel.Id == channelId && + (format == RecruitmentMessageFormat.ComponentsV2 + ? (message.Flags & MessageFlags.ComponentsV2) != 0 && TextDisplays(message.Components).Any(t => t == $"||{marker}||") + : message.Content.Split('\n').Any(t => t == $"||{marker}||" || t == $"-# {marker}")); + + private static IEnumerable TextDisplays(IEnumerable components) + { + foreach (var component in components) + { + if (component is TextDisplayComponent text) yield return text.Content; + if (component is ContainerComponent container) + foreach (var value in TextDisplays(container.Components)) yield return value; + if (component is SectionComponent section) + foreach (var value in TextDisplays(section.Components)) yield return value; + } + } + + // Serialize the complete recursive builder model, dropping only Discord-assigned IDs and + // resolved CDN metadata. Attachment URLs and avatar CDN signatures have a stable identity. + internal static string V2ComponentKey(IEnumerable components) + { + var value = JToken.Parse(Newtonsoft.Json.JsonConvert.SerializeObject(components.ToArray())); + Normalize(value); + return value.ToString(Newtonsoft.Json.Formatting.None); + } + + private static void Normalize(JToken token) + { + if (token is JObject obj) + { + foreach (var property in obj.Properties().ToArray()) + { + var field = property.Value; + if (property.Name is "Id" or "id" or "ProxyUrl" or "proxy_url" or "Height" or "height" or "Width" or "width" or "LoadingState" or "loading_state" or "ContentType" or "content_type") property.Remove(); + else if (field is JValue { Value: string text }) + { + if (text.StartsWith("attachment://", StringComparison.Ordinal)) property.Value = System.IO.Path.GetFileName(text); + else if (Uri.TryCreate(text, UriKind.Absolute, out var uri) && + (uri.Host == "cdn.discordapp.com" || uri.Host == "media.discordapp.net")) + property.Value = uri.AbsolutePath.Contains("/attachments/", StringComparison.Ordinal) + ? System.IO.Path.GetFileName(uri.AbsolutePath) : uri.GetLeftPart(UriPartial.Path); + } + else Normalize(field); + } + } + else if (token is JArray array) foreach (var item in array) Normalize(item); + } + + internal static async Task UpsertV2Async(IMessageChannel channel, ulong botId, string? messageId, + bool recovering, DateTimeOffset since, RecruitmentMessage message, bool mediaAvailable, Action beforeWrite, CancellationToken ct) + { + ct.ThrowIfCancellationRequested(); + using var observation = RecruitmentMessageTransport.Observe(channel.Id); + IUserMessage? existing = messageId is null ? null : await channel.GetMessageAsync(ulong.Parse(messageId), CacheMode.AllowDownload, Options(ct)) as IUserMessage; + if (existing is not null && !OwnsMessage(existing, botId, channel.Id, message.Marker, RecruitmentMessageFormat.ComponentsV2)) + throw new InvalidOperationException("Stored recruitment message identity does not match."); + if (existing is null && recovering) + { + ulong? before = null; + var covered = false; + for (var page = 0; page < 20; page++) + { + var messages = (before.HasValue ? await channel.GetMessagesAsync(before.Value, Direction.Before, 100, CacheMode.AllowDownload, Options(ct)).FlattenAsync() + : await channel.GetMessagesAsync(100, CacheMode.AllowDownload, Options(ct)).FlattenAsync()).ToArray(); + existing = messages.OfType().FirstOrDefault(m => OwnsMessage(m, botId, channel.Id, message.Marker, RecruitmentMessageFormat.ComponentsV2)); + if (existing is not null) break; + if (messages.Length < 100 || messages.Min(m => m.Timestamp) <= since.AddMinutes(-5)) { covered = true; break; } + before = messages.Min(m => m.Id); + } + if (existing is null && !covered) throw new InvalidOperationException("Message recovery history exceeds the scan bound; staff review required before resending."); + } + var media = mediaAvailable ? message.Media : null; + var components = media is null ? message.FallbackComponents ?? message.Components! : message.Components!; + var retained = media is null ? null : existing?.Attachments.SingleOrDefault(a => a.Filename == media.FileName); + var replace = media is not null && (retained is null || existing!.Attachments.Count != 1); + var edit = existing is null || V2ComponentKey(existing.Components) != V2ComponentKey(components.Components) || + replace || media is null && existing.Attachments.Count > 0; + if (edit) beforeWrite(); + var needsRestore = message.RestoreArchive && channel is IThreadChannel observed && + (!observed.IsArchived || observed.IsLocked != message.RestoreLocked); + if (!edit && needsRestore) beforeWrite(); + if (edit && channel is IThreadChannel { IsArchived: true } archived) + { + if (archived.IsLocked && !message.RestoreArchive) throw new InvalidOperationException("A locked listing requires moderator review."); + await archived.ModifyAsync(p => p.Archived = false, Options(ct)); + needsRestore = message.RestoreArchive; + } + var newlySent = existing is null; + using var stream = media is null || !replace && !newlySent ? null : new System.IO.MemoryStream(media.Bytes, writable: false); + try + { + using var publication = RecruitmentMessageTransport.Publish(channel.Id, existing?.Id, retained is null ? Array.Empty() : new[] { retained }); + if (existing is null) + { + existing = media is null + ? await channel.SendMessageAsync(components: components, flags: MessageFlags.ComponentsV2, allowedMentions: AllowedMentions.None, options: Options(ct)) + : await channel.SendFilesAsync(new[] { new FileAttachment(stream!, media.FileName, media.Description) }, components: components, + flags: MessageFlags.ComponentsV2, allowedMentions: AllowedMentions.None, options: Options(ct)); + } + else if (edit) + { + // FileAttachment represents uploads only in 3.20.1. Retain by the actual returned snowflake + // through the narrowly scoped transport correction; never treat upload index 0 as an ID. + await existing.ModifyAsync(p => + { + p.Components = components; p.Flags = MessageFlags.ComponentsV2; p.AllowedMentions = AllowedMentions.None; + if (replace) p.Attachments = new[] { new FileAttachment(stream!, media!.FileName, media.Description) }; + }, Options(ct)); + } + } + catch (HttpException ex) when (media is not null && ex.HttpCode is System.Net.HttpStatusCode.Forbidden or System.Net.HttpStatusCode.BadRequest) + { + // A definite rejected media write is safe to retry as text. Ambiguous responses and + // cancellation propagate with the saved publication/restoration intent untouched. + return await UpsertV2Async(channel, botId, existing?.Id.ToString(), recovering, since, + message with { Media = null, Components = message.FallbackComponents }, false, beforeWrite, ct); + } + if (message.RestoreArchive && (needsRestore || edit) && channel is IThreadChannel restore) + await restore.ModifyAsync(p => { p.Archived = true; p.Locked = message.RestoreLocked; }, Options(ct)); + var attachment = media is null ? null : existing.Attachments.SingleOrDefault(a => a.Filename == media.FileName); + return new Delivery(existing.Id.ToString(), newlySent, attachment?.Id.ToString(), attachment?.Filename); + } +} diff --git a/DiscordBot/Services/Recruitment/IRecruitmentDiscord.cs b/DiscordBot/Services/Recruitment/IRecruitmentDiscord.cs index c777cb90..8f42c2fe 100644 --- a/DiscordBot/Services/Recruitment/IRecruitmentDiscord.cs +++ b/DiscordBot/Services/Recruitment/IRecruitmentDiscord.cs @@ -13,12 +13,12 @@ public sealed record ThreadInventory(IReadOnlyList Threads, bool public enum RecruitmentMessageFormat { Standard, ComponentsV2 } public sealed record RecruitmentMedia(string FileName, byte[] Bytes, string Description); public sealed record RecruitmentMessage(string Content, string Marker, bool OwnerControls, bool RestoreArchive = false, Embed? Embed = null, string? TextFallback = null, bool AcknowledgementRequired = false, bool ReaderControls = false, RecruitmentMessageFormat Format = RecruitmentMessageFormat.Standard, - string? RenderIdentity = null, MessageComponent? Components = null, MessageComponent? FallbackComponents = null, RecruitmentMedia? Media = null) + string? RenderIdentity = null, MessageComponent? Components = null, MessageComponent? FallbackComponents = null, RecruitmentMedia? Media = null, bool RestoreLocked = false) { public string RenderKey => RenderIdentity ?? Content + "\n" + OwnerControls + "\n" + AcknowledgementRequired + "\n" + ReaderControls + "\n" + EmbedKey(Embed); public static string EmbedKey(IEmbed? embed) => embed is null ? "" : Newtonsoft.Json.JsonConvert.SerializeObject(embed.ToEmbedBuilder().Build()); } -public sealed record Delivery(string Id, bool NewlySent); +public sealed record Delivery(string Id, bool NewlySent, string? MediaAttachmentId = null, string? MediaFileName = null); /// Null thread means confirmed Discord Unknown Channel, never a cache miss or permission failure. public interface IRecruitmentDiscord diff --git a/DiscordBot/Services/Recruitment/RecruitmentEngine.cs b/DiscordBot/Services/Recruitment/RecruitmentEngine.cs index 283431b5..adb258ea 100644 --- a/DiscordBot/Services/Recruitment/RecruitmentEngine.cs +++ b/DiscordBot/Services/Recruitment/RecruitmentEngine.cs @@ -233,7 +233,11 @@ private async Task ProcessAsync(Listing post, CancellationToken ct) post.Title = RecruitmentPresentation.Safe(live.Title); RefreshFacts(post, live); post.Facts.TagAdvice = _assets!.TagAdvice(Purpose(post.ForumId), live.Tags, State!.Forums[Purpose(post.ForumId)]); - if (!post.PublicSendPending) post.PublicRestoreArchive = live.Archived && post.AcceptedAt.HasValue; + if (!post.PublicSendPending) + { + post.PublicRestoreArchive = live.Archived && post.AcceptedAt.HasValue; + post.PublicRestoreLocked = post.PublicRestoreArchive && live.Locked; + } post.Hold = null; if (live.Starter is null && !post.PromptDeliveredAt.HasValue && !post.AcceptedAt.HasValue) { @@ -356,18 +360,20 @@ private async Task PublishAsync(Listing post, CancellationToken ct) await PublishStaffAsync(post, ct); if (post.Status is ListingStatus.Deleted or ListingStatus.Reference) { post.PublicationPending = false; post.PublicSendPending = false; Save(); return; } - var message = RecruitmentPresentation.Public(State!, post, _settings, Now); + var message = RecruitmentPresentation.PublicV2(State!, post, _settings, Now); var renderKey = message.RenderKey + "\nPortfolio registry " + State!.PortfolioSites.Version; if (!post.PublicSendPending && !post.PublicationPending && post.PublicMessageId is not null && _publicRenders.GetValueOrDefault(post.Id) == renderKey) return; var recovering = post.PublicSendPending; var delivery = await _discord.UpsertAsync(post.Id, post.PublicMessageId, recovering, post.PublicSendStartedAt ?? post.CreatedAt, message with - { RestoreArchive = post.PublicRestoreArchive || post.Status == ListingStatus.Closed }, () => + { RestoreArchive = post.PublicRestoreArchive || post.Status == ListingStatus.Closed, RestoreLocked = post.PublicRestoreLocked || post.Status == ListingStatus.Closed }, () => { if (!recovering) post.PublicSendStartedAt = Now; post.PublicSendPending = true; Save(); }, ct); - var changed = post.PublicMessageId != delivery.Id || post.PublicSendPending || post.PublicationPending; + var changed = post.PublicMessageId != delivery.Id || post.PublicSendPending || post.PublicationPending || + post.PublicMediaAttachmentId != delivery.MediaAttachmentId || post.PublicMediaFileName != delivery.MediaFileName; + post.PublicMediaAttachmentId = delivery.MediaAttachmentId; post.PublicMediaFileName = delivery.MediaFileName; post.PublicMessageId = delivery.Id; post.PublicSendPending = false; post.PublicationPending = false; _publicRenders[post.Id] = renderKey; if (delivery.NewlySent && post.Deadline.HasValue && !post.AcceptedAt.HasValue) @@ -485,11 +491,11 @@ private async Task ExecuteActionAsync(Listing post, CancellationToken ct) { // Resolve a possibly delivered provisional advisory before removing it. var recovered = await _discord.UpsertAsync(post.Id, null, true, post.PublicSendStartedAt ?? post.CreatedAt, - RecruitmentPresentation.Public(State!, post, _settings, Now), Save, ct); + RecruitmentPresentation.PublicV2(State!, post, _settings, Now), Save, ct); post.PublicMessageId = recovered.Id; post.PublicSendPending = false; Save(); } - if (post.PublicMessageId is not null) await _discord.DeleteAdvisoryAsync(post.Id, post.PublicMessageId, ct); - post.PublicMessageId = null; post.PendingAction = null; Save(); return; + if (post.PublicMessageId is not null) await _discord.DeleteAdvisoryAsync(post.Id, post.PublicMessageId, ct, $"recruitment:{post.Id}:public"); + post.PublicMessageId = null; post.PublicMediaAttachmentId = null; post.PublicMediaFileName = null; post.PendingAction = null; Save(); return; } if (action.CallStarted && action.Kind == RecruitmentActionKind.Close && live.Archived && live.Locked) { diff --git a/DiscordBot/Services/Recruitment/RecruitmentForumTransport.cs b/DiscordBot/Services/Recruitment/RecruitmentForumTransport.cs index 36f99a4b..a27249f0 100644 --- a/DiscordBot/Services/Recruitment/RecruitmentForumTransport.cs +++ b/DiscordBot/Services/Recruitment/RecruitmentForumTransport.cs @@ -51,7 +51,8 @@ public async Task SendAsync(string method, string endpoint, Cancel var response = await RecruitmentRequests.SendAsync(method, endpoint, cancelToken, () => inner.SendAsync(method, endpoint, cancelToken, headerOnly, reason, requestHeaders)); var scope = Inventory.Value; - if (scope is null || method != "GET" || endpoint != $"guilds/{scope.GuildId}/threads/active" || response.StatusCode != HttpStatusCode.OK) return response; + if (scope is null || method != "GET" || endpoint != $"guilds/{scope.GuildId}/threads/active" || response.StatusCode != HttpStatusCode.OK) + return await RecruitmentMessageTransport.ObserveResponseAsync(endpoint, response, cancelToken); using var stream = response.Stream; using var reader = new StreamReader(stream); var body = await reader.ReadToEndAsync(cancelToken); @@ -69,14 +70,22 @@ public async Task SendAsync(string method, string endpoint, Cancel return new RestResponse(response.StatusCode, response.Headers, new MemoryStream(Encoding.UTF8.GetBytes(body))); } - public Task SendAsync(string method, string endpoint, IReadOnlyDictionary multipart, + public async Task SendAsync(string method, string endpoint, IReadOnlyDictionary multipart, CancellationToken cancelToken, bool headerOnly, string reason, - IEnumerable>> requestHeaders) => - RecruitmentRequests.SendAsync(method, endpoint, cancelToken, () => inner.SendAsync(method, endpoint, multipart, cancelToken, headerOnly, reason, requestHeaders)); + IEnumerable>> requestHeaders) + { + var corrected = new Dictionary(multipart); + if (corrected.TryGetValue("payload_json", out var payload) && payload is string json) + corrected["payload_json"] = RecruitmentMessageTransport.Apply(method, endpoint, json, multipart: true); + var response = await RecruitmentRequests.SendAsync(method, endpoint, cancelToken, + () => inner.SendAsync(method, endpoint, corrected, cancelToken, headerOnly, reason, requestHeaders)); + return await RecruitmentMessageTransport.ObserveResponseAsync(endpoint, response, cancelToken); + } - public Task SendAsync(string method, string endpoint, string json, CancellationToken cancelToken, + public async Task SendAsync(string method, string endpoint, string json, CancellationToken cancelToken, bool headerOnly, string reason, IEnumerable>> requestHeaders) { + json = RecruitmentMessageTransport.Apply(method, endpoint, json); if (reason == TagWriteReason) { // 3.20.1 ForumHelper drops IsModerated. Fail closed if a future SDK stops carrying @@ -96,6 +105,7 @@ public Task SendAsync(string method, string endpoint, string json, } json = body.ToString(Formatting.None); } - return RecruitmentRequests.SendAsync(method, endpoint, cancelToken, () => inner.SendAsync(method, endpoint, json, cancelToken, headerOnly, reason, requestHeaders)); + var response = await RecruitmentRequests.SendAsync(method, endpoint, cancelToken, () => inner.SendAsync(method, endpoint, json, cancelToken, headerOnly, reason, requestHeaders)); + return await RecruitmentMessageTransport.ObserveResponseAsync(endpoint, response, cancelToken); } } diff --git a/DiscordBot/Services/Recruitment/RecruitmentMessageTransport.cs b/DiscordBot/Services/Recruitment/RecruitmentMessageTransport.cs new file mode 100644 index 00000000..6b30298d --- /dev/null +++ b/DiscordBot/Services/Recruitment/RecruitmentMessageTransport.cs @@ -0,0 +1,86 @@ +using Newtonsoft.Json.Linq; +using Discord.Net.Rest; +using System.IO; +using System.Net; +using System.Text; + +namespace DiscordBot.Services.Recruitment; + +/// Corrects only explicitly scoped recruitment V2 writes through the SDK queue. +internal static class RecruitmentMessageTransport +{ + private sealed record Publication(string Endpoint, string Method, IReadOnlyList Retained); + private static readonly AsyncLocal Current = new(); + private static readonly AsyncLocal ObservedChannel = new(); + internal static IDisposable Observe(ulong channelId) + { + var previous = ObservedChannel.Value; ObservedChannel.Value = channelId; + return new Scope(() => ObservedChannel.Value = previous); + } + + // Current Discord may expose uploaded component media only as media.attachment_id; + // 3.20.1 drops that response field. Preserve it as SDK attachment metadata before parsing. + internal static async Task ObserveResponseAsync(string endpoint, RestResponse response, CancellationToken ct) + { + if (ObservedChannel.Value is not { } channelId || response.StatusCode != HttpStatusCode.OK || + !(endpoint == $"channels/{channelId}/messages" || endpoint.StartsWith($"channels/{channelId}/messages/", StringComparison.Ordinal) || + endpoint.StartsWith($"channels/{channelId}/messages?", StringComparison.Ordinal))) return response; + using var stream = response.Stream; + using var reader = new StreamReader(stream); + var body = JToken.Parse(await reader.ReadToEndAsync(ct)); + foreach (var message in body is JArray messages ? messages.OfType() : body is JObject single ? new[] { single } : Array.Empty()) + { + if (((int?)message["flags"] & (int)MessageFlags.ComponentsV2) != (int)MessageFlags.ComponentsV2 || (string?)message["channel_id"] != channelId.ToString()) continue; + if (message["attachments"] is not JArray) message["attachments"] = new JArray(); + var attachments = (JArray)message["attachments"]!; + foreach (var media in message["components"]?.SelectTokens("$..media").OfType() ?? Enumerable.Empty()) + { + var url = (string?)media["url"]; + if (url is null) continue; + var filename = url.StartsWith("attachment://", StringComparison.Ordinal) ? url[13..] : null; + var id = (string?)media["attachment_id"]; + if (Uri.TryCreate(url, UriKind.Absolute, out var uri) && uri.Host is "cdn.discordapp.com" or "media.discordapp.net") + { + var parts = uri.AbsolutePath.Split('/'); + if (parts.Length == 5 && parts[1] == "attachments" && parts[2] == channelId.ToString()) + { filename = Uri.UnescapeDataString(parts[4]); id ??= parts[3]; } + } + if (!ulong.TryParse(id, out var attachmentId) || attachmentId == 0 || filename is null || filename.Length > 120 || + filename.IndexOfAny(new[] { '/', '\\' }) >= 0 || attachments.Any(a => (string?)a["id"] == id)) continue; + attachments.Add(new JObject { ["id"] = id, ["filename"] = filename, ["size"] = 0, ["url"] = url, + ["proxy_url"] = (string?)media["proxy_url"] ?? url, ["content_type"] = (string?)media["content_type"] }); + } + } + return new RestResponse(response.StatusCode, response.Headers, new MemoryStream(Encoding.UTF8.GetBytes(body.ToString(Newtonsoft.Json.Formatting.None)))); + } + internal static IDisposable Publish(ulong channelId, ulong? messageId, IReadOnlyList retained) + { + var previous = Current.Value; + Current.Value = new(messageId.HasValue ? $"channels/{channelId}/messages/{messageId}" : $"channels/{channelId}/messages", + messageId.HasValue ? "PATCH" : "POST", retained); + return new Scope(() => Current.Value = previous); + } + private sealed class Scope(Action restore) : IDisposable { public void Dispose() => restore(); } + internal static string Apply(string method, string endpoint, string json, bool multipart = false) + { + if (Current.Value is not { } scope || method != scope.Method || endpoint != scope.Endpoint) return json; + var body = JObject.Parse(json); + if ((int?)body["flags"] is not { } flags || (flags & (int)MessageFlags.ComponentsV2) == 0) + throw new InvalidOperationException("Recruitment V2 publication flag is missing."); + foreach (var name in new[] { "content", "embeds", "poll", "sticker_ids" }) + { + if (body[name] is not { } field) continue; + if (field.Type != JTokenType.Null && field.ToString().Length != 0 && (field is not JArray array || array.Count != 0)) + throw new InvalidOperationException("Recruitment V2 payload has incompatible message fields."); + body.Remove(name); + } + // In 3.20.1 FileAttachment always means upload. The JSON edit path otherwise omits + // attachments entirely; send the exact returned IDs so removals/retention are explicit. + if (method == "PATCH" && !multipart) + { + if (body["attachments"] is not null) throw new InvalidOperationException("Unexpected attachment retention payload."); + body["attachments"] = new JArray(scope.Retained.Select(a => new JObject { ["id"] = a.Id.ToString(), ["filename"] = a.Filename })); + } + return body.ToString(Newtonsoft.Json.Formatting.None); + } +} diff --git a/DiscordBot/Services/Recruitment/RecruitmentPresentation.cs b/DiscordBot/Services/Recruitment/RecruitmentPresentation.cs index 1038955a..faf9e5e3 100644 --- a/DiscordBot/Services/Recruitment/RecruitmentPresentation.cs +++ b/DiscordBot/Services/Recruitment/RecruitmentPresentation.cs @@ -9,36 +9,9 @@ public static class RecruitmentPresentation public static string Safe(string value, int max = 180) => new string(value.Take(max) .Select(c => char.IsControl(c) || "`*_~|<>@[]\\".Contains(c) ? ' ' : c).ToArray()); - public static RecruitmentMessage Public(RecruitmentState state, Listing post, RecruitmentSettings settings, DateTimeOffset now) - { - var lines = new List(); - lines.Add(string.Join("\n", post.Facts.Checks.Select(c => $"**{c.Label}:** {Safe(c.Value, 60)} ({c.Applicability switch { ObservationApplicability.Unavailable => "unavailable", ObservationApplicability.NotApplicable => "not applicable", _ => c.Severity.ToString().ToLowerInvariant() }})"))); - var notes = post.Facts.Observations.Select(o => $"• {o.Description}: “{Safe(o.Evidence, 70)}”.").ToList(); - if (notes.Count > 0) lines.Add("\n**Things to check**\n" + string.Join("\n", notes)); - lines.Add("\nClarify scope, work samples and payment terms here before moving to DMs."); - var identity = post.Facts.ServerNickname ?? post.Facts.GlobalName ?? post.Facts.Username ?? "Unavailable"; - lines.Add($"Author: {Safe(identity, 100)} · {Safe(post.Facts.Username ?? "username unavailable", 100)} · {post.OwnerId}"); - var needsAck = post.Status == ListingStatus.Open && !post.AcceptedAt.HasValue && !post.AcknowledgedAt.HasValue; - if (needsAck) - lines.Add("\n**Author:** Read this forum's Guidelines and enter its code with **Acknowledge**." + - (post.Deadline is { } deadline ? $" Due {Timestamp(deadline)}." : " Your window starts when these controls are available.")); - if (post.Status == ListingStatus.Closed && !post.AcceptedAt.HasValue) lines.Add("\n**Closed:** " + Safe(post.LastOutcome, 220)); - if (post.Hold is not null || post.AvailabilityUnknown) lines.Add("\nBot processing is paused; listing details may be incomplete."); - var embed = new EmbedBuilder().WithTitle(post.Status == ListingStatus.Closed ? "Recruitment · closed" : "Recruitment · reader notes") - .WithDescription(string.Join("\n", lines)).WithColor(new Color(0x747F8D)) - .WithFooter("Automated observations; identity and work are unverified.") - .WithThumbnailUrl(post.Facts.AvatarUrl).Build(); - var marker = $"recruitment:{post.Id}:public"; - var fallback = $"**{embed.Title}**\n{embed.Description}\n{embed.Footer?.Text}\n||{marker}||"; - return new RecruitmentMessage($"||{marker}||", marker, true, Embed: embed, TextFallback: fallback, - AcknowledgementRequired: needsAck, ReaderControls: true); - } - public const string Heading = "Recruitment · Automated Observations"; public const string Guidance = "Automated observations; neither endorsement nor judgment. Verify claims. Keep first contact and key terms here; use DMs only for private information."; - // Final public model. T05 switches Engine.PublishAsync/ExecuteActionAsync to this method - // and deletes the interim embed Public method above. public static RecruitmentMessage PublicV2(RecruitmentState state, Listing post, RecruitmentSettings settings, DateTimeOffset now, Func, byte[]>? render = null) { @@ -59,15 +32,16 @@ public static RecruitmentMessage PublicV2(RecruitmentState state, Listing post, var marker = $"recruitment:{post.Id}:public"; var visible = $"## {Heading}\n*{Guidance}*\n"; var foot = string.Join("\n", operations.Append(warnings).Where(v => v.Length > 0)); + string? fileName = null; MessageComponent Build(bool image) { var children = new List { new TextDisplayBuilder($"## {Heading}") }; - if (image) children.Add(new MediaGalleryBuilder().AddItem("attachment://recruitment-observations.png", RecruitmentBanner.GalleryAlt(checks))); + if (image) children.Add(new MediaGalleryBuilder().AddItem("attachment://" + fileName, RecruitmentBanner.GalleryAlt(checks))); else children.Add(new TextDisplayBuilder(string.Join("\n", checks.Select(c => $"**{c.Label}:** {Safe(c.Value, 60)} ({RecruitmentBanner.Status(c)})")))); children.Add(new TextDisplayBuilder($"*{Guidance}*")); if (foot.Length > 0) children.Add(new TextDisplayBuilder(foot)); - if (avatar is not null) children.Add(new SectionBuilder(new ThumbnailBuilder(new UnfurledMediaItemProperties(avatar), "Author avatar"), new[] { new TextDisplayBuilder(identity) })); - else children.Add(new TextDisplayBuilder(identity + "\nAvatar unavailable.")); + if (image && avatar is not null) children.Add(new SectionBuilder(new ThumbnailBuilder(new UnfurledMediaItemProperties(avatar), "Author avatar"), new[] { new TextDisplayBuilder(identity) })); + else children.Add(new TextDisplayBuilder(identity + (avatar is null ? "\nAvatar unavailable." : "\nAvatar image omitted; see Profile Image observation."))); var row = new ActionRowBuilder().WithButton("Legend", $"recruit:details:{post.Id}", ButtonStyle.Secondary) .WithButton("Report concern", $"recruit:report:{post.Id}", ButtonStyle.Secondary) .WithButton("Your listing", $"recruit:owner:{post.Id}", ButtonStyle.Secondary); @@ -77,12 +51,17 @@ MessageComponent Build(bool image) return new ComponentBuilderV2(new[] { new ContainerBuilder(children) }).Build(); } RecruitmentMedia? media = null; - try { media = new("recruitment-observations.png", (render ?? (c => RecruitmentBanner.Render(c)))(checks), alt); } + try + { + var bytes = (render ?? (c => RecruitmentBanner.Render(c)))(checks); + fileName = "recruitment-observations-" + Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(bytes)).ToLowerInvariant() + ".png"; + media = new(fileName, bytes, alt); + } catch (Exception ex) when (ex is ImageMagick.MagickException or IOException or ArgumentException or InvalidOperationException) { /* Text remains complete and operable. */ } // Deliberately excludes CDN signatures and SDK-generated component IDs. var avatarKey = avatar is null ? "" : new Uri(avatar).GetLeftPart(UriPartial.Path); var key = RecruitmentBanner.Identity(RecruitmentBanner.Version + "\n" + state.PortfolioSites.Version + "\n" + - alt + "\n" + visible + foot + identity + avatarKey + "\n" + needsAck + "\n" + marker + "\n" + (media is not null)); + alt + "\n" + visible + foot + identity + avatarKey + "\n" + needsAck + "\n" + marker + "\n" + fileName); var fallback = Build(false); return new RecruitmentMessage("", marker, true, AcknowledgementRequired: needsAck, ReaderControls: true, Format: RecruitmentMessageFormat.ComponentsV2, RenderIdentity: key, Components: media is null ? fallback : Build(true), diff --git a/DiscordBot/Services/Recruitment/RecruitmentState.cs b/DiscordBot/Services/Recruitment/RecruitmentState.cs index cbd9d129..284eddc1 100644 --- a/DiscordBot/Services/Recruitment/RecruitmentState.cs +++ b/DiscordBot/Services/Recruitment/RecruitmentState.cs @@ -105,6 +105,9 @@ public sealed class Listing public DateTimeOffset? StaffSendStartedAt { get; set; } public bool PublicationPending { get; set; } public bool PublicRestoreArchive { get; set; } + public bool PublicRestoreLocked { get; set; } + public string? PublicMediaAttachmentId { get; set; } + public string? PublicMediaFileName { get; set; } public int TimeoutAlertCount { get; set; } public bool PublicSendPending { get; set; } public bool StaffSendPending { get; set; } diff --git a/DiscordBot/Services/Recruitment/RecruitmentStateStore.cs b/DiscordBot/Services/Recruitment/RecruitmentStateStore.cs index 74577ec1..b989e34f 100644 --- a/DiscordBot/Services/Recruitment/RecruitmentStateStore.cs +++ b/DiscordBot/Services/Recruitment/RecruitmentStateStore.cs @@ -125,6 +125,10 @@ state.Forums is null || state.Listings is null || state.Authors is null || state throw new InvalidDataException("Recruitment forum receipt is invalid."); foreach (var (id, p) in state.Listings) if (p is null || id != p.Id || !Id(id) || !Id(p.OwnerId) || !forums.Values.Contains(p.ForumId) || + p.PublicMediaAttachmentId is not null && !Id(p.PublicMediaAttachmentId) || + (p.PublicMediaAttachmentId is null) != (p.PublicMediaFileName is null) || + p.PublicMediaFileName is { } fileName && (fileName.Length > 120 || !fileName.StartsWith("recruitment-observations-", StringComparison.Ordinal) || !fileName.EndsWith(".png", StringComparison.Ordinal)) || + p.PublicRestoreLocked && !p.PublicRestoreArchive || p.PortfolioRegistryVersion < 0 || p.PortfolioRegistryVersion > registry.Version || p.CreatedAt == default || p.CreatedAt.Offset != TimeSpan.Zero || p.UpdatedAt == default || !Enum.IsDefined(p.Status) || p.ValidCodes is null || p.Facts is null || p.Audit is null || diff --git a/docs/recruitment.md b/docs/recruitment.md index e2dd7c18..928100ad 100644 --- a/docs/recruitment.md +++ b/docs/recruitment.md @@ -6,10 +6,11 @@ Recruitment is disabled in the example, development and production settings. It - Each member has **one listing total** across paid recruiting, paid for hire, hobby recruiting and hobby for hire. An older pending attempt reserves the slot; changing forums does not bypass it. - Posts by bots, configured moderators and administrators are excluded from member recruitment. Previously managed staff posts become references and lose their bot advisory; accepted history is preserved. -- The bot maintains one neutral reader card: account age and time in the server at posting, plus relevant content observations. Every listing uses the same colour; there is no score, clearance or endorsement. **Details** privately shows dates, role count, the effective global/server avatar, payment evidence and work-sample information. Missing data is distinct from an adverse observation. -- Amounts need compensation context; unrelated prices and applicant fees do not establish pay. Mixed paid/unpaid/revenue-share terms remain visible. For-hire samples can be labelled work links or attached image/video/audio files; negated claims and arbitrary contact links do not clear that check. Detection never fetches URLs, verifies authorship or executes attachments. -- Advisory observations cover DM-only terms, applicant fees and urgent payment requests, with short source excerpts. A normal “DM to apply” contact method does not trigger this check. English keyword heuristics can miss context or other languages; these observations never remove a post. Starter/attachment edits and role/avatar changes refresh the same card. Clarify scope, work samples and payment publicly before moving to DMs. -- Unavailable facts remain explicit. Community activity is no longer queried or displayed. Discord's documented [member](https://docs.discord.com/developers/resources/guild#guild-member-object) and [user](https://docs.discord.com/developers/resources/user#user-object) structures do not expose server-profile privacy, so no privacy flag is inferred. Embeds have a text fallback without Embed Links; updating an old advisory removes its obsolete banner attachment. +- The bot maintains one **Recruitment · Automated Observations** response with nine factual checks: Account Age, Join Date, Server Roles, Compensation, Duration, Portfolio, DM Requests, Profile Image and Post Detail. Green describes an observed fact, yellow requests clarification, red highlights missing expected detail or a concern, and grey means Unavailable or Not Applicable. No color establishes trust, authorship or endorsement. **Legend** privately explains all nine checks and their current evidence. +- The response uses Discord Components V2: a deterministic 3×3 image, short guidance, operational notices and the author's observed nickname/display name, username, immutable ID and effective avatar. Names and excerpts are sanitized and every send/edit disables mentions. The image excludes names and never fetches avatars or external links. A complete text-only V2 fallback keeps the same instructions and controls if rendering, Attach Files or Embed Links is unavailable, or Discord rejects the media write. +- Amounts need compensation context; unrelated prices and applicant fees do not establish pay. Mixed paid/unpaid/revenue-share terms remain visible. For-hire samples can be recognized work links or attached image/video/audio files; attached samples without a link request clarification in paid for-hire. Negated claims and arbitrary contact links do not establish samples. Detection never fetches URLs, verifies authorship or executes attachments. +- Short evidence-bearing warnings cover applicant fees, payment pressure and private terms. Affirmative DM-first instructions, including “DM to apply”, request public first contact; negation, safety advice and private-information exceptions are handled separately. English keyword heuristics can miss context or other languages; observations never remove a post. Starter/attachment edits and role/avatar/name changes refresh the same response. Clarify scope, samples and payment publicly before moving to DMs. +- Missing facts remain explicit. Community activity is not queried or displayed. Discord's documented [member](https://docs.discord.com/developers/resources/guild#guild-member-object) and [user](https://docs.discord.com/developers/resources/user#user-object) structures do not expose server-profile privacy, so no privacy flag is inferred. - The owner enters the five-character code from the forum's native Guidelines. The 30-minute window starts after usable controls are delivered. A correct code accepts an eligible listing immediately. Valid submissions received within the window remain timely even if the worker processes them later. - At the deadline, an unacknowledged attempt is removed. Known duplicate, cooldown and posting-pause conflicts are locked and archived promptly without asking for a code. Closed attempts do not automatically reopen when restrictions expire. Pins and explicit exemptions protect against automatic actions. - Only **accepted** listings start a cooldown, measured from the thread's original creation time. Closing/deleting on day 2 leaves 28 days; doing so on day 40 adds no wait. An open listing still occupies the slot after day 30, including when Discord naturally archives it. Removing an unaccepted attempt creates no accepted-listing cooldown, but still counts toward the attempt budget. @@ -43,7 +44,7 @@ Set these fields under `Recruitment` in the existing settings file: Replace zero IDs with the new channels. Do not reuse the old `Channels.Recruitment` or manually configure tag IDs. All four IDs must be distinct forum channels in `GuildId`. `Roles.Moderator` must resolve to the intended role. -The bot needs View Channel, Read Message History, Send Messages in Threads, Manage Threads and Manage Channels on each forum. Creating reference posts also requires Send Messages. Embed Links enables public advisory embeds; no Attach Files permission is needed. In the feed it needs View Channel, Read Message History and Send Messages. Embed Links enables the status overview embed; without it, the overview uses concise text. Deny View Channel to `@everyone`; explicit access grants are limited to the configured moderator role, administrators and the bot. Existing Guild Members and Message Content intents must also be enabled in the Discord application. +The bot needs View Channel, Read Message History, Send Messages in Threads, Manage Threads and Manage Channels on each forum. Creating reference posts also requires Send Messages. Attach Files and Embed Links enable the optional observations image and media; missing either permission keeps the text-only V2 response usable. In the feed it needs View Channel, Read Message History and Send Messages. Embed Links enables the status overview embed; without it, the overview uses concise text. Deny View Channel to `@everyone`; explicit access grants are limited to the configured moderator role, administrators and the bot. Existing Guild Members and Message Content intents must also be enabled in the Discord application. Review `DiscordBot/Assets/recruitment/guidelines/*.md` and `tags.json` before enabling. Templates own the **entire native Guidelines** and contain one `{{code}}` plus the duration placeholders. Rendered Guidelines must fit the 1,000-character budget, including the code and configured durations. They point members to the pinned moderator/admin post at the top of the forum for fuller guidance; moderators maintain that post separately. Each tag definition has a stable key, display name, advisory group and closing flag. Keep keys stable when renaming a tag. The one closing tag is unmoderated. The shipped copy/tag groups are editable defaults pending moderator approval. @@ -124,16 +125,18 @@ Reuse evidence retains at most eight canonical specific candidates per observed State is stored in `{ServerRootPath}/recruitment/`: -- `recruitment-state.json`: schema `udc-recruitment-v2`, string IDs, UTC dates, per-forum setup/reference receipts and cursors, listing records, author aggregates, retry holds and overview delivery receipts. +- `recruitment-state.json`: schema `udc-recruitment-v2`, string IDs, UTC dates, per-forum setup/reference receipts and cursors, listing records, author aggregates, retry holds and overview delivery receipts, and V2 media attachment IDs/filenames plus archive/lock restoration intent. - `recruitment-state.json.bak`: last validated snapshot before the latest replacement. - `initialized`: matching store identity, used to detect a missing primary. - `writer.lock`: exclusive process lease. The file may remain after shutdown; its existence does not itself mean the lease is held. Writes are serialized, flushed and replaced atomically. A write failure fences subsequent mutations until storage is repaired and the process restarted. Corrupt, unsupported, incomplete, missing or mismatched history is never silently replaced with empty cooldowns. Back up the directory and its identity receipt together. A completely lost volume cannot be distinguished locally from a new volume; existing nonempty forum setup holds first-use initialization. Restore history before repairing setup after a suspected volume loss. -For storage recovery, stop the bot, preserve the damaged files, restore a validated primary (or review the `.bak`) with its matching `initialized` receipt, then restart and reconcile. A backup may precede the latest acceptance/action; inspect the staff feed and Discord before restoring it. Do not change forum/guild mappings, delete the identity receipt, or clear author history to bypass a fault. No converter for the old babysitter format is provided. Existing `udc-recruitment-v2` files load with defaults for the new optional operational fields and preserve listing/cooldown history. Once this version saves those fields, older binaries with strict JSON loading can reject the file. Before upgrading, stop the process and preserve a complete copy of the state directory. A rollback requires reviewing/restoring that compatible pre-upgrade snapshot and reconciling any actions since it was taken; the rolling `.bak` is not a permanent pre-upgrade backup. +For storage recovery, stop the bot, preserve the damaged files, restore a validated primary (or review the `.bak`) with its matching `initialized` receipt, then restart and reconcile. A backup may precede the latest acceptance/action; inspect the staff feed and Discord before restoring it. Do not change forum/guild mappings, delete the identity receipt, or clear author history to bypass a fault. The current unreleased bot writes its final state directly; no state migration or old-card conversion is provided. -The worker saves intent and staff evidence before destructive calls, then rechecks current identity and protection. Interrupted message sends recover bot-owned messages by marker before retrying. Recovery scans are bounded; a scan that cannot establish completeness holds instead of duplicating a message. An interrupted delete that finds an absent thread records an unknown actor. Archived-message updates save a restoration intent, temporarily unarchive when necessary, and restore archiving after updating the bot's own advisory. [Discord thread rules](https://docs.discord.com/developers/topics/threads) +The worker saves intent and staff evidence before destructive calls, then rechecks current identity and protection. Interrupted public sends recover by the exact marker in a recursive V2 Text Display, bot author and expected channel before retrying. Stored IDs and deletion use that same ownership predicate; a member copying the marker never grants ownership. Recovery scans are bounded; a scan that cannot establish completeness holds instead of duplicating a message. An interrupted delete that finds an absent thread records an unknown actor. Archived-message updates save a restoration intent, temporarily unarchive when necessary, and restore the saved archive/lock state after updating the bot's own response, including after a lost edit response or restart. [Discord thread rules](https://docs.discord.com/developers/topics/threads) + +Public media filenames contain the SHA-256 digest of the actual image bytes. Identical reconciliation performs no message write or upload. Name/control-only edits retain the actual returned attachment ID; changed observations replace the single attachment. Returned IDs and filenames are persisted, while fresh message reads verify live attachments during recovery. Scoped recruitment response handling preserves component-only `media.attachment_id` metadata that Discord.Net 3.20.1 would otherwise discard, including resolved CDN media URLs. V2 sends/edits have no ordinary content or embeds. Discord.Net 3.20.1's recruitment-only scoped transport removes incompatible null fields and supplies retained attachment IDs on JSON edits; uploads still use the SDK's multipart path, shared request queue, cancellation and rate-limit handling. A definite rejected media write retries as text V2; ambiguous failures retain the saved intent and recover before another create. Optional media never determines the acknowledgement window. Staff entries and the overview retain their active standard formats. [Discord message edits](https://docs.discord.com/developers/resources/message#edit-message) Recruitment uses a single state writer and a bounded, coalesced event queue. Relevant thread/starter edits, member-role/avatar changes, deleted bot controls (including bulk deletion), permissions and readiness events drive targeted work. Acknowledgement deadlines, recovery and pending notifications have scheduled wake-ups. Ordinary acknowledgements do not run forum setup or enumerate all forums. A safety reconciliation runs every 15–18 minutes; incomplete inventory continues in bounded batches. Active-thread enumeration uses one guild request per discovery pass; newly found identities fetch their missing facts separately. The transport observes all identities before the SDK filters the response by forum. Member/starter facts and successful message renders are cached; targeted events or a bounded audit refresh them. Reference provisioning shares discovery instead of enumerating forums separately. Only work still waiting in the queue fences automatic actions; the event being processed does not cancel itself. State changes remain durable before external effects, and destructive actions retain fresh identity/protection checks. @@ -143,7 +146,7 @@ Startup, disconnects, lost queue events and delivery failures invalidate invento ## Validation before deployment -The offline tests cover policy, acknowledgement timestamps through the real queue, creation limits/deletion/restarts, persistence faults and older v2 defaults, installed-SDK payloads, rate-limit recovery, fixed-overview recovery, privacy/authorization/report limits, bounded evidence, neutral reader presentation, content counterexamples and effective avatars. They do not call live Discord. +The offline tests cover policy, acknowledgement timestamps through the real queue, creation limits/deletion/restarts, persistence faults and current V2 delivery/state round-trips, installed-SDK payloads, rate-limit recovery, fixed-overview recovery, privacy/authorization/report limits, bounded evidence, neutral reader presentation, content counterexamples and effective avatars. They do not call live Discord. Before enabling in production, exercise staging permissions and feed privacy; Guidelines/tag repair; acknowledgement and duplicate/cooldown/pause flows; closed controls; private reports and review notices; starter/attachment/profile edits; reference editing/relocking; restart recovery; and mobile readability. Confirm old accepted threads remain open and existing closures stay closed. Verify the mounted persistent directory, backup and graceful shutdown. From 9a54f423eb78caf8e4e46d3f89b75e2272695f5a Mon Sep 17 00:00:00 2001 From: James Kellie Date: Sat, 17 Oct 2026 14:26:08 +1000 Subject: [PATCH 22/31] Pin recruitment responses with durable recovery --- .../Recruitment/RecruitmentEvidenceTests.cs | 17 ++- .../Recruitment/RecruitmentPinTests.cs | 119 +++++++++++++++++ .../Recruitment/RecruitmentPortfolioTests.cs | 7 +- .../RecruitmentPublicationTests.cs | 122 ++++++++++++++++++ .../RecruitmentStorageAndSetupTests.cs | 26 ++++ .../Recruitment/RecruitmentTestRig.cs | 18 +++ .../Recruitment/DiscordRecruitmentPins.cs | 59 +++++++++ .../Recruitment/IRecruitmentDiscord.cs | 4 + .../Recruitment/RecruitmentAttention.cs | 1 + .../Services/Recruitment/RecruitmentEngine.cs | 10 +- .../RecruitmentEngineScheduling.cs | 9 +- .../Recruitment/RecruitmentEvidence.cs | 5 + .../Recruitment/RecruitmentForumTransport.cs | 1 + .../RecruitmentMessageTransport.cs | 9 ++ .../Services/Recruitment/RecruitmentPins.cs | 42 ++++++ .../Services/Recruitment/RecruitmentState.cs | 11 ++ .../Recruitment/RecruitmentStateStore.cs | 3 + docs/recruitment.md | 26 ++-- 18 files changed, 470 insertions(+), 19 deletions(-) create mode 100644 DiscordBot.Tests/Recruitment/RecruitmentPinTests.cs create mode 100644 DiscordBot/Services/Recruitment/DiscordRecruitmentPins.cs create mode 100644 DiscordBot/Services/Recruitment/RecruitmentPins.cs diff --git a/DiscordBot.Tests/Recruitment/RecruitmentEvidenceTests.cs b/DiscordBot.Tests/Recruitment/RecruitmentEvidenceTests.cs index 3c75cfe3..99073d0b 100644 --- a/DiscordBot.Tests/Recruitment/RecruitmentEvidenceTests.cs +++ b/DiscordBot.Tests/Recruitment/RecruitmentEvidenceTests.cs @@ -12,7 +12,12 @@ public async Task IdentityOnlyRefreshUpdatesSameMessageWithoutInventoriesOrEnfor rig.Discord.Threads[post.Id] = rig.Discord.Threads[post.Id] with { ServerNickname = "New @nickname", GlobalName = "Global", Username = "member", AvatarUrl = "https://cdn.discordapp.com/avatars/200/hash.png", AvatarIdentity = "hash" }; await rig.Engine.FactsChangedAsync(post.Id, default); Assert.Equal("New nickname", post.Facts.ServerNickname); - Assert.Contains("New nickname", rig.Discord.Messages["100:recruitment:100:public"].Message.TextFallback); + var message = rig.Discord.Messages["100:recruitment:100:public"].Message; + foreach (var components in new[] { message.Components, message.FallbackComponents }) + { + Assert.NotNull(components); + Assert.Contains("New nickname", Newtonsoft.Json.JsonConvert.SerializeObject(components)); + } Assert.Equal(id, post.PublicMessageId); Assert.Equal(inventories, rig.Discord.Inventories); Assert.Equal(accepted, post.AcceptedAt); Assert.Empty(rig.Discord.Actions); await rig.Restart(); @@ -83,6 +88,14 @@ public async Task SharedContactHintsArePrivateAndRequireSpecificLinks() } Assert.Contains("<#101>", await rig.Staff("lookup")); Assert.DoesNotContain("101", await rig.Engine.DetailsAsync("1", "100", "200", default)); - Assert.DoesNotContain("101", rig.Discord.Messages["100:recruitment:100:public"].Message.TextFallback); + var message = rig.Discord.Messages["100:recruitment:100:public"].Message; + foreach (var components in new[] { message.Components, message.FallbackComponents }) + { + Assert.NotNull(components); + var text = string.Join("\n", Newtonsoft.Json.Linq.JToken.FromObject(components).SelectTokens("$..Content").Select(t => (string?)t)); + Assert.NotEmpty(text); + Assert.DoesNotContain("101", text); + Assert.DoesNotContain("Shared starter", Newtonsoft.Json.JsonConvert.SerializeObject(components)); + } } } diff --git a/DiscordBot.Tests/Recruitment/RecruitmentPinTests.cs b/DiscordBot.Tests/Recruitment/RecruitmentPinTests.cs new file mode 100644 index 00000000..d4ffd519 --- /dev/null +++ b/DiscordBot.Tests/Recruitment/RecruitmentPinTests.cs @@ -0,0 +1,119 @@ +using DiscordBot.Services.Recruitment; + +namespace DiscordBot.Tests.Recruitment; + +public class RecruitmentPinTests +{ + [Fact] + public async Task SuccessIsDurableAndManualUnpinIsPreservedIncludingExplicitRecovery() + { + using var rig = new RecruitmentRig(); await rig.Start(); var post = await rig.Post(); + Assert.True(post.PublicPin.Confirmed); Assert.Equal(post.PublicMessageId, post.PublicPin.MessageId); + Assert.False(post.Pinned); Assert.False(post.Exempt); Assert.Equal(1, rig.Discord.PinWrites); + rig.Discord.MessagePins.Clear(); + await rig.Sweep(); await rig.Staff("reconcile"); await rig.Restart(); + Assert.True(rig.State.Listings[post.Id].PublicPin.Confirmed); + Assert.Empty(rig.Discord.MessagePins); Assert.Equal(1, rig.Discord.PinWrites); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task CosmeticPermanentFailureDoesNotBlockControlsAcceptanceOrAlterWindow(bool full) + { + using var rig = new RecruitmentRig(); await rig.Start(); + rig.Discord.PinFailure = new InvalidOperationException(full ? "Maximum pins reached" : "Pin Messages permission missing"); + var post = await rig.Post(); var deadline = post.Deadline; + Assert.NotNull(deadline); Assert.Null(post.Fault); Assert.Null(post.Hold); + Assert.NotNull(post.PublicPin.Fault); Assert.Null(post.PublicPin.Fault!.RetryAt); + Assert.Contains("optional response pin", rig.Engine.Health().Attention); + var lookup = await rig.Staff("lookup"); + Assert.Contains("Optional response pin: pending", lookup); + Assert.Contains(full ? "Maximum pins reached" : "Pin Messages permission missing", lookup); + Assert.Contains("after relevant access change or explicit recovery", lookup); + Assert.DoesNotContain("Pin Messages permission missing", RecruitmentPresentation.Details(post)); + Assert.Contains("accepted", await rig.Ack()); Assert.Equal(deadline, post.Deadline); + rig.Clock.Advance(TimeSpan.FromDays(40)); await rig.Sweep(); + Assert.Equal(ListingStatus.Open, post.Status); Assert.NotNull(post.AcceptedAt); + rig.Discord.PinFailure = null; + rig.Engine.InvalidateAccess(); await rig.Sweep(); + Assert.True(post.PublicPin.Confirmed); + } + + [Fact] + public async Task LostResponseRestartReadsPinWithoutWritingAgain() + { + using var rig = new RecruitmentRig(); await rig.Start(); rig.Discord.LosePinResponse = true; + var post = await rig.Post(); var deadline = post.Deadline; + Assert.False(post.PublicPin.Confirmed); Assert.NotNull(post.PublicPin.Fault!.RetryAt); + rig.Clock.Advance(TimeSpan.FromMinutes(1)); await rig.Restart(); + post = rig.State.Listings[post.Id]; + Assert.True(post.PublicPin.Confirmed); Assert.Null(post.PublicPin.Fault); Assert.Equal(1, rig.Discord.PinWrites); + Assert.NotNull(deadline); Assert.Null(post.Fault); + } + + [Fact] + public async Task RateLimitRetriesClosedListingWithUnchangedPublicRender() + { + using var rig = new RecruitmentRig(); await rig.Start(); + var retry = rig.Clock.Now.AddMinutes(2); + rig.Discord.PinFailure = new RecruitmentDeferredException(retry); + var post = await rig.Post(); await rig.Ack(); await rig.Staff("close"); + Assert.Equal(ListingStatus.Closed, post.Status); Assert.Equal(retry, post.PublicPin.Fault!.RetryAt); + Assert.Contains(RecruitmentPresentation.Timestamp(retry), await rig.Staff("lookup")); + rig.Discord.PinFailure = null; rig.Clock.Advance(TimeSpan.FromMinutes(2)); + await rig.Engine.RunDueAsync(default); + Assert.True(post.PublicPin.Confirmed); Assert.Equal(ListingStatus.Closed, post.Status); + Assert.True(rig.Discord.Threads[post.Id].Archived); Assert.True(rig.Discord.Threads[post.Id].Locked); + } + + [Fact] + public async Task DeletedResponseReplacementGetsItsOwnPin() + { + using var rig = new RecruitmentRig(); await rig.Start(); var post = await rig.Post(); + var old = post.PublicMessageId!; + foreach (var key in rig.Discord.Messages.Where(m => m.Value.Id == old).Select(m => m.Key).ToArray()) rig.Discord.Messages.Remove(key); + await rig.Engine.MessageDeletedAsync(post.Id, new HashSet { old }, default); + Assert.NotEqual(old, post.PublicMessageId); Assert.True(post.PublicPin.Confirmed); + Assert.Equal(post.PublicMessageId, post.PublicPin.MessageId); Assert.Equal(2, rig.Discord.PinWrites); + } + + [Fact] + public async Task ReplacementPreservesInterruptedPinArchiveIntent() + { + using var rig = new RecruitmentRig(); await rig.Start(); var post = await rig.Post(); await rig.Ack(); + var old = post.PublicMessageId!; + post.PublicPin.Confirmed = false; post.PublicPin.RestoreArchive = post.PublicPin.RestoreLocked = true; + post.PublicPin.Fault = new RecruitmentFault("Restore interrupted", rig.Clock.Now.AddSeconds(15)); + foreach (var key in rig.Discord.Messages.Where(m => m.Value.Id == old).Select(m => m.Key).ToArray()) rig.Discord.Messages.Remove(key); + await rig.Engine.MessageDeletedAsync(post.Id, new HashSet { old }, default); + var replacement = rig.Discord.Messages.Values.Single(m => m.Id == post.PublicMessageId).Message; + Assert.True(replacement.RestoreArchive); Assert.True(replacement.RestoreLocked); + Assert.True(post.PublicPin.Confirmed); Assert.NotEqual(old, post.PublicPin.MessageId); + } + + [Fact] + public async Task NetworkIoFailureIsCosmeticButPinIntentStorageFailureIsFatal() + { + using var rig = new RecruitmentRig(); await rig.Start(); + rig.Discord.PinFailure = new IOException("Network connection interrupted"); + var post = await rig.Post(); + Assert.Null(post.Fault); Assert.NotNull(post.Deadline); Assert.NotNull(post.PublicPin.Fault!.RetryAt); + Assert.Contains("accepted", await rig.Ack()); + rig.Discord.PinFailure = null; rig.Clock.Now = post.PublicPin.Fault.RetryAt!.Value; + rig.Discord.Threads[post.Id] = rig.Discord.Threads[post.Id] with { Archived = true, Locked = true }; + rig.Discord.BeforePinWrite = () => Directory.CreateDirectory(Path.Combine(rig.Root, "recruitment/recruitment-state.json.tmp")); + await Assert.ThrowsAsync(() => rig.Engine.RunDueAsync(default)); + Assert.Contains("stopped", rig.Engine.Status); + Assert.False(post.PublicPin.Confirmed); + } + + [Fact] + public async Task BotMessagePinNeverProtectsPendingListingFromTimeout() + { + using var rig = new RecruitmentRig(); await rig.Start(); var post = await rig.Post(); + Assert.True(post.PublicPin.Confirmed); rig.Clock.Now = post.Deadline!.Value.AddSeconds(1); + await rig.Engine.RunDueAsync(default); + Assert.Equal(ListingStatus.Deleted, post.Status); Assert.Contains((post.Id, "delete"), rig.Discord.Actions); + } +} diff --git a/DiscordBot.Tests/Recruitment/RecruitmentPortfolioTests.cs b/DiscordBot.Tests/Recruitment/RecruitmentPortfolioTests.cs index 286786ad..2fbde1b6 100644 --- a/DiscordBot.Tests/Recruitment/RecruitmentPortfolioTests.cs +++ b/DiscordBot.Tests/Recruitment/RecruitmentPortfolioTests.cs @@ -230,7 +230,12 @@ public async Task ReuseHintsAreBoundedPrivateDifferentAuthorAndRetainedOnlyWithL var lookup = await rig.Staff("lookup"); var hint = lookup[lookup.IndexOf("Shared starter", StringComparison.Ordinal)..]; Assert.Equal(5, hint.Split("(author").Length - 1); Assert.Contains("shared projects can be legitimate", hint); Assert.DoesNotContain("Shared starter", await rig.Engine.DetailsAsync("1", "100", "200", default)); - Assert.DoesNotContain("Shared starter", rig.Discord.Messages["100:recruitment:100:public"].Message.TextFallback); + var message = rig.Discord.Messages["100:recruitment:100:public"].Message; + foreach (var components in new[] { message.Components, message.FallbackComponents }) + { + Assert.NotNull(components); + Assert.DoesNotContain("Shared starter", Newtonsoft.Json.JsonConvert.SerializeObject(components)); + } foreach (var post in rig.State.Listings.Values.Where(p => p.Id != "100")) { post.Status = ListingStatus.Deleted; post.UpdatedAt = rig.Clock.Now.AddYears(-2); rig.Discord.Threads.Remove(post.Id); } RecruitmentPolicy.Prune(rig.State, rig.Clock.Now, 12, 24, 30); diff --git a/DiscordBot.Tests/Recruitment/RecruitmentPublicationTests.cs b/DiscordBot.Tests/Recruitment/RecruitmentPublicationTests.cs index 9376b3ae..b2fdefb1 100644 --- a/DiscordBot.Tests/Recruitment/RecruitmentPublicationTests.cs +++ b/DiscordBot.Tests/Recruitment/RecruitmentPublicationTests.cs @@ -344,6 +344,110 @@ public async Task V2ConfirmedMissingMessageIsRecreatedWithUsableControls() Assert.True(sent.NewlySent); Assert.Single(transport.MessageWrites); Assert.Contains("Acknowledge", transport.Message.ToString()); } + [Fact] + public async Task InstalledSdkPinCorrectionIsScopedToExactMessageAndLeavesOrdinaryPinsAlone() + { + var transport = new PublicationTransport { MessageMissing = true }; using var client = await Client(transport); + var channel = (IThreadChannel)(await client.Rest.GetChannelAsync(100))!; + await DiscordRecruitmentAdapter.UpsertV2Async(channel, 999, "101", false, DateTimeOffset.UtcNow, V2(media: false), true, () => { }, default); + Assert.True(await DiscordRecruitmentAdapter.PinOwnedAsync(channel, 999, 101, true, false, false, (_, _) => { }, default)); + Assert.Equal(new[] { "channels/100/messages/pins/101" }, transport.PinRoutes); + var message = (IUserMessage)(await channel.GetMessageAsync(101))!; + await message.PinAsync(); + Assert.Equal("channels/100/pins/101", transport.PinRoutes.Last()); + using (RecruitmentMessageTransport.Pin(100, 101)) + { + Assert.Equal("channels/100/pins/102", RecruitmentMessageTransport.PinEndpoint("PUT", "channels/100/pins/102")); + Assert.Equal("channels/200/pins/101", RecruitmentMessageTransport.PinEndpoint("PUT", "channels/200/pins/101")); + Assert.Equal("channels/100/pins/101", RecruitmentMessageTransport.PinEndpoint("DELETE", "channels/100/pins/101")); + } + Assert.Equal(32768, (int)transport.Thread["flags"]!); + Assert.Equal(0, transport.Deletes); + } + + [Theory] + [InlineData(false)] + [InlineData(null)] + public async Task OptionalPinWithoutPermissionDoesNotWrite(bool? allowed) + { + var transport = new PublicationTransport { MessageMissing = true }; using var client = await Client(transport); + var channel = (IThreadChannel)(await client.Rest.GetChannelAsync(100))!; + await DiscordRecruitmentAdapter.UpsertV2Async(channel, 999, "101", false, DateTimeOffset.UtcNow, V2(media: false), true, () => { }, default); + await Assert.ThrowsAsync(() => DiscordRecruitmentAdapter.PinOwnedAsync(channel, 999, 101, allowed, false, false, (_, _) => { }, default)); + Assert.Empty(transport.PinRoutes); Assert.Empty(transport.ChannelWrites); + } + + [Fact] + public async Task LostPinResponseRestoresArchivedClosedThreadWhenMessageAlreadyPinned() + { + var transport = new PublicationTransport { MessageMissing = true }; using var client = await Client(transport); + var channel = (IThreadChannel)(await client.Rest.GetChannelAsync(100))!; + await DiscordRecruitmentAdapter.UpsertV2Async(channel, 999, "101", false, DateTimeOffset.UtcNow, V2(media: false), true, () => { }, default); + transport.Thread["thread_metadata"]!["archived"] = true; transport.Thread["thread_metadata"]!["locked"] = true; + channel = (IThreadChannel)(await client.Rest.GetChannelAsync(100))!; + transport.LosePinResponse = true; transport.FailRestoreOnce = true; + var receipt = new MessagePinReceipt { MessageId = "101" }; + void SaveIntent(bool archive, bool locked) { receipt.RestoreArchive = archive; receipt.RestoreLocked = locked; } + await Assert.ThrowsAsync(() => DiscordRecruitmentAdapter.PinOwnedAsync(channel, 999, 101, true, false, false, SaveIntent, default)); + Assert.True(receipt.RestoreArchive); Assert.True(receipt.RestoreLocked); + Assert.False((bool)transport.Thread["thread_metadata"]!["archived"]!); + Assert.True((bool)transport.Message["pinned"]!); + channel = (IThreadChannel)(await client.Rest.GetChannelAsync(100))!; + Assert.True(await DiscordRecruitmentAdapter.PinOwnedAsync(channel, 999, 101, false, receipt.RestoreArchive, receipt.RestoreLocked, SaveIntent, default)); + Assert.Single(transport.PinRoutes); + Assert.True((bool)transport.Thread["thread_metadata"]!["archived"]!); + Assert.True((bool)transport.Thread["thread_metadata"]!["locked"]!); + Assert.Equal(32768, (int)transport.Thread["flags"]!); + } + + [Theory] + [InlineData(400)] + [InlineData(403)] + [InlineData(429)] + public async Task InstalledSdkArchivedPinFailureRestoresThreadAndKeepsFailureCosmetic(int status) + { + var transport = new PublicationTransport { MessageMissing = true }; using var client = await Client(transport); + var channel = (IThreadChannel)(await client.Rest.GetChannelAsync(100))!; + await DiscordRecruitmentAdapter.UpsertV2Async(channel, 999, "101", false, DateTimeOffset.UtcNow, V2(media: false), true, () => { }, default); + transport.Thread["thread_metadata"]!["archived"] = true; transport.Thread["thread_metadata"]!["locked"] = true; + channel = (IThreadChannel)(await client.Rest.GetChannelAsync(100))!; + transport.PinStatus = status; + var receipt = new MessagePinReceipt { MessageId = "101" }; + var error = await Record.ExceptionAsync(() => DiscordRecruitmentAdapter.PinOwnedAsync(channel, 999, 101, true, false, false, + (archive, locked) => { receipt.RestoreArchive = archive; receipt.RestoreLocked = locked; }, default)); + Assert.NotNull(error); + // A 429 also defers restoration in the same channel. The saved intent retries once + // that server deadline passes, without requiring Pin Messages permission. + if (status == 429) + { + var deferred = Assert.IsType(error); + Assert.True(receipt.RestoreArchive); + await Task.Delay(deferred.RetryAt - DateTimeOffset.UtcNow + TimeSpan.FromMilliseconds(100)); + channel = (IThreadChannel)(await client.Rest.GetChannelAsync(100))!; + transport.PinStatus = 0; + await Assert.ThrowsAsync(() => DiscordRecruitmentAdapter.PinOwnedAsync(channel, 999, 101, false, + receipt.RestoreArchive, receipt.RestoreLocked, (archive, locked) => { receipt.RestoreArchive = archive; receipt.RestoreLocked = locked; }, default)); + } + else Assert.IsType(error); + Assert.True((bool)transport.Thread["thread_metadata"]!["archived"]!); + Assert.True((bool)transport.Thread["thread_metadata"]!["locked"]!); + Assert.False(receipt.RestoreArchive); Assert.Single(transport.PinRoutes); + var fault = RecruitmentFault.From(error!, DateTimeOffset.UtcNow); + Assert.Equal(status == 429, fault.RetryAt.HasValue); + Assert.Equal(0, transport.Deletes); + } + + [Fact] + public async Task PinRefusesCopiedMarkerOrWrongOwner() + { + var transport = new PublicationTransport { MessageMissing = true }; using var client = await Client(transport); + var channel = (IThreadChannel)(await client.Rest.GetChannelAsync(100))!; + await DiscordRecruitmentAdapter.UpsertV2Async(channel, 999, "101", false, DateTimeOffset.UtcNow, V2(media: false), true, () => { }, default); + transport.Message["author"]!["id"] = "200"; + await Assert.ThrowsAsync(() => DiscordRecruitmentAdapter.PinOwnedAsync(channel, 999, 101, true, false, false, (_, _) => { }, default)); + Assert.Empty(transport.PinRoutes); + } + private static BotSettings Settings() => new() { GuildId = 1, Recruitment = new RecruitmentSettings { Forums = new RecruitmentForums { PaidRecruitingId = 11, PaidForHireId = 12, HobbyRecruitingId = 13, HobbyForHireId = 14 } }, Roles = new() { Moderator = 50 } }; @@ -383,6 +487,10 @@ private sealed class PublicationTransport : IRestClient public bool RejectUpload { get; set; } public bool ComponentOnlyMedia { get; set; } public bool CdnMediaUrl { get; set; } + public bool LosePinResponse { get; set; } + public bool FailRestoreOnce { get; set; } + public int PinStatus { get; set; } + public List PinRoutes { get; } = new(); public int Deletes { get; private set; } public int Uploads { get; private set; } public JObject Message => _message; @@ -451,6 +559,8 @@ private Task Respond(string method, string endpoint, JObject? patc { if (method == "PATCH") { + if (FailRestoreOnce && (bool?)patch?["archived"] == true) + { FailRestoreOnce = false; throw new TimeoutException("Restore response unavailable"); } ChannelWrites.Add(patch!); foreach (var property in patch!.Properties()) if (property.Name is "locked" or "archived") Thread["thread_metadata"]![property.Name] = property.Value.DeepClone(); @@ -471,6 +581,18 @@ private Task Respond(string method, string endpoint, JObject? patc if (LoseCreateResponse) { LoseCreateResponse = false; throw new InvalidOperationException("Lost create response"); } body = MessageResponse().ToString(); } + else if (method == "PUT" && endpoint is "channels/100/messages/pins/101" or "channels/100/pins/101") + { + PinRoutes.Add(endpoint); + if (PinStatus != 0) + { + var payload = PinStatus == 429 ? "{\"retry_after\":0.01,\"global\":false}" : "{\"code\":30003,\"message\":\"Pin write rejected\"}"; + return Task.FromResult(new RestResponse((HttpStatusCode)PinStatus, new Dictionary(), new MemoryStream(Encoding.UTF8.GetBytes(payload)))); + } + _message["pinned"] = true; + if (LosePinResponse) { LosePinResponse = false; throw new TimeoutException("Pin response lost"); } + return Task.FromResult(new RestResponse(HttpStatusCode.NoContent, new Dictionary(), new MemoryStream())); + } else if (endpoint == "channels/100/messages/101") { if (method == "DELETE") { Deletes++; return Task.FromResult(new RestResponse(HttpStatusCode.NoContent, new Dictionary(), new MemoryStream())); } diff --git a/DiscordBot.Tests/Recruitment/RecruitmentStorageAndSetupTests.cs b/DiscordBot.Tests/Recruitment/RecruitmentStorageAndSetupTests.cs index 8b07f1ef..b00471c8 100644 --- a/DiscordBot.Tests/Recruitment/RecruitmentStorageAndSetupTests.cs +++ b/DiscordBot.Tests/Recruitment/RecruitmentStorageAndSetupTests.cs @@ -57,16 +57,42 @@ public async Task CurrentStateRoundTripsV2DeliveryAndArchiveIntentWithHistory() var pending = writer.State.Listings["100"]; pending.PublicMediaAttachmentId = "901"; pending.PublicMediaFileName = fileName; pending.PublicRestoreArchive = true; pending.PublicRestoreLocked = true; pending.PublicSendPending = true; + pending.PublicPin = new MessagePinReceipt { MessageId = pending.PublicMessageId, RestoreArchive = true, RestoreLocked = true, + Fault = new RecruitmentFault("Pin response lost", rig.Clock.Now.AddMinutes(1), 2) }; pending.PublicSendStartedAt = rig.Clock.Now; writer.Save(); } using var store = new RecruitmentStateStore(rig.Root, "1", forums, rig.Clock.Now); Assert.Equal("901", store.State.Listings["100"].PublicMediaAttachmentId); Assert.Equal(fileName, store.State.Listings["100"].PublicMediaFileName); Assert.True(store.State.Listings["100"].PublicRestoreLocked); Assert.True(store.State.Listings["100"].PublicSendPending); + var pin = store.State.Listings["100"].PublicPin; + Assert.Equal(post.PublicMessageId, pin.MessageId); Assert.False(pin.Confirmed); + Assert.True(pin.RestoreArchive); Assert.True(pin.RestoreLocked); Assert.Equal(2, pin.Fault!.Attempts); + Assert.Equal(rig.Clock.Now.AddMinutes(1), pin.Fault.RetryAt); Assert.Equal(post.AcceptedAt, store.State.Listings["100"].AcceptedAt); Assert.Equal(post.CreatedAt, store.State.Authors["200"].LastAcceptedCreation); } + [Theory] + [InlineData("null")] + [InlineData("id")] + [InlineData("confirmed")] + [InlineData("locked")] + public async Task MalformedPinReceiptIsRejected(string damage) + { + using var rig = new RecruitmentRig(); await rig.Start(); await rig.Post(); rig.Engine.Dispose(); + var forums = rig.Settings.Recruitment.Forums.Mapping().ToDictionary(x => x.Key, x => x.Value.ToString()); + using var store = new RecruitmentStateStore(rig.Root, "1", forums, rig.Clock.Now); + var post = store.State.Listings["100"]; + post.PublicPin = damage switch + { + "null" => null!, "id" => new() { MessageId = "invalid" }, + "confirmed" => new() { Confirmed = true }, "locked" => new() { MessageId = post.PublicMessageId, RestoreLocked = true }, + _ => throw new InvalidOperationException() + }; + Assert.Throws(store.Save); + } + [Theory] [InlineData("corrupt")] [InlineData("missing")] diff --git a/DiscordBot.Tests/Recruitment/RecruitmentTestRig.cs b/DiscordBot.Tests/Recruitment/RecruitmentTestRig.cs index 8020cfcc..e5790180 100644 --- a/DiscordBot.Tests/Recruitment/RecruitmentTestRig.cs +++ b/DiscordBot.Tests/Recruitment/RecruitmentTestRig.cs @@ -199,6 +199,24 @@ public Task UpsertAsync(string channelId, string? messageId, bool reco if (LoseNextSendResponse) { LoseNextSendResponse = false; throw new InvalidOperationException("Ambiguous send"); } return Task.FromResult(new Delivery(id, true)); } + public HashSet MessagePins { get; } = new(); + public int PinWrites { get; private set; } + public Exception? PinFailure { get; set; } + public bool LosePinResponse { get; set; } + public Action? BeforePinWrite { get; set; } + public Task PinAdvisoryAsync(string threadId, string messageId, bool restoreArchive, bool restoreLocked, + Action beforeWrite, CancellationToken ct) + { + if (!Messages.Values.Any(m => m.Id == messageId)) return Task.FromResult(false); + if (MessagePins.Contains(messageId)) return Task.FromResult(true); + if (PinFailure is not null) throw PinFailure; + BeforePinWrite?.Invoke(); + var thread = Threads[threadId]; + beforeWrite(restoreArchive || thread.Archived, restoreArchive ? restoreLocked : thread.Archived && thread.Locked); + PinWrites++; MessagePins.Add(messageId); + if (LosePinResponse) { LosePinResponse = false; throw new TimeoutException("Pin response lost"); } + return Task.FromResult(true); + } public Task CloseAsync(ThreadSnapshot thread, string? closedTagId, bool protectPinned, CancellationToken ct, Func? allowWrite = null) { if (allowWrite?.Invoke() == false) throw new InvalidOperationException("Evidence changed"); diff --git a/DiscordBot/Services/Recruitment/DiscordRecruitmentPins.cs b/DiscordBot/Services/Recruitment/DiscordRecruitmentPins.cs new file mode 100644 index 00000000..9390985f --- /dev/null +++ b/DiscordBot/Services/Recruitment/DiscordRecruitmentPins.cs @@ -0,0 +1,59 @@ +using Discord.Rest; + +namespace DiscordBot.Services.Recruitment; + +public sealed partial class DiscordRecruitmentAdapter +{ + public async Task PinAdvisoryAsync(string threadId, string messageId, bool restoreArchive, bool restoreLocked, + Action beforeWrite, CancellationToken ct) + { + // A fresh channel is essential after a lost unarchive/pin response. + var channel = await _client.Rest.GetChannelAsync(ulong.Parse(threadId), Options(ct)) as RestThreadChannel; + if (channel is null || channel.GuildId != _settings.GuildId || + !_settings.Recruitment.Forums.Mapping().Values.Contains(channel.ParentChannelId)) + throw new InvalidOperationException("Response pin thread unavailable."); + var guild = _client.GetGuild(_settings.GuildId); + var parent = guild?.GetChannel(channel.ParentChannelId); + bool? allowed = parent is null ? null : guild!.CurrentUser.GetPermissions(parent).PinMessages; + return await PinOwnedAsync(channel, BotUserId, ulong.Parse(messageId), allowed, restoreArchive, restoreLocked, beforeWrite, ct); + } + + internal static async Task PinOwnedAsync(IThreadChannel channel, ulong botId, ulong messageId, bool? allowed, + bool restoreArchive, bool restoreLocked, Action beforeWrite, CancellationToken ct) + { + using var observation = RecruitmentMessageTransport.Observe(channel.Id); + var message = await channel.GetMessageAsync(messageId, CacheMode.AllowDownload, Options(ct)) as IUserMessage; + var archive = restoreArchive || channel.IsArchived; + var locked = restoreArchive ? restoreLocked : channel.IsLocked; + async Task RestoreAsync(bool force) + { + if (archive && (force || !channel.IsArchived || channel.IsLocked != locked)) + { + beforeWrite(true, locked); + await channel.ModifyAsync(p => { p.Archived = true; p.Locked = locked; }, Options(ct)); + } + if (archive) beforeWrite(false, false); // Restoration is now durable, even if the pin failed. + } + // Restoration from an interrupted attempt is independent of pin capability. + if (restoreArchive) await RestoreAsync(false); + if (message is null) return false; + if (!OwnsMessage(message, botId, channel.Id, $"recruitment:{channel.Id}:public", RecruitmentMessageFormat.ComponentsV2)) + throw new InvalidOperationException("Response pin message identity does not match."); + if (message.IsPinned) return true; + if (allowed != true) throw new InvalidOperationException(allowed == false ? "Pin Messages permission missing" : "Pin Messages permission evidence unavailable"); + beforeWrite(archive, archive && locked); + try + { + if (channel.IsArchived) await channel.ModifyAsync(p => p.Archived = false, Options(ct)); + using var pin = RecruitmentMessageTransport.Pin(channel.Id, messageId); + await message.PinAsync(Options(ct)); + } + finally + { + // A definite rejection must not leave the thread open indefinitely. Force the + // restore because a lost unarchive response may leave the local SDK object stale. + if (archive) await RestoreAsync(true); + } + return true; + } +} diff --git a/DiscordBot/Services/Recruitment/IRecruitmentDiscord.cs b/DiscordBot/Services/Recruitment/IRecruitmentDiscord.cs index 8f42c2fe..16074b55 100644 --- a/DiscordBot/Services/Recruitment/IRecruitmentDiscord.cs +++ b/DiscordBot/Services/Recruitment/IRecruitmentDiscord.cs @@ -42,6 +42,10 @@ public interface IRecruitmentDiscord // Ambiguous sends must search the entire bounded recovery interval or hold; never blindly resend. Task UpsertAsync(string channelId, string? messageId, bool recovering, DateTimeOffset since, RecruitmentMessage message, Action beforeWrite, CancellationToken ct); + // False means the exact delivered message is absent. Persist restoration intent before writes, + // and clear it only after restoration is confirmed. + Task PinAdvisoryAsync(string threadId, string messageId, bool restoreArchive, bool restoreLocked, + Action beforeWrite, CancellationToken ct); Task CloseAsync(ThreadSnapshot thread, string? closedTagId, bool protectPinned, CancellationToken ct, Func? allowWrite = null); Task ReopenAsync(ThreadSnapshot thread, string? closedTagId, CancellationToken ct); Task DeleteAsync(ThreadSnapshot thread, string reason, bool protectPinned, CancellationToken ct, Func? allowWrite = null); diff --git a/DiscordBot/Services/Recruitment/RecruitmentAttention.cs b/DiscordBot/Services/Recruitment/RecruitmentAttention.cs index 7ca21b83..fff65fa3 100644 --- a/DiscordBot/Services/Recruitment/RecruitmentAttention.cs +++ b/DiscordBot/Services/Recruitment/RecruitmentAttention.cs @@ -11,6 +11,7 @@ public sealed partial class RecruitmentEngine var reasons = new List(); if (post.Reports.Any(r => r.ResolvedAt is null)) reasons.Add("private reports"); if (post.MaterialChanges > post.ReviewedMaterialChanges) reasons.Add("changed terms/links"); + if (post.PublicPin.Fault is not null) reasons.Add("optional response pin: " + RecruitmentPresentation.Safe(post.PublicPin.Fault.Message, 100)); if (post.Hold is not null) reasons.Add("processing held"); if (post.AttemptPausedUntil > Now) reasons.Add("posting pause"); if (post.TimeoutAlertCount >= 3 && State.Authors.GetValueOrDefault(post.OwnerId)?.TimeoutAlerted == true) reasons.Add("repeated timeouts"); diff --git a/DiscordBot/Services/Recruitment/RecruitmentEngine.cs b/DiscordBot/Services/Recruitment/RecruitmentEngine.cs index adb258ea..6692d1de 100644 --- a/DiscordBot/Services/Recruitment/RecruitmentEngine.cs +++ b/DiscordBot/Services/Recruitment/RecruitmentEngine.cs @@ -102,16 +102,18 @@ public async Task SweepAsync(CancellationToken ct, bool retryHeld = true) _ready = true; State.RecoveryFault = null; await DiscoverAsync(ct); + if (retryHeld) + foreach (var post in State.Listings.Values) post.PublicPin.Fault = null; if (retryHeld) foreach (var notice in State.Listings.Values.SelectMany(p => p.Attention).Where(n => n.MessageId is null || n.SendPending)) notice.Fault = null; var cursor = ulong.TryParse(State.SweepAfterId, out var savedCursor) ? savedCursor : 0; var batch = State.Listings.Values - .Where(p => p.Status == ListingStatus.Open || p.PendingAction is not null || p.StaffSendPending || p.PublicSendPending || p.PublicationPending || AttentionDue(p)) + .Where(p => p.Status == ListingStatus.Open || p.PendingAction is not null || p.StaffSendPending || p.PublicSendPending || p.PublicationPending || AttentionDue(p) || PinDue(p)) .OrderBy(p => ulong.Parse(p.Id) <= cursor).ThenBy(p => ulong.Parse(p.Id)).Take(10).ToArray(); foreach (var post in batch) { ct.ThrowIfCancellationRequested(); - if (retryHeld) post.Fault = null; + if (retryHeld) { post.Fault = null; post.PublicPin.Fault = null; } _discord.InvalidateFacts(post.Id); await ProcessSafelyAsync(post, ct, verifyHeld: true); } @@ -235,8 +237,8 @@ private async Task ProcessAsync(Listing post, CancellationToken ct) post.Facts.TagAdvice = _assets!.TagAdvice(Purpose(post.ForumId), live.Tags, State!.Forums[Purpose(post.ForumId)]); if (!post.PublicSendPending) { - post.PublicRestoreArchive = live.Archived && post.AcceptedAt.HasValue; - post.PublicRestoreLocked = post.PublicRestoreArchive && live.Locked; + post.PublicRestoreArchive = post.PublicPin.RestoreArchive || live.Archived && post.AcceptedAt.HasValue; + post.PublicRestoreLocked = post.PublicRestoreArchive && (post.PublicPin.RestoreArchive ? post.PublicPin.RestoreLocked : live.Locked); } post.Hold = null; if (live.Starter is null && !post.PromptDeliveredAt.HasValue && !post.AcceptedAt.HasValue) diff --git a/DiscordBot/Services/Recruitment/RecruitmentEngineScheduling.cs b/DiscordBot/Services/Recruitment/RecruitmentEngineScheduling.cs index 751597cd..d5a2b2a0 100644 --- a/DiscordBot/Services/Recruitment/RecruitmentEngineScheduling.cs +++ b/DiscordBot/Services/Recruitment/RecruitmentEngineScheduling.cs @@ -49,15 +49,19 @@ private DateTimeOffset? NextWorkAt private DateTimeOffset? ListingDue(Listing post) { if (post.Fault is { } fault) return fault.RetryAt; + var pinDue = PinDue(post) ? (DateTimeOffset?)Now : + post.Status is ListingStatus.Open or ListingStatus.Closed && post.PublicMessageId is not null + ? post.PublicPin.Fault?.RetryAt : null; if (post.PendingAction is not null || post.PublicSendPending || post.StaffSendPending || post.PublicationPending) return Now; var attention = post.Attention.Where(n => n.MessageId is null || n.SendPending) .Select(n => n.Fault is null ? (DateTimeOffset?)Now : n.Fault.RetryAt).Where(t => t.HasValue).Min(); - if (post.Status != ListingStatus.Open) return attention; + if (post.Status != ListingStatus.Open) return new[] { attention, pinDue }.Where(t => t.HasValue).Min(); if (post.PortfolioRegistryVersion != State!.PortfolioSites.Version) return Now; if (_recoverWindows.Contains(post.Id)) return Now; if (!post.PromptDeliveredAt.HasValue && !post.AcceptedAt.HasValue) return Now; var dates = new List(); if (attention.HasValue) dates.Add(attention.Value); + if (pinDue.HasValue) dates.Add(pinDue.Value); if (!post.Exempt && !post.Pinned) { if (!post.AcceptedAt.HasValue && post.Deadline.HasValue) dates.Add(post.Deadline.Value); @@ -99,7 +103,7 @@ public void InvalidateAccess(string? forum = null) State.Overview.Fault = null; foreach (var f in State.Forums.Values.Where(f => forum is null || f.ForumId == forum)) f.Fault = null; foreach (var f in State.Forums.Values.Where(f => forum is null || f.ForumId == forum)) f.Reference.Fault = null; - foreach (var post in State.Listings.Values.Where(p => forum is null || p.ForumId == forum)) post.Fault = null; + foreach (var post in State.Listings.Values.Where(p => forum is null || p.ForumId == forum)) { post.Fault = null; post.PublicPin.Fault = null; } _nextAudit = Now; MarkEvidenceGap(); } @@ -175,6 +179,7 @@ private async Task ProcessSafelyAsync(Listing post, CancellationToken ct, bool v try { await ProcessAsync(post, ct); + await PinResponseAsync(post, ct); if (previousFault is not null) Save(); } catch (Exception ex) when (ex is not OperationCanceledException && ex is not System.IO.IOException) diff --git a/DiscordBot/Services/Recruitment/RecruitmentEvidence.cs b/DiscordBot/Services/Recruitment/RecruitmentEvidence.cs index b58b5bf4..51f04192 100644 --- a/DiscordBot/Services/Recruitment/RecruitmentEvidence.cs +++ b/DiscordBot/Services/Recruitment/RecruitmentEvidence.cs @@ -49,6 +49,11 @@ private string EvidenceDetails(Listing post) { var text = RecruitmentPresentation.Staff(State!, post, _settings).Content + "\n" + $"Created {RecruitmentPresentation.Timestamp(post.CreatedAt)} · accepted {(post.AcceptedAt.HasValue ? RecruitmentPresentation.Timestamp(post.AcceptedAt.Value) : "no")}\n"; + var pin = post.PublicPin; + text += $"Optional response pin: {(pin.MessageId is null ? "not attempted" : pin.Confirmed ? "confirmed; later manual unpins respected" : "pending")} · message `{pin.MessageId ?? "none"}`" + + (pin.RestoreArchive ? " · archive/lock restoration pending" : "") + "\n"; + if (pin.Fault is { } pinFault) + text += $"Pin fault: {RecruitmentPresentation.Safe(pinFault.Message, 400)} · retry {(pinFault.RetryAt is { } retry ? RecruitmentPresentation.Timestamp(retry) : "after relevant access change or explicit recovery")}\n"; var versions = new[] { post.OriginalStarter }.Concat(post.EarlierMaterialVersions).Append(post.LatestStarter) .OfType().DistinctBy(v => (v.ObservedAt, v.Fingerprint)); foreach (var version in versions) diff --git a/DiscordBot/Services/Recruitment/RecruitmentForumTransport.cs b/DiscordBot/Services/Recruitment/RecruitmentForumTransport.cs index a27249f0..68b3dd7e 100644 --- a/DiscordBot/Services/Recruitment/RecruitmentForumTransport.cs +++ b/DiscordBot/Services/Recruitment/RecruitmentForumTransport.cs @@ -48,6 +48,7 @@ private sealed class Scope(Action restore) : IDisposable public async Task SendAsync(string method, string endpoint, CancellationToken cancelToken, bool headerOnly, string reason, IEnumerable>> requestHeaders) { + endpoint = RecruitmentMessageTransport.PinEndpoint(method, endpoint); var response = await RecruitmentRequests.SendAsync(method, endpoint, cancelToken, () => inner.SendAsync(method, endpoint, cancelToken, headerOnly, reason, requestHeaders)); var scope = Inventory.Value; diff --git a/DiscordBot/Services/Recruitment/RecruitmentMessageTransport.cs b/DiscordBot/Services/Recruitment/RecruitmentMessageTransport.cs index 6b30298d..10be6bba 100644 --- a/DiscordBot/Services/Recruitment/RecruitmentMessageTransport.cs +++ b/DiscordBot/Services/Recruitment/RecruitmentMessageTransport.cs @@ -11,6 +11,15 @@ internal static class RecruitmentMessageTransport { private sealed record Publication(string Endpoint, string Method, IReadOnlyList Retained); private static readonly AsyncLocal Current = new(); + private static readonly AsyncLocal<(ulong Channel, ulong Message)?> PinScope = new(); + internal static IDisposable Pin(ulong channel, ulong message) + { + var previous = PinScope.Value; PinScope.Value = (channel, message); + return new Scope(() => PinScope.Value = previous); + } + internal static string PinEndpoint(string method, string endpoint) => + PinScope.Value is { } pin && method == "PUT" && endpoint == $"channels/{pin.Channel}/pins/{pin.Message}" + ? $"channels/{pin.Channel}/messages/pins/{pin.Message}" : endpoint; private static readonly AsyncLocal ObservedChannel = new(); internal static IDisposable Observe(ulong channelId) { diff --git a/DiscordBot/Services/Recruitment/RecruitmentPins.cs b/DiscordBot/Services/Recruitment/RecruitmentPins.cs new file mode 100644 index 00000000..83a02e1c --- /dev/null +++ b/DiscordBot/Services/Recruitment/RecruitmentPins.cs @@ -0,0 +1,42 @@ +namespace DiscordBot.Services.Recruitment; + +public sealed partial class RecruitmentEngine +{ + private bool PinDue(Listing post) => post.Status is ListingStatus.Open or ListingStatus.Closed && + post.PublicMessageId is not null && + (post.PublicPin.MessageId != post.PublicMessageId || + (!post.PublicPin.Confirmed || post.PublicPin.RestoreArchive) && (post.PublicPin.Fault is null || post.PublicPin.Fault.IsDue(Now))); + + private async Task PinResponseAsync(Listing post, CancellationToken ct) + { + if (!PinDue(post)) return; + if (post.PublicPin.MessageId != post.PublicMessageId) + { post.PublicPin = new MessagePinReceipt { MessageId = post.PublicMessageId }; Save(); } + var receipt = post.PublicPin; + bool found; + var persistenceFailed = false; + try + { + found = await _discord.PinAdvisoryAsync(post.Id, receipt.MessageId!, receipt.RestoreArchive, receipt.RestoreLocked, + (archive, locked) => + { + receipt.RestoreArchive = archive; receipt.RestoreLocked = locked; + try { Save(); } + catch (Exception ex) + { persistenceFailed = true; throw new System.IO.IOException("Could not persist response pin intent.", ex); } + }, ct); + } + catch (Exception ex) when ((ex is not OperationCanceledException || !ct.IsCancellationRequested) && !persistenceFailed) + { + receipt.Fault = RecruitmentFault.From(ex, Now, receipt.Fault); + if (receipt.RestoreArchive && receipt.Fault.RetryAt is null) + receipt.Fault = receipt.Fault with { RetryAt = Now.AddSeconds(Math.Min(900, 15 * Math.Pow(2, receipt.Fault.Attempts - 1)) + Random.Shared.Next(1, 6)) }; + Save(); return; + } + if (found) + { receipt.Confirmed = true; receipt.RestoreArchive = receipt.RestoreLocked = false; receipt.Fault = null; } + else + { post.PublicMessageId = null; post.PublicationPending = true; _publicRenders.Remove(post.Id); post.PublicPin = new(); } + Save(); + } +} diff --git a/DiscordBot/Services/Recruitment/RecruitmentState.cs b/DiscordBot/Services/Recruitment/RecruitmentState.cs index 284eddc1..2188fb98 100644 --- a/DiscordBot/Services/Recruitment/RecruitmentState.cs +++ b/DiscordBot/Services/Recruitment/RecruitmentState.cs @@ -99,6 +99,7 @@ public sealed class Listing public List ValidCodes { get; set; } = new(); public DateTimeOffset? LastCodeAttemptAt { get; set; } public DateTimeOffset? AttemptPausedUntil { get; set; } + public MessagePinReceipt PublicPin { get; set; } = new(); public string? PublicMessageId { get; set; } public string? StaffMessageId { get; set; } public DateTimeOffset? PublicSendStartedAt { get; set; } @@ -281,3 +282,13 @@ public sealed class OverviewReceipt public DateTimeOffset? LastEditedAt { get; set; } public RecruitmentFault? Fault { get; set; } } + +/// Optional message pin work; never grants forum-post protection or listing capacity. +public sealed class MessagePinReceipt +{ + public string? MessageId { get; set; } + public bool Confirmed { get; set; } + public bool RestoreArchive { get; set; } + public bool RestoreLocked { get; set; } + public RecruitmentFault? Fault { get; set; } +} diff --git a/DiscordBot/Services/Recruitment/RecruitmentStateStore.cs b/DiscordBot/Services/Recruitment/RecruitmentStateStore.cs index b989e34f..363dc76c 100644 --- a/DiscordBot/Services/Recruitment/RecruitmentStateStore.cs +++ b/DiscordBot/Services/Recruitment/RecruitmentStateStore.cs @@ -129,6 +129,9 @@ state.Forums is null || state.Listings is null || state.Authors is null || state (p.PublicMediaAttachmentId is null) != (p.PublicMediaFileName is null) || p.PublicMediaFileName is { } fileName && (fileName.Length > 120 || !fileName.StartsWith("recruitment-observations-", StringComparison.Ordinal) || !fileName.EndsWith(".png", StringComparison.Ordinal)) || p.PublicRestoreLocked && !p.PublicRestoreArchive || + p.PublicPin is null || p.PublicPin.MessageId is not null && !Id(p.PublicPin.MessageId) || + p.PublicPin.Confirmed && p.PublicPin.MessageId is null || + p.PublicPin.RestoreLocked && !p.PublicPin.RestoreArchive || p.PortfolioRegistryVersion < 0 || p.PortfolioRegistryVersion > registry.Version || p.CreatedAt == default || p.CreatedAt.Offset != TimeSpan.Zero || p.UpdatedAt == default || !Enum.IsDefined(p.Status) || p.ValidCodes is null || p.Facts is null || p.Audit is null || diff --git a/docs/recruitment.md b/docs/recruitment.md index 928100ad..c2b0768c 100644 --- a/docs/recruitment.md +++ b/docs/recruitment.md @@ -1,6 +1,6 @@ # Recruitment management -Recruitment is disabled in the example, development and production settings. It uses four **new** forums, one staff text feed, and the existing configured moderator role. Settings and asset changes take effect after a restart. The administrator portfolio-site registry changes immediately without rewriting settings. There is no migration from the old single-forum babysitter or `feat/babysitter` state. +Recruitment is disabled in the example, development and production settings. It uses four **new** forums, one staff text feed, and the existing configured moderator role. Settings and asset changes take effect after a restart. The administrator portfolio-site registry changes immediately without rewriting settings. ## Member behavior @@ -12,10 +12,10 @@ Recruitment is disabled in the example, development and production settings. It - Short evidence-bearing warnings cover applicant fees, payment pressure and private terms. Affirmative DM-first instructions, including “DM to apply”, request public first contact; negation, safety advice and private-information exceptions are handled separately. English keyword heuristics can miss context or other languages; observations never remove a post. Starter/attachment edits and role/avatar/name changes refresh the same response. Clarify scope, samples and payment publicly before moving to DMs. - Missing facts remain explicit. Community activity is not queried or displayed. Discord's documented [member](https://docs.discord.com/developers/resources/guild#guild-member-object) and [user](https://docs.discord.com/developers/resources/user#user-object) structures do not expose server-profile privacy, so no privacy flag is inferred. - The owner enters the five-character code from the forum's native Guidelines. The 30-minute window starts after usable controls are delivered. A correct code accepts an eligible listing immediately. Valid submissions received within the window remain timely even if the worker processes them later. -- At the deadline, an unacknowledged attempt is removed. Known duplicate, cooldown and posting-pause conflicts are locked and archived promptly without asking for a code. Closed attempts do not automatically reopen when restrictions expire. Pins and explicit exemptions protect against automatic actions. +- At the deadline, an unacknowledged attempt is removed. Known duplicate, cooldown and posting-pause conflicts are locked and archived promptly without asking for a code. Closed attempts do not automatically reopen when restrictions expire. Forum-post pins and explicit exemptions protect against automatic actions; pinning only the bot response grants no protection. - Only **accepted** listings start a cooldown, measured from the thread's original creation time. Closing/deleting on day 2 leaves 28 days; doing so on day 40 adds no wait. An open listing still occupies the slot after day 30, including when Discord naturally archives it. Removing an unaccepted attempt creates no accepted-listing cooldown, but still counts toward the attempt budget. - **Your listing** opens private owner controls. The owner can close the listing or confirm removal through an expiring, single-use control. Accepted cooldowns survive removal. The `Closed` tag also requests lock-and-archive. Existing unrelated applied tags are preserved if there is no room for another tag. -- Accepted listings remain indefinitely until the owner or a moderator closes/removes them. Owners can edit or bump the existing thread to preserve its conversation history. Natural auto-archive does not free the slot. Legacy `UnansweredDays` and reply-history fields are accepted when loading old configuration/state but no longer control expiry, and pending automatic unanswered closures are cancelled. +- Accepted listings remain indefinitely until the owner or a moderator closes/removes them. Owners can edit or bump the existing thread to preserve its conversation history. Natural auto-archive does not free the slot. - Public replies and owner editing remain available while a listing is open. There is no short edit lock, reply deletion, or parent permission rewrite. Configured recruitment forums and their threads do not earn new XP, including while recruitment is disabled; existing totals are unchanged. ## Configure new forums @@ -44,7 +44,7 @@ Set these fields under `Recruitment` in the existing settings file: Replace zero IDs with the new channels. Do not reuse the old `Channels.Recruitment` or manually configure tag IDs. All four IDs must be distinct forum channels in `GuildId`. `Roles.Moderator` must resolve to the intended role. -The bot needs View Channel, Read Message History, Send Messages in Threads, Manage Threads and Manage Channels on each forum. Creating reference posts also requires Send Messages. Attach Files and Embed Links enable the optional observations image and media; missing either permission keeps the text-only V2 response usable. In the feed it needs View Channel, Read Message History and Send Messages. Embed Links enables the status overview embed; without it, the overview uses concise text. Deny View Channel to `@everyone`; explicit access grants are limited to the configured moderator role, administrators and the bot. Existing Guild Members and Message Content intents must also be enabled in the Discord application. +The bot needs View Channel, Read Message History, Send Messages in Threads, Manage Threads and Manage Channels on each forum. Creating reference posts also requires Send Messages. **Pin Messages** enables the optional response pin inside each listing. Missing pin permission, unavailable permission evidence or a full pin list never holds essential message delivery or acceptance. Attach Files and Embed Links enable the optional observations image and media; missing either permission keeps the text-only V2 response usable. In the feed it needs View Channel, Read Message History and Send Messages. Embed Links enables the status overview embed; without it, the overview uses concise text. Deny View Channel to `@everyone`; explicit access grants are limited to the configured moderator role, administrators and the bot. Existing Guild Members and Message Content intents must also be enabled in the Discord application. Review `DiscordBot/Assets/recruitment/guidelines/*.md` and `tags.json` before enabling. Templates own the **entire native Guidelines** and contain one `{{code}}` plus the duration placeholders. Rendered Guidelines must fit the 1,000-character budget, including the code and configured durations. They point members to the pinned moderator/admin post at the top of the forum for fuller guidance; moderators maintain that post separately. Each tag definition has a stable key, display name, advisory group and closing flag. Keep keys stable when renaming a tag. The one closing tag is unmoderated. The shipped copy/tag groups are editable defaults pending moderator approval. @@ -82,9 +82,9 @@ All commands are ephemeral and check the configured guild and the caller's curre Repair and reconciliation return a short job ID promptly. The worker rechecks moderator authorization when the job runs; `/recruitment status` shows its latest result. Status uses the gateway's current member/role cache and never waits behind mutation work. Missing authorization evidence denies access. -The feed contains one fixed **Recruitment Status** overview, edited in place. Staff may pin it manually; the bot does not need Manage Messages. Updates coalesce for five seconds with at least 30 seconds between edits. Missing-message recovery and unfinished replacement/cleanup receipts from older versions remain supported. Ordinary feed traffic does not move it. Cosmetic delivery failures do not block acceptance. +The feed contains one fixed **Recruitment Status** overview, edited in place. Staff may pin it manually; the bot does not need Manage Messages. Updates coalesce for five seconds with at least 30 seconds between edits. Missing-message recovery and interrupted replacement/cleanup receipts are durable. Ordinary feed traffic does not move it. Cosmetic delivery failures do not block acceptance. -The overview links up to five listings needing report review, material-edit review, recovery or repeated-attempt attention. It also shows observed creations, accepted listings, closed unaccepted attempts, timeouts and mean prompt-to-accept delay for listings created in the past 24 hours. These are retained-record aggregates, not a complete event-analytics system. Use moderator review and recorded reasons to assess false positives before tightening thresholds. +The overview links up to five listings needing report review, material-edit review, recovery, optional response-pin recovery or repeated-attempt attention. It also shows observed creations, accepted listings, closed unaccepted attempts, timeouts and mean prompt-to-accept delay for listings created in the past 24 hours. These are retained-record aggregates, not a complete event-analytics system. Use moderator review and recorded reasons to assess false positives before tightening thresholds. The staff feed keeps one concise entry per attempt, with full details in lookup. A new unresolved report, first unreviewed material edit, newly triggered posting pause or third consecutive confirmed Guidelines timeout creates a separate review notice. Further timeouts do not repeat that threshold notice until a real acknowledgement or moderator reset clears the streak. Reports/material edits are grouped until reviewed. Delivery receipts recover ambiguous sends instead of duplicating notices. Wrong guesses are rate-limited and do not increment timeouts; independently missing threads or ambiguous delete responses do not invent timeout evidence. Routine queued identities log only in Debug builds; actual inventory/permission/recovery faults still warn. @@ -125,12 +125,12 @@ Reuse evidence retains at most eight canonical specific candidates per observed State is stored in `{ServerRootPath}/recruitment/`: -- `recruitment-state.json`: schema `udc-recruitment-v2`, string IDs, UTC dates, per-forum setup/reference receipts and cursors, listing records, author aggregates, retry holds and overview delivery receipts, and V2 media attachment IDs/filenames plus archive/lock restoration intent. +- `recruitment-state.json`: schema `udc-recruitment-v2`, string IDs, UTC dates, per-forum setup/reference receipts and cursors, listing records, author aggregates, retry holds and overview delivery receipts, V2 media attachment IDs/filenames and archive/lock restoration intent, plus a separate optional message-pin receipt/fault. - `recruitment-state.json.bak`: last validated snapshot before the latest replacement. - `initialized`: matching store identity, used to detect a missing primary. - `writer.lock`: exclusive process lease. The file may remain after shutdown; its existence does not itself mean the lease is held. -Writes are serialized, flushed and replaced atomically. A write failure fences subsequent mutations until storage is repaired and the process restarted. Corrupt, unsupported, incomplete, missing or mismatched history is never silently replaced with empty cooldowns. Back up the directory and its identity receipt together. A completely lost volume cannot be distinguished locally from a new volume; existing nonempty forum setup holds first-use initialization. Restore history before repairing setup after a suspected volume loss. +The state reader rejects unknown fields; do not run an earlier binary against snapshots containing fields it does not understand. Writes are serialized, flushed and replaced atomically. A write failure fences subsequent mutations until storage is repaired and the process restarted. Corrupt, unsupported, incomplete, missing or mismatched history is never silently replaced with empty cooldowns. Back up the directory and its identity receipt together. A completely lost volume cannot be distinguished locally from a new volume; existing nonempty forum setup holds first-use initialization. Restore history before repairing setup after a suspected volume loss. For storage recovery, stop the bot, preserve the damaged files, restore a validated primary (or review the `.bak`) with its matching `initialized` receipt, then restart and reconcile. A backup may precede the latest acceptance/action; inspect the staff feed and Discord before restoring it. Do not change forum/guild mappings, delete the identity receipt, or clear author history to bypass a fault. The current unreleased bot writes its final state directly; no state migration or old-card conversion is provided. @@ -138,6 +138,12 @@ The worker saves intent and staff evidence before destructive calls, then rechec Public media filenames contain the SHA-256 digest of the actual image bytes. Identical reconciliation performs no message write or upload. Name/control-only edits retain the actual returned attachment ID; changed observations replace the single attachment. Returned IDs and filenames are persisted, while fresh message reads verify live attachments during recovery. Scoped recruitment response handling preserves component-only `media.attachment_id` metadata that Discord.Net 3.20.1 would otherwise discard, including resolved CDN media URLs. V2 sends/edits have no ordinary content or embeds. Discord.Net 3.20.1's recruitment-only scoped transport removes incompatible null fields and supplies retained attachment IDs on JSON edits; uploads still use the SDK's multipart path, shared request queue, cancellation and rate-limit handling. A definite rejected media write retries as text V2; ambiguous failures retain the saved intent and recover before another create. Optional media never determines the acknowledgement window. Staff entries and the overview retain their active standard formats. [Discord message edits](https://docs.discord.com/developers/resources/message#edit-message) +The bot attempts to pin the exact owned V2 response only after its returned message ID is saved and essential listing processing has completed. This pins a **message inside the thread**, separately from the forum-post pin. It never grants an exemption, frees a slot, delays acceptance, extends a deadline or reopens a closed listing. Pin-system messages, other people's pins and full pin lists are left intact. Once pin success is confirmed, subsequent manual unpins are respected, including restart, repair and explicit reconcile. A deleted response is replaced and the replacement gets its own pin attempt. + +An unconfirmed pin retries by reading the actual message pin state first, so a lost response does not repeat a successful pin. Its separate receipt saves archive/lock restoration intent before any temporary unarchive; recovery restores that state even if the message is already pinned. Transient faults use bounded backoff (15 seconds to 15 minutes plus jitter); HTTP 429 respects the server retry deadline. Missing permission and full-capacity failures wait for a relevant permission/access change or explicit repair/reconcile; unfinished archive/lock restoration keeps a bounded retry even when the pin itself was rejected. These recovery commands retry unfinished pin work, including accepted and closed listings, while preserving confirmed manual unpins. A concise optional-pin diagnostic appears in staff status/overview; moderator lookup includes each response pin status, fault, retry eligibility and pending archive restoration. Storage-write failures remain fatal and require storage recovery; they are not cosmetic pin failures. + +Discord.Net 3.20.1's `PinAsync` uses the deprecated `PUT channels/{channel.id}/pins/{message.id}` route. A recruitment-only execution scope corrects that exact channel/message PUT to `channels/{channel.id}/messages/pins/{message.id}` inside the existing SDK transport. Unscoped pins and other methods/identities pass through unchanged; authentication, cancellation, the request queue and rate-limit handling stay SDK-owned. Local installed-version tests verify payload routing and restoration; live permission and client behavior still require staging. [Discord message pins](https://docs.discord.com/developers/resources/message#pin-message) + Recruitment uses a single state writer and a bounded, coalesced event queue. Relevant thread/starter edits, member-role/avatar changes, deleted bot controls (including bulk deletion), permissions and readiness events drive targeted work. Acknowledgement deadlines, recovery and pending notifications have scheduled wake-ups. Ordinary acknowledgements do not run forum setup or enumerate all forums. A safety reconciliation runs every 15–18 minutes; incomplete inventory continues in bounded batches. Active-thread enumeration uses one guild request per discovery pass; newly found identities fetch their missing facts separately. The transport observes all identities before the SDK filters the response by forum. Member/starter facts and successful message renders are cached; targeted events or a bounded audit refresh them. Reference provisioning shares discovery instead of enumerating forums separately. Only work still waiting in the queue fences automatic actions; the event being processed does not cancel itself. State changes remain durable before external effects, and destructive actions retain fresh identity/protection checks. Confirmed thread-deletion events release listing capacity and clear stale holds even while setup is unavailable. Bounded safety audits also probe permanently held records for deletions missed during downtime, without blindly retrying their writes. Invalid setup, permission and ownership failures otherwise hold until a relevant invalidation or explicit recovery. Transient failures back off with jitter. Recruitment hands actual HTTP 429 retry deadlines back to the scheduler, with conservative channel/guild scope and global handling, and retains the SDK's shared request queue. Known SDK bucket exhaustion also fails promptly rather than sleeping through minutes. Repeated identical fault logs are suppressed; status shows the scope and retry eligibility. @@ -146,9 +152,9 @@ Startup, disconnects, lost queue events and delivery failures invalidate invento ## Validation before deployment -The offline tests cover policy, acknowledgement timestamps through the real queue, creation limits/deletion/restarts, persistence faults and current V2 delivery/state round-trips, installed-SDK payloads, rate-limit recovery, fixed-overview recovery, privacy/authorization/report limits, bounded evidence, neutral reader presentation, content counterexamples and effective avatars. They do not call live Discord. +The offline tests cover policy, acknowledgement timestamps through the real queue, creation limits/deletion/restarts, persistence faults and current V2 delivery/pin/state round-trips, installed-SDK payloads, manual unpins, lost pin responses, archived pin restoration, rate-limit recovery, fixed-overview recovery, privacy/authorization/report limits, bounded evidence, neutral reader presentation, content counterexamples and effective avatars. They do not call live Discord. -Before enabling in production, exercise staging permissions and feed privacy; Guidelines/tag repair; acknowledgement and duplicate/cooldown/pause flows; closed controls; private reports and review notices; starter/attachment/profile edits; reference editing/relocking; restart recovery; and mobile readability. Confirm old accepted threads remain open and existing closures stay closed. Verify the mounted persistent directory, backup and graceful shutdown. +Before enabling in production, exercise staging permissions and feed privacy; Guidelines/tag repair; acknowledgement and duplicate/cooldown/pause flows; closed controls; private reports and review notices; starter/attachment/profile edits; reference editing/relocking; optional message pins, missing Pin Messages and full pin capacity; manual unpins; restart recovery; and mobile readability. Confirm old accepted threads remain open and existing closures stay closed. Verify the mounted persistent directory, backup and graceful shutdown. Consider **10 minutes of native forum creation slowmode** as a pilot, configured by staff. Discord's `rate_limit_per_user` for forum creation is separate from `default_thread_rate_limit_per_user` for replies; keep public clarification practical. Review native AutoMod spam/mention/keyword rules alongside the bot. These server settings are not changed by this implementation. [Discord forum fields](https://docs.discord.com/developers/topics/threads), [AutoMod FAQ](https://support.discord.com/hc/en-us/articles/4421269296535-AutoMod-FAQ) From 98cd5152acde79ef1e026eca6672299e5b50448b Mon Sep 17 00:00:00 2001 From: James Kellie Date: Mon, 19 Oct 2026 20:34:17 +1000 Subject: [PATCH 23/31] fix: A number of tweaks to recruitment service and rendering --- .vscode/tasks.json | 106 ++++++++++------- .../RecruitmentAcknowledgementTests.cs | 112 ++++++++++++++++++ .../Recruitment/RecruitmentBannerTests.cs | 1 + .../RecruitmentPresentationTests.cs | 32 +++++ .../RecruitmentPublicationTests.cs | 29 +++++ .../Recruitment/RecruitmentTestRig.cs | 5 + .../Recruitment/RecruitmentWorkflowTests.cs | 5 +- .../Modules/Recruitment/RecruitmentModule.cs | 3 +- .../Recruitment/DiscordRecruitmentAdapter.cs | 7 +- .../Recruitment/IRecruitmentDiscord.cs | 2 +- .../Recruitment/RecruitmentAcknowledgement.cs | 42 +++++++ .../Services/Recruitment/RecruitmentBanner.cs | 38 +++--- .../Services/Recruitment/RecruitmentEngine.cs | 3 +- .../RecruitmentEngineScheduling.cs | 4 +- .../Recruitment/RecruitmentPresentation.cs | 11 +- .../Recruitment/RecruitmentService.cs | 6 + .../Recruitment/RecruitmentSignals.cs | 4 +- .../Services/Recruitment/RecruitmentState.cs | 9 ++ .../Recruitment/RecruitmentStateStore.cs | 2 + 19 files changed, 345 insertions(+), 76 deletions(-) create mode 100644 DiscordBot.Tests/Recruitment/RecruitmentAcknowledgementTests.cs create mode 100644 DiscordBot/Services/Recruitment/RecruitmentAcknowledgement.cs diff --git a/.vscode/tasks.json b/.vscode/tasks.json index eab0d4b2..b47a7b4e 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -1,48 +1,62 @@ { - "version": "2.0.0", - "tasks": [ - { - "label": "build", - "command": "dotnet", - "type": "process", - "args": [ - "build", - "${workspaceFolder}/DiscordBot/DiscordBot.csproj", - "--configuration", - "Debug", - "--output", - "${workspaceFolder}/DiscordBot/bin/vscode", - "/property:GenerateFullPaths=true", - "/consoleloggerparameters:NoSummary" - ], - "problemMatcher": "$msCompile" - }, - { - "label": "publish", - "command": "dotnet", - "type": "process", - "args": [ - "publish", - "${workspaceFolder}/DiscordBot/DiscordBot.csproj", - "/property:GenerateFullPaths=true", - "/consoleloggerparameters:NoSummary" - ], - "problemMatcher": "$msCompile" - }, - { - "label": "watch", - "command": "dotnet", - "type": "process", - "args": [ - "watch", - "--project", - "${workspaceFolder}/DiscordBot/DiscordBot.csproj", - "run" - ], - "options": { - "cwd": "${workspaceFolder}/DiscordBot" - }, - "problemMatcher": "$msCompile" - } - ] + "version": "2.0.0", + "tasks": [ + { + "label": "build", + "command": "dotnet", + "type": "process", + "args": [ + "build", + "${workspaceFolder}/DiscordBot/DiscordBot.csproj", + "--configuration", + "Debug", + "--output", + "${workspaceFolder}/DiscordBot/bin/vscode", + "/property:GenerateFullPaths=true", + "/consoleloggerparameters:NoSummary" + ], + "problemMatcher": "$msCompile" + }, + { + "label": "publish", + "command": "dotnet", + "type": "process", + "args": [ + "publish", + "${workspaceFolder}/DiscordBot/DiscordBot.csproj", + "/property:GenerateFullPaths=true", + "/consoleloggerparameters:NoSummary" + ], + "problemMatcher": "$msCompile" + }, + { + "label": "watch", + "command": "dotnet", + "type": "process", + "args": [ + "watch", + "--project", + "${workspaceFolder}/DiscordBot/DiscordBot.csproj", + "run" + ], + "options": { + "cwd": "${workspaceFolder}/DiscordBot" + }, + "problemMatcher": "$msCompile" + }, + { + "label": "run", + "command": "dotnet", + "type": "process", + "args": [ + "run", + "--project", + "${workspaceFolder}/DiscordBot/DiscordBot.csproj" + ], + "options": { + "cwd": "${workspaceFolder}/DiscordBot" + }, + "problemMatcher": "$msCompile" + } + ] } diff --git a/DiscordBot.Tests/Recruitment/RecruitmentAcknowledgementTests.cs b/DiscordBot.Tests/Recruitment/RecruitmentAcknowledgementTests.cs new file mode 100644 index 00000000..c5fe9a73 --- /dev/null +++ b/DiscordBot.Tests/Recruitment/RecruitmentAcknowledgementTests.cs @@ -0,0 +1,112 @@ +using DiscordBot.Services.Recruitment; + +namespace DiscordBot.Tests.Recruitment; + +public class RecruitmentAcknowledgementTests +{ + [Fact] + public async Task ModalOwnershipUsesPersistedSnapshotEvenWhileWorkerIsBusyAndSubmissionRechecksOwner() + { + using var rig = new RecruitmentRig(); + rig.Discord.Threads["100"] = new("100", "11", "200", "Example listing", rig.Clock.Now, + false, false, false, false, Array.Empty(), "Budget AUD 500 for a short Unity project", null, null); + var service = new RecruitmentService(rig.Settings, rig.Discord, rig.Clock); + var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + Assert.False(service.CanOpenAcknowledgement("1", "100", "200")); + await service.StartAsync(); + try + { + var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var busy = service.RunAsync(async (_, ct) => { entered.SetResult(); await release.Task.WaitAsync(ct); return true; }); + await entered.Task; + Assert.True(service.CanOpenAcknowledgement("1", "100", "200")); + Assert.False(service.CanOpenAcknowledgement("1", "100", "201")); + Assert.False(service.CanOpenAcknowledgement("1", "100", "900")); + Assert.False(service.CanOpenAcknowledgement("2", "100", "200")); + Assert.False(service.CanOpenAcknowledgement("1", "101", "200")); + release.SetResult(); await busy; + var rejected = await service.RunAsync((engine, ct) => engine.AcknowledgeAsync("1", "100", "201", + engine.State!.Listings["100"].ValidCodes.Last(), ct)); + Assert.Contains("does not belong to you", rejected); + Assert.Null(await service.RunAsync((engine, _) => Task.FromResult(engine.State!.Listings["100"].AcknowledgedAt))); + var accepted = await service.RunAsync((engine, ct) => engine.AcknowledgeAsync("1", "100", "200", + engine.State!.Listings["100"].ValidCodes.Last(), ct)); + Assert.Contains("accepted", accepted); + await service.RunAsync((engine, ct) => engine.CloseOwnerAsync("1", "100", "200", ct)); + Assert.False(service.CanOpenAcknowledgement("1", "100", "200")); + } + finally { release.TrySetResult(); await service.StopAsync(default); } + Assert.False(service.CanOpenAcknowledgement("1", "100", "200")); + } + + [Fact] + public async Task AuthorIsPingedOnceAfterUsableControlsAndUpdatesAndRestartsDoNotRepeatIt() + { + using var rig = new RecruitmentRig(); await rig.Start(); var post = await rig.Post(); + var notice = Assert.Single(rig.Discord.Messages.Values, m => m.Message.MentionUserId is not null); + Assert.Equal((ulong)200, notice.Message.MentionUserId); + Assert.Equal(notice.Id, post.AcknowledgementNotice.MessageId); + Assert.Contains("<@200>", notice.Message.Content); + Assert.Contains("Guidelines", notice.Message.Content); Assert.Contains("Acknowledge", notice.Message.Content); + Assert.NotNull(post.PublicMessageId); Assert.NotNull(post.Deadline); + Assert.Contains("Check the card for your deadline", notice.Message.Content); + Assert.DoesNotContain("https://", notice.Message.Content); + Assert.False(notice.Message.OwnerControls); Assert.False(notice.Message.AcknowledgementRequired); + var sends = rig.Discord.Sends; + await rig.Engine.ThreadChangedAsync(post.Id, default); await rig.Sweep(); + var oldControls = post.PublicMessageId!; + await rig.Discord.DeleteAdvisoryAsync(post.Id, oldControls, default); + await rig.Engine.MessageDeletedAsync(post.Id, new HashSet { oldControls }, default); + Assert.NotEqual(oldControls, post.PublicMessageId); + Assert.Equal(sends + 1, rig.Discord.Sends); // Only the restored card is new. + sends = rig.Discord.Sends; + await rig.Restart(); + await rig.Ack(); await rig.Restart(); + Assert.Equal(sends, rig.Discord.Sends); + Assert.Single(rig.Discord.Messages.Values, m => m.Message.MentionUserId is not null); + } + + [Fact] + public async Task AmbiguousAuthorNoticeRecoversPersistedSendWithoutSecondMessage() + { + using var rig = new RecruitmentRig(); await rig.Start(); rig.Discord.LoseAcknowledgementNoticeResponse = true; + var post = await rig.Post(); + Assert.True(post.AcknowledgementNotice.SendPending); Assert.NotNull(post.AcknowledgementNotice.StartedAt); + Assert.NotNull(post.AcknowledgementNotice.Fault); Assert.Null(post.AcknowledgementNotice.MessageId); + var persisted = File.ReadAllText(Path.Combine(rig.Root, "recruitment/recruitment-state.json")); + Assert.Contains("Author notice response lost", persisted); + var sends = rig.Discord.Sends; + await rig.Restart(); + var recovered = rig.State.Listings[post.Id].AcknowledgementNotice; + Assert.False(recovered.SendPending); Assert.NotNull(recovered.MessageId); Assert.Null(recovered.Fault); + Assert.Equal(sends, rig.Discord.Sends); + Assert.Single(rig.Discord.Messages.Values, m => m.Message.MentionUserId is not null); + } + + [Fact] + public async Task NoticeFailureRetriesIndependentlyWithoutResettingAcknowledgementWindow() + { + using var rig = new RecruitmentRig(); await rig.Start(); + rig.Discord.AcknowledgementNoticeFailure = new TimeoutException("Notice delivery unavailable"); + var post = await rig.Post(); var deadline = post.Deadline; var delivered = post.PromptDeliveredAt; + Assert.NotNull(deadline); Assert.Null(post.Fault); Assert.Null(post.AcknowledgementNotice.MessageId); + rig.Clock.Now = post.AcknowledgementNotice.Fault!.RetryAt!.Value.AddTicks(1); + rig.Discord.AcknowledgementNoticeFailure = null; + await rig.Engine.RunDueAsync(default); + Assert.NotNull(post.AcknowledgementNotice.MessageId); Assert.Null(post.AcknowledgementNotice.Fault); + Assert.Equal(deadline, post.Deadline); Assert.Equal(delivered, post.PromptDeliveredAt); + } + + [Fact] + public async Task NoticeFailureDoesNotBlockAcknowledgementOrSendAnObsoletePingAfterAcceptance() + { + using var rig = new RecruitmentRig(); await rig.Start(); + rig.Discord.AcknowledgementNoticeFailure = new TimeoutException("Notice delivery unavailable"); + var post = await rig.Post(); + Assert.Contains("accepted", await rig.Ack()); + rig.Discord.AcknowledgementNoticeFailure = null; + rig.Clock.Advance(TimeSpan.FromMinutes(1)); await rig.Engine.RunDueAsync(default); await rig.Restart(); + Assert.DoesNotContain(rig.Discord.Messages.Values, m => m.Message.MentionUserId is not null); + Assert.NotNull(rig.State.Listings[post.Id].AcceptedAt); + } +} diff --git a/DiscordBot.Tests/Recruitment/RecruitmentBannerTests.cs b/DiscordBot.Tests/Recruitment/RecruitmentBannerTests.cs index 1f9352f9..31eb7e58 100644 --- a/DiscordBot.Tests/Recruitment/RecruitmentBannerTests.cs +++ b/DiscordBot.Tests/Recruitment/RecruitmentBannerTests.cs @@ -81,6 +81,7 @@ public async Task RenderIsBoundedDeterministicAndReadableWithoutColor() Assert.NotEmpty(RecruitmentBanner.Render(longChecks)); // Local review fixtures, intentionally outside repository discovery. Directory.CreateDirectory("/tmp/udc-t04-previews"); + File.WriteAllBytes("/tmp/udc-t04-previews/observations.png", RecruitmentBanner.Render(post.Facts.Checks)); image.Write("/tmp/udc-t04-previews/grid.png"); foreach (var width in new uint[] { 320, 360, 640 }) foreach (var light in new[] { false, true }) diff --git a/DiscordBot.Tests/Recruitment/RecruitmentPresentationTests.cs b/DiscordBot.Tests/Recruitment/RecruitmentPresentationTests.cs index 4005d611..142ec58b 100644 --- a/DiscordBot.Tests/Recruitment/RecruitmentPresentationTests.cs +++ b/DiscordBot.Tests/Recruitment/RecruitmentPresentationTests.cs @@ -30,9 +30,41 @@ public async Task PublicFallbackKeepsWarningsInstructionsAndAllObservations() Assert.Contains("Applicant fee", text); Assert.Contains("fee requested", text); Assert.Contains("Acknowledge", text); Assert.Contains("processing is paused", text); Assert.All(post.Facts.Checks, check => Assert.Contains(check.Label, text)); + Assert.Contains("**Job Duration:**", text); + Assert.DoesNotContain("(Green)", text); Assert.DoesNotContain("(Yellow)", text); Assert.DoesNotContain("trusted", text); Assert.DoesNotContain("no red flags", text); } + [Fact] + public async Task LegendUsesCompactStatusIconsAndBoldLabelsWhilePreservingEvidenceAndUnknowns() + { + using var rig = new RecruitmentRig(); await rig.Start(); var post = await rig.Post(); + post.Facts.Checks = new() + { + new("account-age", "Account Age", ObservationSeverity.Green, ObservationApplicability.Available, "At least 2 years"), + new("duration", "Job Duration", ObservationSeverity.Yellow, ObservationApplicability.Available, "Timing Unclear"), + new("compensation", "Compensation", ObservationSeverity.Red, ObservationApplicability.Available, "No Monetary Pay Found", "Unpaid position"), + new("portfolio", "Portfolio", ObservationSeverity.Green, ObservationApplicability.NotApplicable, "Not Applicable"), + new("join-date", "Join Date", ObservationSeverity.Red, ObservationApplicability.Unavailable, "Rejoined after posting"), + new("post-detail", "Post Detail", ObservationSeverity.Yellow, ObservationApplicability.Available, "159 Characters", + "Normalized body length is a heuristic, not proof of completeness or quality.") + }; + post.Facts.Observations = new() { new("fee", "Applicant fee", "pay **$50** <@123>") }; + var text = RecruitmentPresentation.Details(post); + Assert.Contains("🟢 **Account Age:** At least 2 years", text); + Assert.Contains("🔶 **Job Duration:** Timing Unclear", text); + Assert.Contains("🟥 **Compensation:** No Monetary Pay Found — Unpaid position", text); + Assert.Contains("⚪ **Portfolio:** Not Applicable", text); + Assert.Contains("⚪ **Join Date:** Rejoined after posting", text); + Assert.Contains("**Applicant fee:**", text); Assert.Contains("$50", text); Assert.DoesNotContain("<@123>", text); + Assert.DoesNotContain("Available /", text); Assert.DoesNotContain("NotApplicable /", text); + Assert.DoesNotContain("Server Roles excludes everyone", text); + Assert.DoesNotContain("Normalized body length is a heuristic", text); + Assert.Contains("length does not prove quality", text); Assert.Contains("privately", text); + Assert.InRange(text.Length, 1, 1400); + Assert.Contains("Job Duration: Timing Unclear (Amber)", RecruitmentBanner.Alt(post.Facts.Checks)); + } + [Fact] public async Task HobbyRecruitingKeepsNotApplicableFacts() { diff --git a/DiscordBot.Tests/Recruitment/RecruitmentPublicationTests.cs b/DiscordBot.Tests/Recruitment/RecruitmentPublicationTests.cs index b2fdefb1..d8382b90 100644 --- a/DiscordBot.Tests/Recruitment/RecruitmentPublicationTests.cs +++ b/DiscordBot.Tests/Recruitment/RecruitmentPublicationTests.cs @@ -118,6 +118,35 @@ public async Task InstalledSdkRemovesAcknowledgementButtonWhileKeepingReaderAndO Assert.Equal(writes, transport.MessageWrites.Count); } + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task InstalledSdkAuthorNoticeMentionsOnlyAuthorOnCreationAndNeverOnRecoveryOrEdit(bool loseResponse) + { + var transport = new PublicationTransport { MessageMissing = true, LoseCreateResponse = loseResponse }; + transport.Message["attachments"] = new JArray(); transport.Message["embeds"] = new JArray(); + using var client = await Client(transport); + var adapter = new DiscordRecruitmentAdapter(client, Settings()); + const string marker = "recruitment:100:acknowledgement"; + var message = new RecruitmentMessage($"<@200> please acknowledge. <@201> @everyone <@&50>\n||{marker}||", + marker, false, MentionUserId: 200); + var error = await Record.ExceptionAsync(() => adapter.UpsertAsync("100", null, false, DateTimeOffset.UtcNow, + message, () => { }, default)); + if (loseResponse) Assert.IsType(error); else Assert.Null(error); + var create = Assert.Single(transport.MessageWrites); + var mentions = create["allowed_mentions"]!; + Assert.Empty((JArray)mentions["parse"]!); + Assert.Equal(new[] { "200" }, ((JArray)mentions["users"]!).Values()); + Assert.False(mentions["roles"]?.Values().Any() ?? false); + var recovered = await adapter.UpsertAsync("100", null, true, DateTimeOffset.UtcNow, message, () => { }, default); + Assert.False(recovered.NewlySent); Assert.Single(transport.MessageWrites); + await adapter.UpsertAsync("100", recovered.Id, false, DateTimeOffset.UtcNow, + message with { Content = message.Content + "\nUpdated deadline." }, () => { }, default); + var edit = transport.MessageWrites.Last()["allowed_mentions"]!; + Assert.Empty((JArray)edit["parse"]!); + Assert.False(edit["users"]?.Values().Any() ?? false); + } + [Fact] public async Task DepartedMemberLeavesProfileFactsUnavailableWithoutLosingStarter() { diff --git a/DiscordBot.Tests/Recruitment/RecruitmentTestRig.cs b/DiscordBot.Tests/Recruitment/RecruitmentTestRig.cs index e5790180..8ed6ac4e 100644 --- a/DiscordBot.Tests/Recruitment/RecruitmentTestRig.cs +++ b/DiscordBot.Tests/Recruitment/RecruitmentTestRig.cs @@ -112,6 +112,8 @@ internal sealed class FakeRecruitmentDiscord : IRecruitmentDiscord public bool InventoryComplete { get; set; } = true; public bool FailStaff { get; set; } public bool LoseNextSendResponse { get; set; } + public bool LoseAcknowledgementNoticeResponse { get; set; } + public Exception? AcknowledgementNoticeFailure { get; set; } public bool LoseNextCloseResponse { get; set; } public bool LoseNextDeleteResponse { get; set; } public bool FailPermissions { get; set; } @@ -188,6 +190,7 @@ public Task UpsertAsync(string channelId, string? messageId, bool reco Upserts++; if (FailOverview && message.Marker.StartsWith("recruitment:overview:")) throw new TimeoutException("Overview unavailable"); if (FailStaff && channelId == "99") throw new InvalidOperationException("Feed unavailable"); + if (message.Marker.EndsWith(":acknowledgement") && AcknowledgementNoticeFailure is { } failure) throw failure; var key = channelId + ":" + message.Marker; if (Messages.TryGetValue(key, out var existing)) { @@ -196,6 +199,8 @@ public Task UpsertAsync(string channelId, string? messageId, bool reco } beforeWrite(); var id = (++_nextId).ToString(); Messages[key] = (id, message); Sends++; + if (LoseAcknowledgementNoticeResponse && message.Marker.EndsWith(":acknowledgement")) + { LoseAcknowledgementNoticeResponse = false; throw new TimeoutException("Author notice response lost"); } if (LoseNextSendResponse) { LoseNextSendResponse = false; throw new InvalidOperationException("Ambiguous send"); } return Task.FromResult(new Delivery(id, true)); } diff --git a/DiscordBot.Tests/Recruitment/RecruitmentWorkflowTests.cs b/DiscordBot.Tests/Recruitment/RecruitmentWorkflowTests.cs index 9a6b02ee..511cc049 100644 --- a/DiscordBot.Tests/Recruitment/RecruitmentWorkflowTests.cs +++ b/DiscordBot.Tests/Recruitment/RecruitmentWorkflowTests.cs @@ -115,8 +115,9 @@ public async Task AmbiguousSendRecoversOneMessagePerDestination() { using var rig = new RecruitmentRig(); await rig.Start(); rig.Discord.LoseNextSendResponse = true; await rig.Post(); await rig.Restart(); await rig.Sweep(); - Assert.Equal(2, rig.Discord.Sends); - Assert.Equal(2, rig.Discord.Messages.Count); + Assert.Equal(3, rig.Discord.Sends); + Assert.Equal(3, rig.Discord.Messages.Count); + Assert.Single(rig.Discord.Messages.Values, m => m.Message.MentionUserId == 200); Assert.NotNull(rig.State.Listings["100"].Deadline); } diff --git a/DiscordBot/Modules/Recruitment/RecruitmentModule.cs b/DiscordBot/Modules/Recruitment/RecruitmentModule.cs index 0891abad..2a002df9 100644 --- a/DiscordBot/Modules/Recruitment/RecruitmentModule.cs +++ b/DiscordBot/Modules/Recruitment/RecruitmentModule.cs @@ -113,7 +113,8 @@ public async Task SubmitConcern(string thread, ConcernModal modal) public async Task Acknowledge(string thread) { if (thread != Thread || Context.Guild is null) { await RespondAsync("This control belongs to another thread.", ephemeral: true); return; } - // The modal itself grants no authority; submission checks persisted ownership and eligibility. + if (!Recruitment.CanOpenAcknowledgement(Guild, thread, Actor)) + { await RespondAsync("Only the listing author can acknowledge the Guidelines for an open listing.", ephemeral: true); return; } await RespondWithModalAsync($"recruit:code:{thread}"); } public sealed class GuidelinesModal : IModal diff --git a/DiscordBot/Services/Recruitment/DiscordRecruitmentAdapter.cs b/DiscordBot/Services/Recruitment/DiscordRecruitmentAdapter.cs index c75a7bfd..08df3d32 100644 --- a/DiscordBot/Services/Recruitment/DiscordRecruitmentAdapter.cs +++ b/DiscordBot/Services/Recruitment/DiscordRecruitmentAdapter.cs @@ -315,7 +315,7 @@ public async Task UpsertAsync(string channelId, string? messageId, boo await archived.ModifyAsync(p => p.Archived = false, Options(ct)); unarchivedForEdit = true; } - await existing.ModifyAsync(p => { p.Content = message.Content; p.Components = components; p.Attachments = Array.Empty(); p.Embeds = message.Embed is null ? Array.Empty() : new[] { message.Embed }; p.AllowedMentions = AllowedMentions.None; }, Options(ct)); + await existing.ModifyAsync(p => { p.Content = message.Content; p.Components = components; p.Attachments = Array.Empty(); p.Embeds = message.Embed is null ? Array.Empty() : new[] { message.Embed }; p.AllowedMentions = new AllowedMentions(AllowedMentionTypes.None); }, Options(ct)); } // The caller persists this restoration intent before publication, so a restart can finish it. if (message.RestoreArchive && channel is IThreadChannel thread && (unarchivedForEdit || !thread.IsArchived)) @@ -331,7 +331,10 @@ public async Task UpsertAsync(string channelId, string? messageId, boo if (archivedThread.IsLocked && !message.RestoreArchive) throw new InvalidOperationException("A locked thread needs moderator review before receiving a challenge."); await archivedThread.ModifyAsync(p => p.Archived = false, Options(ct)); } - var sent = await channel.SendMessageAsync(message.Content, options: Options(ct), allowedMentions: AllowedMentions.None, components: components, embed: message.Embed); + // Only a new, explicit author notice may ping. Recovery edits always suppress mentions above. + var mentions = message.MentionUserId is { } author + ? new AllowedMentions(AllowedMentionTypes.None) { UserIds = new List { author } } : AllowedMentions.None; + var sent = await channel.SendMessageAsync(message.Content, options: Options(ct), allowedMentions: mentions, components: components, embed: message.Embed); if (message.RestoreArchive && channel is IThreadChannel restoreThread) await restoreThread.ModifyAsync(p => p.Archived = true, Options(ct)); return new Delivery(sent.Id.ToString(), true); diff --git a/DiscordBot/Services/Recruitment/IRecruitmentDiscord.cs b/DiscordBot/Services/Recruitment/IRecruitmentDiscord.cs index 16074b55..f2c6c8a3 100644 --- a/DiscordBot/Services/Recruitment/IRecruitmentDiscord.cs +++ b/DiscordBot/Services/Recruitment/IRecruitmentDiscord.cs @@ -13,7 +13,7 @@ public sealed record ThreadInventory(IReadOnlyList Threads, bool public enum RecruitmentMessageFormat { Standard, ComponentsV2 } public sealed record RecruitmentMedia(string FileName, byte[] Bytes, string Description); public sealed record RecruitmentMessage(string Content, string Marker, bool OwnerControls, bool RestoreArchive = false, Embed? Embed = null, string? TextFallback = null, bool AcknowledgementRequired = false, bool ReaderControls = false, RecruitmentMessageFormat Format = RecruitmentMessageFormat.Standard, - string? RenderIdentity = null, MessageComponent? Components = null, MessageComponent? FallbackComponents = null, RecruitmentMedia? Media = null, bool RestoreLocked = false) + string? RenderIdentity = null, MessageComponent? Components = null, MessageComponent? FallbackComponents = null, RecruitmentMedia? Media = null, bool RestoreLocked = false, ulong? MentionUserId = null) { public string RenderKey => RenderIdentity ?? Content + "\n" + OwnerControls + "\n" + AcknowledgementRequired + "\n" + ReaderControls + "\n" + EmbedKey(Embed); public static string EmbedKey(IEmbed? embed) => embed is null ? "" : Newtonsoft.Json.JsonConvert.SerializeObject(embed.ToEmbedBuilder().Build()); diff --git a/DiscordBot/Services/Recruitment/RecruitmentAcknowledgement.cs b/DiscordBot/Services/Recruitment/RecruitmentAcknowledgement.cs new file mode 100644 index 00000000..d52df190 --- /dev/null +++ b/DiscordBot/Services/Recruitment/RecruitmentAcknowledgement.cs @@ -0,0 +1,42 @@ +using System.IO; + +namespace DiscordBot.Services.Recruitment; + +public sealed partial class RecruitmentEngine +{ + private DateTimeOffset? AcknowledgementNoticeDue(Listing post) => + post.Status == ListingStatus.Open && !post.AcceptedAt.HasValue && !post.AcknowledgedAt.HasValue && + post.PublicMessageId is not null && post.Deadline > Now && + (post.AcknowledgementNotice.MessageId is null || post.AcknowledgementNotice.SendPending) + ? post.AcknowledgementNotice.Fault is { } fault ? fault.RetryAt : Now : null; + + private async Task PublishAcknowledgementNoticeAsync(Listing post, CancellationToken ct) + { + if (AcknowledgementNoticeDue(post) is not { } due || due > Now) return; + var notice = post.AcknowledgementNotice; + var marker = $"recruitment:{post.Id}:acknowledgement"; + // The card owns the current deadline and may be restored with a new message ID/window. + var message = new RecruitmentMessage($"<@{post.OwnerId}>, please read this forum's **Guidelines**, then use **Acknowledge** on the " + + $"observations card in this thread to enter its code. Check the card for your deadline.\n||{marker}||", + marker, false, MentionUserId: ulong.Parse(post.OwnerId)); + var recovering = notice.SendPending; + try + { + var delivery = await _discord.UpsertAsync(post.Id, notice.MessageId, recovering, notice.StartedAt ?? Now, + message, () => + { + if (!recovering) notice.StartedAt = Now; + notice.SendPending = true; Save(); + }, ct); + notice.MessageId = delivery.Id; notice.SendPending = false; notice.Fault = null; + Save(); + } + catch (Exception ex) when (ex is not IOException && ex is not OperationCanceledException) + { + // The public controls remain usable; an optional notification cannot reset the deadline + // or prevent a timely acknowledgement. Retry/recover independently without a second ping. + notice.Fault = RecruitmentFault.From(ex, Now, notice.Fault); + Save(); + } + } +} diff --git a/DiscordBot/Services/Recruitment/RecruitmentBanner.cs b/DiscordBot/Services/Recruitment/RecruitmentBanner.cs index 9efe64d3..aa2ae3a4 100644 --- a/DiscordBot/Services/Recruitment/RecruitmentBanner.cs +++ b/DiscordBot/Services/Recruitment/RecruitmentBanner.cs @@ -9,16 +9,19 @@ namespace DiscordBot.Services.Recruitment; /// Offline, bounded renderer. No author identifiers or downloaded content enter the image. public static class RecruitmentBanner { - public const string Version = "observations-grid-2"; - public const int Width = 1200, Height = 840; + public const string Version = "observations-grid-3"; + public const int Width = 1200, Height = 756; private static readonly object Gate = new(); private static readonly Dictionary Cache = new(); public static string Identity(string value) => Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(value))); - public static string Status(RecruitmentObservation c) => c.Applicability != ObservationApplicability.Available ? "Grey" : c.Severity.ToString(); + public static string Status(RecruitmentObservation c) => c.Applicability != ObservationApplicability.Available ? "Grey" : + c.Severity == ObservationSeverity.Yellow ? "Amber" : c.Severity.ToString(); + public static string Icon(RecruitmentObservation c) => Status(c) switch + { "Green" => "🟢", "Amber" => "🔶", "Red" => "🟥", _ => "⚪" }; public static string Alt(IReadOnlyList checks) => string.Join("; ", checks.Select(c => $"{c.Label}: {RecruitmentPresentation.Safe(c.Value, 60)} ({Status(c)})")); public static string GalleryAlt(IReadOnlyList checks) { - var labels = new[] { "Age", "Joined", "Roles", "Pay", "Duration", "Portfolio", "DM", "Avatar", "Detail" }; + var labels = new[] { "Age", "Joined", "Roles", "Pay", "Job Duration", "Portfolio", "DM", "Avatar", "Detail" }; var text = string.Join("; ", checks.Select((c, i) => $"{labels[i]}: {RecruitmentPresentation.Safe(c.Value, 60)}")); // The installed Discord.Net gallery builder caps descriptions at 256. The attachment // description and Legend retain full labels/statuses even for unexpected long values. @@ -36,24 +39,27 @@ public static byte[] Render(IReadOnlyList checks, string using var image = new MagickImage(new MagickColor("#20242B"), Width, Height); for (var i = 0; i < 9; i++) { - var c = checks[i]; var x = i % 3 * 400 + 12; var y = i / 3 * 280 + 12; + var c = checks[i]; var x = i % 3 * 400 + 12; var y = i / 3 * 252 + 12; var status = Status(c); - var color = status switch { "Green" => "#66D5A0", "Yellow" => "#F1CD68", "Red" => "#FF9494", _ => "#BAC2CE" }; - var d = new Drawables().FillColor(new MagickColor("#2C323B")).Rectangle(x, y, x + 376, y + 256) - .Font(fontPath).FontPointSize(40).FillColor(MagickColors.White).Text(x + 16, y + 48, c.Label) + var color = status switch { "Green" => "#66D5A0", "Amber" => "#F1CD68", "Red" => "#FF9494", _ => "#BAC2CE" }; + var d = new Drawables().FillColor(new MagickColor("#2C323B")).Rectangle(x, y, x + 376, y + 228) + // A small matching stroke gives bundled-font headings a consistent bold weight. + .Font(fontPath).FontPointSize(40).FillColor(MagickColors.White).StrokeColor(MagickColors.White).StrokeWidth(1.2) + .Text(x + 16, y + 48, c.Label).StrokeWidth(0).StrokeColor(MagickColors.Transparent) .FillColor(new MagickColor(color)); - // Shapes and explicit words preserve meaning without color. - if (status == "Green") d.Circle(x + 26, y + 229, x + 36, y + 229); - else if (status == "Yellow") d.Polygon(new PointD(x + 16, y + 239), new PointD(x + 26, y + 217), new PointD(x + 36, y + 239)); - else if (status == "Red") d.Rectangle(x + 16, y + 219, x + 36, y + 239); - else d.FillColor(MagickColors.Transparent).StrokeColor(new MagickColor(color)).StrokeWidth(3).Circle(x + 26, y + 229, x + 36, y + 229).StrokeWidth(0); - d.FillColor(MagickColors.White).FontPointSize(40).Text(x + 48, y + 240, status); + // Distinct shapes and full status names in alt text keep meaning without color. + if (status == "Green") d.Circle(x + 188, y + 205, x + 204, y + 205); + else if (status == "Amber") d.Polygon(new PointD(x + 188, y + 187), new PointD(x + 206, y + 205), + new PointD(x + 188, y + 223), new PointD(x + 170, y + 205)); + else if (status == "Red") d.Rectangle(x + 172, y + 189, x + 204, y + 221); + else d.FillColor(MagickColors.Transparent).StrokeColor(new MagickColor(color)).StrokeWidth(3) + .Circle(x + 188, y + 205, x + 204, y + 205).StrokeWidth(0).StrokeColor(MagickColors.Transparent); var words = RecruitmentPresentation.Safe(c.Value, 60).Split(' ', StringSplitOptions.RemoveEmptyEntries); var lines = new List(); var line = ""; foreach (var word in words) { if (line.Length + word.Length > 16 && line.Length > 0) { lines.Add(line); line = ""; } line += (line.Length > 0 ? " " : "") + word; } lines.Add(line); - d.FontPointSize(40); - for (var n = 0; n < Math.Min(3, lines.Count); n++) d.Text(x + 16, y + 100 + n * 44, lines[n].Length > 17 ? lines[n][..16] + "…" : lines[n]); + d.FillColor(MagickColors.White).FontPointSize(40); + for (var n = 0; n < Math.Min(3, lines.Count); n++) d.Text(x + 16, y + 92 + n * 40, lines[n].Length > 17 ? lines[n][..16] + "…" : lines[n]); d.Draw(image); } image.Format = MagickFormat.Png; image.Strip(); diff --git a/DiscordBot/Services/Recruitment/RecruitmentEngine.cs b/DiscordBot/Services/Recruitment/RecruitmentEngine.cs index 6692d1de..e9d63fad 100644 --- a/DiscordBot/Services/Recruitment/RecruitmentEngine.cs +++ b/DiscordBot/Services/Recruitment/RecruitmentEngine.cs @@ -103,7 +103,7 @@ public async Task SweepAsync(CancellationToken ct, bool retryHeld = true) State.RecoveryFault = null; await DiscoverAsync(ct); if (retryHeld) - foreach (var post in State.Listings.Values) post.PublicPin.Fault = null; + foreach (var post in State.Listings.Values) { post.PublicPin.Fault = null; post.AcknowledgementNotice.Fault = null; } if (retryHeld) foreach (var notice in State.Listings.Values.SelectMany(p => p.Attention).Where(n => n.MessageId is null || n.SendPending)) notice.Fault = null; var cursor = ulong.TryParse(State.SweepAfterId, out var savedCursor) ? savedCursor : 0; @@ -280,6 +280,7 @@ private async Task ProcessAsync(Listing post, CancellationToken ct) post.Version++; Save(); await PublishAsync(post, ct); } + await PublishAcknowledgementNoticeAsync(post, ct); if (EvidenceCurrent && post.AcknowledgedAt.HasValue && !post.AcceptedAt.HasValue) await RefreshBlockersAsync(post, ct); if (EvidenceCurrent && RecruitmentPolicy.TryAccept(State, post, Now, _settings.CooldownDays)) diff --git a/DiscordBot/Services/Recruitment/RecruitmentEngineScheduling.cs b/DiscordBot/Services/Recruitment/RecruitmentEngineScheduling.cs index d5a2b2a0..a06837fa 100644 --- a/DiscordBot/Services/Recruitment/RecruitmentEngineScheduling.cs +++ b/DiscordBot/Services/Recruitment/RecruitmentEngineScheduling.cs @@ -60,6 +60,7 @@ post.Status is ListingStatus.Open or ListingStatus.Closed && post.PublicMessageI if (_recoverWindows.Contains(post.Id)) return Now; if (!post.PromptDeliveredAt.HasValue && !post.AcceptedAt.HasValue) return Now; var dates = new List(); + if (AcknowledgementNoticeDue(post) is { } noticeDue) dates.Add(noticeDue); if (attention.HasValue) dates.Add(attention.Value); if (pinDue.HasValue) dates.Add(pinDue.Value); if (!post.Exempt && !post.Pinned) @@ -103,7 +104,8 @@ public void InvalidateAccess(string? forum = null) State.Overview.Fault = null; foreach (var f in State.Forums.Values.Where(f => forum is null || f.ForumId == forum)) f.Fault = null; foreach (var f in State.Forums.Values.Where(f => forum is null || f.ForumId == forum)) f.Reference.Fault = null; - foreach (var post in State.Listings.Values.Where(p => forum is null || p.ForumId == forum)) { post.Fault = null; post.PublicPin.Fault = null; } + foreach (var post in State.Listings.Values.Where(p => forum is null || p.ForumId == forum)) + { post.Fault = null; post.PublicPin.Fault = null; post.AcknowledgementNotice.Fault = null; } _nextAudit = Now; MarkEvidenceGap(); } diff --git a/DiscordBot/Services/Recruitment/RecruitmentPresentation.cs b/DiscordBot/Services/Recruitment/RecruitmentPresentation.cs index faf9e5e3..483671e3 100644 --- a/DiscordBot/Services/Recruitment/RecruitmentPresentation.cs +++ b/DiscordBot/Services/Recruitment/RecruitmentPresentation.cs @@ -37,7 +37,7 @@ MessageComponent Build(bool image) { var children = new List { new TextDisplayBuilder($"## {Heading}") }; if (image) children.Add(new MediaGalleryBuilder().AddItem("attachment://" + fileName, RecruitmentBanner.GalleryAlt(checks))); - else children.Add(new TextDisplayBuilder(string.Join("\n", checks.Select(c => $"**{c.Label}:** {Safe(c.Value, 60)} ({RecruitmentBanner.Status(c)})")))); + else children.Add(new TextDisplayBuilder(string.Join("\n", checks.Select(c => $"{RecruitmentBanner.Icon(c)} **{c.Label}:** {Safe(c.Value, 60)}")))); children.Add(new TextDisplayBuilder($"*{Guidance}*")); if (foot.Length > 0) children.Add(new TextDisplayBuilder(foot)); if (image && avatar is not null) children.Add(new SectionBuilder(new ThumbnailBuilder(new UnfurledMediaItemProperties(avatar), "Author avatar"), new[] { new TextDisplayBuilder(identity) })); @@ -71,9 +71,12 @@ MessageComponent Build(bool image) public static string Details(Listing post) { return "**Legend · Recruitment observations**\n" + string.Join("\n", post.Facts.Checks.Select(c => - $"{c.Label}: {Safe(c.Value, 60)}. {c.Applicability} / {c.Severity}." + (string.IsNullOrEmpty(c.Evidence) ? "" : $" {Safe(c.Evidence, 370)}"))) + - (post.Facts.Observations.Count == 0 ? "" : "\n**Additional evidence**\n" + string.Join("\n", post.Facts.Observations.Take(4).Select(o => $"• {Safe(o.Description, 140)}: “{Safe(o.Evidence, 180)}”."))) + - "\nGreen describes an observed fact; yellow asks for clarification; red highlights a missing expected detail or concern. Grey means Unavailable or Not Applicable. Colors never establish trust.\nAccount Age: at posting, green ≥2 years; yellow ≥6 months. Join Date: at posting, green ≥1 year; yellow ≥2 months; later joins are rejoined, with no invented tenure. Server Roles excludes everyone.\nCompensation: monetary rate/budget, unclear or mixed terms, or no monetary pay; hobby is Not Applicable. Duration seeks an explicit work window/availability. Portfolio recognizes specific supported-platform work links, without endorsement; recruiting is Not Applicable; attached samples alone request clarification for paid for-hire.\nDM Requests flags affirmative DM-first instructions, more strongly below 120 body characters. Profile Image describes custom/default appearance only. Post Detail: green ≥300, yellow 120–299, red <120 normalized characters; URLs and markup excluded.\nObservations use starter and membership data; unavailable data is not evidence against the author. Roles, avatars, links and length do not establish trust or authorship. Reports go privately to moderators."; + $"{RecruitmentBanner.Icon(c)} **{c.Label}:** {Safe(c.Value, 60)}" + + (c.Id == "post-detail" || string.IsNullOrWhiteSpace(c.Evidence) ? "" : $" — {Safe(c.Evidence, 120)}"))) + + (post.Facts.Observations.Count == 0 ? "" : "\n\n**Additional evidence**\n" + string.Join("\n", post.Facts.Observations.Take(4).Select(o => $"• **{Safe(o.Description, 80)}:** “{Safe(o.Evidence, 120)}”."))) + + "\n\n🟢 Observed fact · 🔶 Clarification needed · 🟥 Missing detail or concern · ⚪ Unavailable or not applicable" + + "\nAges are measured at posting. Job Duration looks for a work window or availability. Portfolio links are detected, not verified. Post Detail excludes links and formatting; length does not prove quality." + + "\nThese observations do not establish trust or authorship; unavailable data is not evidence against the author. Use **Report concern** to contact moderators privately."; } public static RecruitmentMessage Staff(RecruitmentState state, Listing post, RecruitmentSettings settings) diff --git a/DiscordBot/Services/Recruitment/RecruitmentService.cs b/DiscordBot/Services/Recruitment/RecruitmentService.cs index 8ebfea5f..2520b1d9 100644 --- a/DiscordBot/Services/Recruitment/RecruitmentService.cs +++ b/DiscordBot/Services/Recruitment/RecruitmentService.cs @@ -294,6 +294,12 @@ private Task MemberUpdated(Cacheable before, SocketGuild return Task.CompletedTask; } + // Use the immutable ownership snapshot so opening a modal does not wait behind Discord work. + // Submission still authorizes against the persisted listing on the serialized worker. + public bool CanOpenAcknowledgement(string guild, string thread, string actor) => + _settings.Recruitment.Enabled && !_stop.IsCancellationRequested && guild == _settings.GuildId.ToString() && + Volatile.Read(ref _listingOwners).TryGetValue(actor, out var threads) && threads.Contains(thread); + public bool CanReadStatus(string guild, string actor) => guild == _settings.GuildId.ToString() && ulong.TryParse(actor, out var id) && _client?.GetGuild(_settings.GuildId)?.GetUser(id) is { } user && (user.GuildPermissions.Administrator || user.Roles.Any(r => r.Id == _settings.Roles.Moderator)); diff --git a/DiscordBot/Services/Recruitment/RecruitmentSignals.cs b/DiscordBot/Services/Recruitment/RecruitmentSignals.cs index 9c05c408..f1991825 100644 --- a/DiscordBot/Services/Recruitment/RecruitmentSignals.cs +++ b/DiscordBot/Services/Recruitment/RecruitmentSignals.cs @@ -43,8 +43,8 @@ void Age(string id, string label, DateTimeOffset? date, int highMonths, int lowM else Add("compensation", "Compensation", payment.Kind == CompensationKind.Rate ? ObservationSeverity.Green : compensationRed ? ObservationSeverity.Red : ObservationSeverity.Yellow, payment.Kind switch { CompensationKind.Rate => "Rate Stated", CompensationKind.Mixed => "Mixed Terms", CompensationKind.MonetaryPromise => "Rate Unclear", CompensationKind.Unpaid => "Unpaid / Exposure", CompensationKind.RevenueShare => "Revenue Share Only", _ => "No Monetary Pay Found" }, payment.Evidence); var duration = RecruitmentContent.Duration(live.Starter, hire); - if (duration is null) Unknown("duration", "Duration"); - else Add("duration", "Duration", duration.Length > 0 ? ObservationSeverity.Green : paid && compensationRed ? ObservationSeverity.Red : ObservationSeverity.Yellow, + if (duration is null) Unknown("duration", "Job Duration"); + else Add("duration", "Job Duration", duration.Length > 0 ? ObservationSeverity.Green : paid && compensationRed ? ObservationSeverity.Red : ObservationSeverity.Yellow, duration.Length > 0 ? "Window Stated" : "Timing Unclear", duration); var portfolio = RecruitmentContent.ReadPortfolio(live.Starter, live.Attachments, supportedHosts); if (!hire) Na("portfolio", "Portfolio"); diff --git a/DiscordBot/Services/Recruitment/RecruitmentState.cs b/DiscordBot/Services/Recruitment/RecruitmentState.cs index 2188fb98..e88818cb 100644 --- a/DiscordBot/Services/Recruitment/RecruitmentState.cs +++ b/DiscordBot/Services/Recruitment/RecruitmentState.cs @@ -99,6 +99,7 @@ public sealed class Listing public List ValidCodes { get; set; } = new(); public DateTimeOffset? LastCodeAttemptAt { get; set; } public DateTimeOffset? AttemptPausedUntil { get; set; } + public AcknowledgementNoticeReceipt AcknowledgementNotice { get; set; } = new(); public MessagePinReceipt PublicPin { get; set; } = new(); public string? PublicMessageId { get; set; } public string? StaffMessageId { get; set; } @@ -136,6 +137,14 @@ public sealed class Listing public List Attention { get; set; } = new(); } +public sealed class AcknowledgementNoticeReceipt +{ + public string? MessageId { get; set; } + public bool SendPending { get; set; } + public DateTimeOffset? StartedAt { get; set; } + public RecruitmentFault? Fault { get; set; } +} + public sealed class RecruitmentReport { public string ReporterId { get; set; } = ""; diff --git a/DiscordBot/Services/Recruitment/RecruitmentStateStore.cs b/DiscordBot/Services/Recruitment/RecruitmentStateStore.cs index 363dc76c..7a9971f8 100644 --- a/DiscordBot/Services/Recruitment/RecruitmentStateStore.cs +++ b/DiscordBot/Services/Recruitment/RecruitmentStateStore.cs @@ -129,6 +129,8 @@ state.Forums is null || state.Listings is null || state.Authors is null || state (p.PublicMediaAttachmentId is null) != (p.PublicMediaFileName is null) || p.PublicMediaFileName is { } fileName && (fileName.Length > 120 || !fileName.StartsWith("recruitment-observations-", StringComparison.Ordinal) || !fileName.EndsWith(".png", StringComparison.Ordinal)) || p.PublicRestoreLocked && !p.PublicRestoreArchive || + p.AcknowledgementNotice is null || p.AcknowledgementNotice.MessageId is not null && !Id(p.AcknowledgementNotice.MessageId) || + p.AcknowledgementNotice.SendPending && p.AcknowledgementNotice.StartedAt is null || p.PublicPin is null || p.PublicPin.MessageId is not null && !Id(p.PublicPin.MessageId) || p.PublicPin.Confirmed && p.PublicPin.MessageId is null || p.PublicPin.RestoreLocked && !p.PublicPin.RestoreArchive || From 8e48438f9cd50a4ebede553a95dabec1918a5499 Mon Sep 17 00:00:00 2001 From: James Kellie Date: Mon, 19 Oct 2026 21:02:12 +1000 Subject: [PATCH 24/31] Organize recruitment responsibilities and test suites --- .../{ => Analysis}/RecruitmentContentTests.cs | 0 .../RecruitmentPortfolioTests.cs | 0 .../{ => Analysis}/RecruitmentSignalTests.cs | 0 .../{ => Discord}/RecruitmentPinTests.cs | 0 .../RecruitmentPublicationTests.cs | 0 .../RecruitmentReferenceTests.cs | 0 .../RecruitmentTransportTests.cs | 0 .../RecruitmentStorageAndSetupTests.cs | 0 .../RecruitmentBannerTests.cs | 0 .../RecruitmentPresentationTests.cs | 0 .../{ => Runtime}/RecruitmentOverviewTests.cs | 0 .../RecruitmentSchedulingTests.cs | 0 .../{ => Support}/RecruitmentTestRig.cs | 0 .../RecruitmentAcknowledgementTests.cs | 0 .../RecruitmentAttemptTests.cs | 0 .../RecruitmentEvidenceTests.cs | 0 .../RecruitmentFollowUpTests.cs | 0 .../RecruitmentRecoveryTests.cs | 0 .../{ => Workflows}/RecruitmentReportTests.cs | 0 .../RecruitmentSubmissionTests.cs | 0 .../RecruitmentWorkflowTests.cs | 0 .../Contracts}/IRecruitmentDiscord.cs | 20 -- .../Contracts/RecruitmentMessages.cs | 11 + .../Contracts/RecruitmentSnapshots.cs | 12 + .../RecruitmentAcknowledgement.cs | 0 .../{ => Application}/RecruitmentAttempts.cs | 0 .../{ => Application}/RecruitmentAttention.cs | 0 .../{ => Application}/RecruitmentEngine.cs | 0 .../RecruitmentEngineScheduling.cs | 0 .../{ => Application}/RecruitmentEvidence.cs | 0 .../{ => Application}/RecruitmentOverview.cs | 0 .../{ => Application}/RecruitmentPins.cs | 0 .../RecruitmentPortfolioCommands.cs | 36 +++ .../RecruitmentReferences.cs | 0 .../{ => Application}/RecruitmentReports.cs | 0 .../RecruitmentSetup.cs} | 65 ---- .../Analysis}/RecruitmentContent.cs | 0 .../Analysis/RecruitmentPortfolioDomains.cs | 56 ++++ .../Analysis}/RecruitmentPortfolioUrls.cs | 0 .../Analysis}/RecruitmentSignals.cs | 0 .../Domain/Models/AuthorHistory.cs | 18 ++ .../Domain/Models/DeliveryReceipts.cs | 31 ++ .../Recruitment/Domain/Models/ForumReceipt.cs | 40 +++ .../Recruitment/Domain/Models/Listing.cs | 68 ++++ .../Domain/Models/ListingEvidence.cs | 58 ++++ .../Domain/Models/PortfolioRegistry.cs | 14 + .../Domain/Models/RecruitmentAction.cs | 21 ++ .../{ => Domain/Models}/RecruitmentFault.cs | 0 .../Domain/Models/RecruitmentState.cs | 38 +++ .../Domain/Models/TagDefinition.cs | 14 + .../Domain/Policies/RecruitmentPolicy.cs | 57 ++++ .../Assets/RecruitmentAssets.cs | 63 ++++ .../Discord}/DiscordRecruitmentAdapter.cs | 0 .../Discord}/DiscordRecruitmentDelivery.cs | 0 .../Discord}/DiscordRecruitmentPins.cs | 0 .../Discord}/DiscordRecruitmentReferences.cs | 0 .../Transport}/RecruitmentForumTransport.cs | 0 .../Transport}/RecruitmentMessageTransport.cs | 0 .../Discord/Transport}/RecruitmentRequests.cs | 0 .../Persistence}/RecruitmentStateStore.cs | 0 .../{ => Presentation}/RecruitmentBanner.cs | 0 .../RecruitmentPresentation.cs | 0 .../RecruitmentPortfolioRegistry.cs | 98 ------ .../Services/Recruitment/RecruitmentState.cs | 303 ------------------ .../{ => Runtime}/RecruitmentHealth.cs | 0 65 files changed, 537 insertions(+), 486 deletions(-) rename DiscordBot.Tests/Recruitment/{ => Analysis}/RecruitmentContentTests.cs (100%) rename DiscordBot.Tests/Recruitment/{ => Analysis}/RecruitmentPortfolioTests.cs (100%) rename DiscordBot.Tests/Recruitment/{ => Analysis}/RecruitmentSignalTests.cs (100%) rename DiscordBot.Tests/Recruitment/{ => Discord}/RecruitmentPinTests.cs (100%) rename DiscordBot.Tests/Recruitment/{ => Discord}/RecruitmentPublicationTests.cs (100%) rename DiscordBot.Tests/Recruitment/{ => Discord}/RecruitmentReferenceTests.cs (100%) rename DiscordBot.Tests/Recruitment/{ => Discord}/RecruitmentTransportTests.cs (100%) rename DiscordBot.Tests/Recruitment/{ => Persistence}/RecruitmentStorageAndSetupTests.cs (100%) rename DiscordBot.Tests/Recruitment/{ => Presentation}/RecruitmentBannerTests.cs (100%) rename DiscordBot.Tests/Recruitment/{ => Presentation}/RecruitmentPresentationTests.cs (100%) rename DiscordBot.Tests/Recruitment/{ => Runtime}/RecruitmentOverviewTests.cs (100%) rename DiscordBot.Tests/Recruitment/{ => Runtime}/RecruitmentSchedulingTests.cs (100%) rename DiscordBot.Tests/Recruitment/{ => Support}/RecruitmentTestRig.cs (100%) rename DiscordBot.Tests/Recruitment/{ => Workflows}/RecruitmentAcknowledgementTests.cs (100%) rename DiscordBot.Tests/Recruitment/{ => Workflows}/RecruitmentAttemptTests.cs (100%) rename DiscordBot.Tests/Recruitment/{ => Workflows}/RecruitmentEvidenceTests.cs (100%) rename DiscordBot.Tests/Recruitment/{ => Workflows}/RecruitmentFollowUpTests.cs (100%) rename DiscordBot.Tests/Recruitment/{ => Workflows}/RecruitmentRecoveryTests.cs (100%) rename DiscordBot.Tests/Recruitment/{ => Workflows}/RecruitmentReportTests.cs (100%) rename DiscordBot.Tests/Recruitment/{ => Workflows}/RecruitmentSubmissionTests.cs (100%) rename DiscordBot.Tests/Recruitment/{ => Workflows}/RecruitmentWorkflowTests.cs (100%) rename DiscordBot/Services/Recruitment/{ => Application/Contracts}/IRecruitmentDiscord.cs (52%) create mode 100644 DiscordBot/Services/Recruitment/Application/Contracts/RecruitmentMessages.cs create mode 100644 DiscordBot/Services/Recruitment/Application/Contracts/RecruitmentSnapshots.cs rename DiscordBot/Services/Recruitment/{ => Application}/RecruitmentAcknowledgement.cs (100%) rename DiscordBot/Services/Recruitment/{ => Application}/RecruitmentAttempts.cs (100%) rename DiscordBot/Services/Recruitment/{ => Application}/RecruitmentAttention.cs (100%) rename DiscordBot/Services/Recruitment/{ => Application}/RecruitmentEngine.cs (100%) rename DiscordBot/Services/Recruitment/{ => Application}/RecruitmentEngineScheduling.cs (100%) rename DiscordBot/Services/Recruitment/{ => Application}/RecruitmentEvidence.cs (100%) rename DiscordBot/Services/Recruitment/{ => Application}/RecruitmentOverview.cs (100%) rename DiscordBot/Services/Recruitment/{ => Application}/RecruitmentPins.cs (100%) create mode 100644 DiscordBot/Services/Recruitment/Application/RecruitmentPortfolioCommands.cs rename DiscordBot/Services/Recruitment/{ => Application}/RecruitmentReferences.cs (100%) rename DiscordBot/Services/Recruitment/{ => Application}/RecruitmentReports.cs (100%) rename DiscordBot/Services/Recruitment/{RecruitmentAssets.cs => Application/RecruitmentSetup.cs} (71%) rename DiscordBot/Services/Recruitment/{ => Domain/Analysis}/RecruitmentContent.cs (100%) create mode 100644 DiscordBot/Services/Recruitment/Domain/Analysis/RecruitmentPortfolioDomains.cs rename DiscordBot/Services/Recruitment/{ => Domain/Analysis}/RecruitmentPortfolioUrls.cs (100%) rename DiscordBot/Services/Recruitment/{ => Domain/Analysis}/RecruitmentSignals.cs (100%) create mode 100644 DiscordBot/Services/Recruitment/Domain/Models/AuthorHistory.cs create mode 100644 DiscordBot/Services/Recruitment/Domain/Models/DeliveryReceipts.cs create mode 100644 DiscordBot/Services/Recruitment/Domain/Models/ForumReceipt.cs create mode 100644 DiscordBot/Services/Recruitment/Domain/Models/Listing.cs create mode 100644 DiscordBot/Services/Recruitment/Domain/Models/ListingEvidence.cs create mode 100644 DiscordBot/Services/Recruitment/Domain/Models/PortfolioRegistry.cs create mode 100644 DiscordBot/Services/Recruitment/Domain/Models/RecruitmentAction.cs rename DiscordBot/Services/Recruitment/{ => Domain/Models}/RecruitmentFault.cs (100%) create mode 100644 DiscordBot/Services/Recruitment/Domain/Models/RecruitmentState.cs create mode 100644 DiscordBot/Services/Recruitment/Domain/Models/TagDefinition.cs create mode 100644 DiscordBot/Services/Recruitment/Domain/Policies/RecruitmentPolicy.cs create mode 100644 DiscordBot/Services/Recruitment/Infrastructure/Assets/RecruitmentAssets.cs rename DiscordBot/Services/Recruitment/{ => Infrastructure/Discord}/DiscordRecruitmentAdapter.cs (100%) rename DiscordBot/Services/Recruitment/{ => Infrastructure/Discord}/DiscordRecruitmentDelivery.cs (100%) rename DiscordBot/Services/Recruitment/{ => Infrastructure/Discord}/DiscordRecruitmentPins.cs (100%) rename DiscordBot/Services/Recruitment/{ => Infrastructure/Discord}/DiscordRecruitmentReferences.cs (100%) rename DiscordBot/Services/Recruitment/{ => Infrastructure/Discord/Transport}/RecruitmentForumTransport.cs (100%) rename DiscordBot/Services/Recruitment/{ => Infrastructure/Discord/Transport}/RecruitmentMessageTransport.cs (100%) rename DiscordBot/Services/Recruitment/{ => Infrastructure/Discord/Transport}/RecruitmentRequests.cs (100%) rename DiscordBot/Services/Recruitment/{ => Infrastructure/Persistence}/RecruitmentStateStore.cs (100%) rename DiscordBot/Services/Recruitment/{ => Presentation}/RecruitmentBanner.cs (100%) rename DiscordBot/Services/Recruitment/{ => Presentation}/RecruitmentPresentation.cs (100%) delete mode 100644 DiscordBot/Services/Recruitment/RecruitmentPortfolioRegistry.cs delete mode 100644 DiscordBot/Services/Recruitment/RecruitmentState.cs rename DiscordBot/Services/Recruitment/{ => Runtime}/RecruitmentHealth.cs (100%) diff --git a/DiscordBot.Tests/Recruitment/RecruitmentContentTests.cs b/DiscordBot.Tests/Recruitment/Analysis/RecruitmentContentTests.cs similarity index 100% rename from DiscordBot.Tests/Recruitment/RecruitmentContentTests.cs rename to DiscordBot.Tests/Recruitment/Analysis/RecruitmentContentTests.cs diff --git a/DiscordBot.Tests/Recruitment/RecruitmentPortfolioTests.cs b/DiscordBot.Tests/Recruitment/Analysis/RecruitmentPortfolioTests.cs similarity index 100% rename from DiscordBot.Tests/Recruitment/RecruitmentPortfolioTests.cs rename to DiscordBot.Tests/Recruitment/Analysis/RecruitmentPortfolioTests.cs diff --git a/DiscordBot.Tests/Recruitment/RecruitmentSignalTests.cs b/DiscordBot.Tests/Recruitment/Analysis/RecruitmentSignalTests.cs similarity index 100% rename from DiscordBot.Tests/Recruitment/RecruitmentSignalTests.cs rename to DiscordBot.Tests/Recruitment/Analysis/RecruitmentSignalTests.cs diff --git a/DiscordBot.Tests/Recruitment/RecruitmentPinTests.cs b/DiscordBot.Tests/Recruitment/Discord/RecruitmentPinTests.cs similarity index 100% rename from DiscordBot.Tests/Recruitment/RecruitmentPinTests.cs rename to DiscordBot.Tests/Recruitment/Discord/RecruitmentPinTests.cs diff --git a/DiscordBot.Tests/Recruitment/RecruitmentPublicationTests.cs b/DiscordBot.Tests/Recruitment/Discord/RecruitmentPublicationTests.cs similarity index 100% rename from DiscordBot.Tests/Recruitment/RecruitmentPublicationTests.cs rename to DiscordBot.Tests/Recruitment/Discord/RecruitmentPublicationTests.cs diff --git a/DiscordBot.Tests/Recruitment/RecruitmentReferenceTests.cs b/DiscordBot.Tests/Recruitment/Discord/RecruitmentReferenceTests.cs similarity index 100% rename from DiscordBot.Tests/Recruitment/RecruitmentReferenceTests.cs rename to DiscordBot.Tests/Recruitment/Discord/RecruitmentReferenceTests.cs diff --git a/DiscordBot.Tests/Recruitment/RecruitmentTransportTests.cs b/DiscordBot.Tests/Recruitment/Discord/RecruitmentTransportTests.cs similarity index 100% rename from DiscordBot.Tests/Recruitment/RecruitmentTransportTests.cs rename to DiscordBot.Tests/Recruitment/Discord/RecruitmentTransportTests.cs diff --git a/DiscordBot.Tests/Recruitment/RecruitmentStorageAndSetupTests.cs b/DiscordBot.Tests/Recruitment/Persistence/RecruitmentStorageAndSetupTests.cs similarity index 100% rename from DiscordBot.Tests/Recruitment/RecruitmentStorageAndSetupTests.cs rename to DiscordBot.Tests/Recruitment/Persistence/RecruitmentStorageAndSetupTests.cs diff --git a/DiscordBot.Tests/Recruitment/RecruitmentBannerTests.cs b/DiscordBot.Tests/Recruitment/Presentation/RecruitmentBannerTests.cs similarity index 100% rename from DiscordBot.Tests/Recruitment/RecruitmentBannerTests.cs rename to DiscordBot.Tests/Recruitment/Presentation/RecruitmentBannerTests.cs diff --git a/DiscordBot.Tests/Recruitment/RecruitmentPresentationTests.cs b/DiscordBot.Tests/Recruitment/Presentation/RecruitmentPresentationTests.cs similarity index 100% rename from DiscordBot.Tests/Recruitment/RecruitmentPresentationTests.cs rename to DiscordBot.Tests/Recruitment/Presentation/RecruitmentPresentationTests.cs diff --git a/DiscordBot.Tests/Recruitment/RecruitmentOverviewTests.cs b/DiscordBot.Tests/Recruitment/Runtime/RecruitmentOverviewTests.cs similarity index 100% rename from DiscordBot.Tests/Recruitment/RecruitmentOverviewTests.cs rename to DiscordBot.Tests/Recruitment/Runtime/RecruitmentOverviewTests.cs diff --git a/DiscordBot.Tests/Recruitment/RecruitmentSchedulingTests.cs b/DiscordBot.Tests/Recruitment/Runtime/RecruitmentSchedulingTests.cs similarity index 100% rename from DiscordBot.Tests/Recruitment/RecruitmentSchedulingTests.cs rename to DiscordBot.Tests/Recruitment/Runtime/RecruitmentSchedulingTests.cs diff --git a/DiscordBot.Tests/Recruitment/RecruitmentTestRig.cs b/DiscordBot.Tests/Recruitment/Support/RecruitmentTestRig.cs similarity index 100% rename from DiscordBot.Tests/Recruitment/RecruitmentTestRig.cs rename to DiscordBot.Tests/Recruitment/Support/RecruitmentTestRig.cs diff --git a/DiscordBot.Tests/Recruitment/RecruitmentAcknowledgementTests.cs b/DiscordBot.Tests/Recruitment/Workflows/RecruitmentAcknowledgementTests.cs similarity index 100% rename from DiscordBot.Tests/Recruitment/RecruitmentAcknowledgementTests.cs rename to DiscordBot.Tests/Recruitment/Workflows/RecruitmentAcknowledgementTests.cs diff --git a/DiscordBot.Tests/Recruitment/RecruitmentAttemptTests.cs b/DiscordBot.Tests/Recruitment/Workflows/RecruitmentAttemptTests.cs similarity index 100% rename from DiscordBot.Tests/Recruitment/RecruitmentAttemptTests.cs rename to DiscordBot.Tests/Recruitment/Workflows/RecruitmentAttemptTests.cs diff --git a/DiscordBot.Tests/Recruitment/RecruitmentEvidenceTests.cs b/DiscordBot.Tests/Recruitment/Workflows/RecruitmentEvidenceTests.cs similarity index 100% rename from DiscordBot.Tests/Recruitment/RecruitmentEvidenceTests.cs rename to DiscordBot.Tests/Recruitment/Workflows/RecruitmentEvidenceTests.cs diff --git a/DiscordBot.Tests/Recruitment/RecruitmentFollowUpTests.cs b/DiscordBot.Tests/Recruitment/Workflows/RecruitmentFollowUpTests.cs similarity index 100% rename from DiscordBot.Tests/Recruitment/RecruitmentFollowUpTests.cs rename to DiscordBot.Tests/Recruitment/Workflows/RecruitmentFollowUpTests.cs diff --git a/DiscordBot.Tests/Recruitment/RecruitmentRecoveryTests.cs b/DiscordBot.Tests/Recruitment/Workflows/RecruitmentRecoveryTests.cs similarity index 100% rename from DiscordBot.Tests/Recruitment/RecruitmentRecoveryTests.cs rename to DiscordBot.Tests/Recruitment/Workflows/RecruitmentRecoveryTests.cs diff --git a/DiscordBot.Tests/Recruitment/RecruitmentReportTests.cs b/DiscordBot.Tests/Recruitment/Workflows/RecruitmentReportTests.cs similarity index 100% rename from DiscordBot.Tests/Recruitment/RecruitmentReportTests.cs rename to DiscordBot.Tests/Recruitment/Workflows/RecruitmentReportTests.cs diff --git a/DiscordBot.Tests/Recruitment/RecruitmentSubmissionTests.cs b/DiscordBot.Tests/Recruitment/Workflows/RecruitmentSubmissionTests.cs similarity index 100% rename from DiscordBot.Tests/Recruitment/RecruitmentSubmissionTests.cs rename to DiscordBot.Tests/Recruitment/Workflows/RecruitmentSubmissionTests.cs diff --git a/DiscordBot.Tests/Recruitment/RecruitmentWorkflowTests.cs b/DiscordBot.Tests/Recruitment/Workflows/RecruitmentWorkflowTests.cs similarity index 100% rename from DiscordBot.Tests/Recruitment/RecruitmentWorkflowTests.cs rename to DiscordBot.Tests/Recruitment/Workflows/RecruitmentWorkflowTests.cs diff --git a/DiscordBot/Services/Recruitment/IRecruitmentDiscord.cs b/DiscordBot/Services/Recruitment/Application/Contracts/IRecruitmentDiscord.cs similarity index 52% rename from DiscordBot/Services/Recruitment/IRecruitmentDiscord.cs rename to DiscordBot/Services/Recruitment/Application/Contracts/IRecruitmentDiscord.cs index f2c6c8a3..87845c8d 100644 --- a/DiscordBot/Services/Recruitment/IRecruitmentDiscord.cs +++ b/DiscordBot/Services/Recruitment/Application/Contracts/IRecruitmentDiscord.cs @@ -1,25 +1,5 @@ namespace DiscordBot.Services.Recruitment; -public sealed record LiveTag(string Id, string Name, bool Moderated, IEmote? Emoji = null); -public sealed record ForumSnapshot(string Id, string Topic, bool RequireTag, IReadOnlyList Tags, ChannelFlags Flags = ChannelFlags.None); -public sealed record ThreadCreation(string Id, string ForumId, string OwnerId, string Title, DateTimeOffset CreatedAt); -public sealed record RecruitmentAuthor(bool IsBot, bool IsStaff); -public sealed record ThreadSnapshot(string Id, string ForumId, string OwnerId, string Title, DateTimeOffset CreatedAt, - bool OwnerIsBot, bool Pinned, bool Archived, bool Locked, IReadOnlyList Tags, string? Starter, - DateTimeOffset? AccountCreatedAt, DateTimeOffset? JoinedAt, bool OwnerIsStaff = false, int? MemberRoleCount = null, bool? HasCustomAvatar = null, IReadOnlyList? Attachments = null, - string? ServerNickname = null, string? GlobalName = null, string? Username = null, string? AvatarUrl = null, string? AvatarIdentity = null); -public sealed record ArchiveCursor(DateTimeOffset Since, DateTimeOffset? Before = null, DateTimeOffset? StartedAt = null); -public sealed record ThreadInventory(IReadOnlyList Threads, bool Complete, IReadOnlyDictionary? Cursors = null); -public enum RecruitmentMessageFormat { Standard, ComponentsV2 } -public sealed record RecruitmentMedia(string FileName, byte[] Bytes, string Description); -public sealed record RecruitmentMessage(string Content, string Marker, bool OwnerControls, bool RestoreArchive = false, Embed? Embed = null, string? TextFallback = null, bool AcknowledgementRequired = false, bool ReaderControls = false, RecruitmentMessageFormat Format = RecruitmentMessageFormat.Standard, - string? RenderIdentity = null, MessageComponent? Components = null, MessageComponent? FallbackComponents = null, RecruitmentMedia? Media = null, bool RestoreLocked = false, ulong? MentionUserId = null) -{ - public string RenderKey => RenderIdentity ?? Content + "\n" + OwnerControls + "\n" + AcknowledgementRequired + "\n" + ReaderControls + "\n" + EmbedKey(Embed); - public static string EmbedKey(IEmbed? embed) => embed is null ? "" : Newtonsoft.Json.JsonConvert.SerializeObject(embed.ToEmbedBuilder().Build()); -} -public sealed record Delivery(string Id, bool NewlySent, string? MediaAttachmentId = null, string? MediaFileName = null); - /// Null thread means confirmed Discord Unknown Channel, never a cache miss or permission failure. public interface IRecruitmentDiscord { diff --git a/DiscordBot/Services/Recruitment/Application/Contracts/RecruitmentMessages.cs b/DiscordBot/Services/Recruitment/Application/Contracts/RecruitmentMessages.cs new file mode 100644 index 00000000..300ab206 --- /dev/null +++ b/DiscordBot/Services/Recruitment/Application/Contracts/RecruitmentMessages.cs @@ -0,0 +1,11 @@ +namespace DiscordBot.Services.Recruitment; + +public enum RecruitmentMessageFormat { Standard, ComponentsV2 } +public sealed record RecruitmentMedia(string FileName, byte[] Bytes, string Description); +public sealed record RecruitmentMessage(string Content, string Marker, bool OwnerControls, bool RestoreArchive = false, Embed? Embed = null, string? TextFallback = null, bool AcknowledgementRequired = false, bool ReaderControls = false, RecruitmentMessageFormat Format = RecruitmentMessageFormat.Standard, + string? RenderIdentity = null, MessageComponent? Components = null, MessageComponent? FallbackComponents = null, RecruitmentMedia? Media = null, bool RestoreLocked = false, ulong? MentionUserId = null) +{ + public string RenderKey => RenderIdentity ?? Content + "\n" + OwnerControls + "\n" + AcknowledgementRequired + "\n" + ReaderControls + "\n" + EmbedKey(Embed); + public static string EmbedKey(IEmbed? embed) => embed is null ? "" : Newtonsoft.Json.JsonConvert.SerializeObject(embed.ToEmbedBuilder().Build()); +} +public sealed record Delivery(string Id, bool NewlySent, string? MediaAttachmentId = null, string? MediaFileName = null); diff --git a/DiscordBot/Services/Recruitment/Application/Contracts/RecruitmentSnapshots.cs b/DiscordBot/Services/Recruitment/Application/Contracts/RecruitmentSnapshots.cs new file mode 100644 index 00000000..6ad2abf7 --- /dev/null +++ b/DiscordBot/Services/Recruitment/Application/Contracts/RecruitmentSnapshots.cs @@ -0,0 +1,12 @@ +namespace DiscordBot.Services.Recruitment; + +public sealed record LiveTag(string Id, string Name, bool Moderated, IEmote? Emoji = null); +public sealed record ForumSnapshot(string Id, string Topic, bool RequireTag, IReadOnlyList Tags, ChannelFlags Flags = ChannelFlags.None); +public sealed record ThreadCreation(string Id, string ForumId, string OwnerId, string Title, DateTimeOffset CreatedAt); +public sealed record RecruitmentAuthor(bool IsBot, bool IsStaff); +public sealed record ThreadSnapshot(string Id, string ForumId, string OwnerId, string Title, DateTimeOffset CreatedAt, + bool OwnerIsBot, bool Pinned, bool Archived, bool Locked, IReadOnlyList Tags, string? Starter, + DateTimeOffset? AccountCreatedAt, DateTimeOffset? JoinedAt, bool OwnerIsStaff = false, int? MemberRoleCount = null, bool? HasCustomAvatar = null, IReadOnlyList? Attachments = null, + string? ServerNickname = null, string? GlobalName = null, string? Username = null, string? AvatarUrl = null, string? AvatarIdentity = null); +public sealed record ArchiveCursor(DateTimeOffset Since, DateTimeOffset? Before = null, DateTimeOffset? StartedAt = null); +public sealed record ThreadInventory(IReadOnlyList Threads, bool Complete, IReadOnlyDictionary? Cursors = null); diff --git a/DiscordBot/Services/Recruitment/RecruitmentAcknowledgement.cs b/DiscordBot/Services/Recruitment/Application/RecruitmentAcknowledgement.cs similarity index 100% rename from DiscordBot/Services/Recruitment/RecruitmentAcknowledgement.cs rename to DiscordBot/Services/Recruitment/Application/RecruitmentAcknowledgement.cs diff --git a/DiscordBot/Services/Recruitment/RecruitmentAttempts.cs b/DiscordBot/Services/Recruitment/Application/RecruitmentAttempts.cs similarity index 100% rename from DiscordBot/Services/Recruitment/RecruitmentAttempts.cs rename to DiscordBot/Services/Recruitment/Application/RecruitmentAttempts.cs diff --git a/DiscordBot/Services/Recruitment/RecruitmentAttention.cs b/DiscordBot/Services/Recruitment/Application/RecruitmentAttention.cs similarity index 100% rename from DiscordBot/Services/Recruitment/RecruitmentAttention.cs rename to DiscordBot/Services/Recruitment/Application/RecruitmentAttention.cs diff --git a/DiscordBot/Services/Recruitment/RecruitmentEngine.cs b/DiscordBot/Services/Recruitment/Application/RecruitmentEngine.cs similarity index 100% rename from DiscordBot/Services/Recruitment/RecruitmentEngine.cs rename to DiscordBot/Services/Recruitment/Application/RecruitmentEngine.cs diff --git a/DiscordBot/Services/Recruitment/RecruitmentEngineScheduling.cs b/DiscordBot/Services/Recruitment/Application/RecruitmentEngineScheduling.cs similarity index 100% rename from DiscordBot/Services/Recruitment/RecruitmentEngineScheduling.cs rename to DiscordBot/Services/Recruitment/Application/RecruitmentEngineScheduling.cs diff --git a/DiscordBot/Services/Recruitment/RecruitmentEvidence.cs b/DiscordBot/Services/Recruitment/Application/RecruitmentEvidence.cs similarity index 100% rename from DiscordBot/Services/Recruitment/RecruitmentEvidence.cs rename to DiscordBot/Services/Recruitment/Application/RecruitmentEvidence.cs diff --git a/DiscordBot/Services/Recruitment/RecruitmentOverview.cs b/DiscordBot/Services/Recruitment/Application/RecruitmentOverview.cs similarity index 100% rename from DiscordBot/Services/Recruitment/RecruitmentOverview.cs rename to DiscordBot/Services/Recruitment/Application/RecruitmentOverview.cs diff --git a/DiscordBot/Services/Recruitment/RecruitmentPins.cs b/DiscordBot/Services/Recruitment/Application/RecruitmentPins.cs similarity index 100% rename from DiscordBot/Services/Recruitment/RecruitmentPins.cs rename to DiscordBot/Services/Recruitment/Application/RecruitmentPins.cs diff --git a/DiscordBot/Services/Recruitment/Application/RecruitmentPortfolioCommands.cs b/DiscordBot/Services/Recruitment/Application/RecruitmentPortfolioCommands.cs new file mode 100644 index 00000000..aea24c82 --- /dev/null +++ b/DiscordBot/Services/Recruitment/Application/RecruitmentPortfolioCommands.cs @@ -0,0 +1,36 @@ +using System.Globalization; +using System.IO; + +namespace DiscordBot.Services.Recruitment; + +public sealed partial class RecruitmentEngine +{ + public async Task PortfolioSitesAsync(string guild, string actor, string operation, string domain, string reason, CancellationToken ct) + { + if (guild != _bot.GuildId.ToString() || !await _discord.IsAdministratorAsync(guild, actor, ct)) + return "Administrator access is required in the configured server."; + if (State is null || !_settings.Enabled) return "Recruitment is unavailable."; + var registry = State.PortfolioSites; + if (operation == "list") + return "Recognized portfolio sites (recognition is not endorsement):\n" + + (registry.Hosts.Count == 0 ? "None configured." : string.Join("\n", registry.Hosts.Order(StringComparer.Ordinal))) + + $"\nRegistry version {registry.Version}. Subdomains match at a dot boundary."; + if (operation is not ("add" or "remove")) return "Choose list, add or remove."; + reason = RecruitmentPresentation.Safe(reason, 300).Trim(); + if (string.IsNullOrWhiteSpace(reason)) return "A reason is required for this change."; + if (!RecruitmentPortfolioDomains.TryNormalize(domain, out var host)) + return "Enter a DNS hostname below a public suffix, without a URL, path, port, credentials or wildcard."; + var exists = registry.Hosts.Contains(host, StringComparer.Ordinal); + if (exists == (operation == "add")) return exists ? "That site is already configured." : "That site is not configured."; + if (operation == "add" && registry.Hosts.Count >= 100) return "The portfolio-site limit is 100 domains. Remove a site first."; + if (registry.Version == long.MaxValue) return "Registry version limit reached; operator review required."; + if (operation == "add") registry.Hosts.Add(host); else registry.Hosts.Remove(host); + registry.Version++; + registry.Audit.Add(new RecruitmentAudit(Now, actor, $"{operation} {host}: {reason}")); + if (registry.Audit.Count > 100) registry.Audit.RemoveRange(0, registry.Audit.Count - 100); + // Version mismatch makes open listings due, using the existing bounded scheduling/retry path. + // This policy update does not change starter versions or manufacture member edits. + Save(); + return $"Portfolio site {host} {(operation == "add" ? "added" : "removed")}. Retained open listings will refresh in bounded batches; recognition is not endorsement."; + } +} diff --git a/DiscordBot/Services/Recruitment/RecruitmentReferences.cs b/DiscordBot/Services/Recruitment/Application/RecruitmentReferences.cs similarity index 100% rename from DiscordBot/Services/Recruitment/RecruitmentReferences.cs rename to DiscordBot/Services/Recruitment/Application/RecruitmentReferences.cs diff --git a/DiscordBot/Services/Recruitment/RecruitmentReports.cs b/DiscordBot/Services/Recruitment/Application/RecruitmentReports.cs similarity index 100% rename from DiscordBot/Services/Recruitment/RecruitmentReports.cs rename to DiscordBot/Services/Recruitment/Application/RecruitmentReports.cs diff --git a/DiscordBot/Services/Recruitment/RecruitmentAssets.cs b/DiscordBot/Services/Recruitment/Application/RecruitmentSetup.cs similarity index 71% rename from DiscordBot/Services/Recruitment/RecruitmentAssets.cs rename to DiscordBot/Services/Recruitment/Application/RecruitmentSetup.cs index 58943123..76cb5540 100644 --- a/DiscordBot/Services/Recruitment/RecruitmentAssets.cs +++ b/DiscordBot/Services/Recruitment/Application/RecruitmentSetup.cs @@ -5,71 +5,6 @@ namespace DiscordBot.Services.Recruitment; -public sealed class TagDefinition -{ - public string Key { get; set; } = ""; - public string Name { get; set; } = ""; - public string Group { get; set; } = ""; - public bool Closing { get; set; } -} - -public sealed class RecruitmentAssets -{ - public const int GuidelinesMaxLength = 1000; - - public Dictionary Templates { get; } - public Dictionary> Tags { get; } - public RecruitmentAssets(string root, RecruitmentSettings settings) - { - var directory = Path.Combine(root, "recruitment"); - Tags = JsonConvert.DeserializeObject>>(File.ReadAllText(Path.Combine(directory, "tags.json"))) - ?? throw new InvalidDataException("Recruitment tag manifest is empty."); - Templates = new(); - foreach (var purpose in settings.Forums.Mapping().Keys) - { - var template = File.ReadAllText(Path.Combine(directory, "guidelines", purpose + ".md")); - if (template.Split("{{code}}").Length != 2 || - new[] { "acknowledgementMinutes", "cooldownDays" }.Any(p => !template.Contains("{{" + p + "}}"))) - throw new InvalidDataException($"Invalid Guidelines placeholders for {purpose}."); - Templates[purpose] = template; - if (!Tags.TryGetValue(purpose, out var tags) || tags.Count is < 1 or > 20 || - tags.Count(t => t.Closing) != 1 || tags.Any(t => string.IsNullOrWhiteSpace(t.Key) || - t.Key.Length > 32 || t.Group.Length > 32 || string.IsNullOrWhiteSpace(t.Name) || - t.Name.Length is < 1 or > 20 || (t.Closing && t.Group.Length != 0)) || - tags.Select(t => t.Key).Distinct().Count() != tags.Count || - tags.Select(t => t.Name).Distinct(StringComparer.OrdinalIgnoreCase).Count() != tags.Count) - throw new InvalidDataException($"Invalid managed tags for {purpose}."); - _ = Render(purpose, "ABCDE", settings); - } - } - - public string Render(string purpose, string code, RecruitmentSettings settings) - { - var text = Templates[purpose].Replace("{{code}}", code) - .Replace("{{acknowledgementMinutes}}", settings.AcknowledgementMinutes.ToString()) - .Replace("{{cooldownDays}}", settings.CooldownDays.ToString()) - .Replace("{{unansweredDays}}", settings.UnansweredDays.ToString()).Trim(); - if (text.Length > GuidelinesMaxLength) - throw new InvalidDataException($"Guidelines for {purpose} exceed the {GuidelinesMaxLength}-character budget after rendering."); - if (text.Contains("{{")) throw new InvalidDataException($"Invalid Guidelines placeholders for {purpose}."); - return text; - } - - public static string NewCode() - { - const string alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; - return new string(Enumerable.Range(0, 5).Select(_ => alphabet[RandomNumberGenerator.GetInt32(alphabet.Length)]).ToArray()); - } - - public string TagAdvice(string purpose, IEnumerable appliedIds, ForumReceipt receipt) - { - var groups = Tags[purpose].Where(t => !t.Closing && t.Group.Length > 0).GroupBy(t => t.Group); - var missing = groups.Where(g => !g.Any(t => receipt.Tags.TryGetValue(t.Key, out var managed) && appliedIds.Contains(managed.Id))) - .Select(g => g.Key).ToArray(); - return missing.Length == 0 ? "Recommended tag groups present" : "Consider adding tags for: " + string.Join(", ", missing); - } -} - /// Publishes native Guidelines and managed tags, retaining intent across partial Discord writes. public sealed class RecruitmentSetup { diff --git a/DiscordBot/Services/Recruitment/RecruitmentContent.cs b/DiscordBot/Services/Recruitment/Domain/Analysis/RecruitmentContent.cs similarity index 100% rename from DiscordBot/Services/Recruitment/RecruitmentContent.cs rename to DiscordBot/Services/Recruitment/Domain/Analysis/RecruitmentContent.cs diff --git a/DiscordBot/Services/Recruitment/Domain/Analysis/RecruitmentPortfolioDomains.cs b/DiscordBot/Services/Recruitment/Domain/Analysis/RecruitmentPortfolioDomains.cs new file mode 100644 index 00000000..884f6a13 --- /dev/null +++ b/DiscordBot/Services/Recruitment/Domain/Analysis/RecruitmentPortfolioDomains.cs @@ -0,0 +1,56 @@ +using System.Globalization; +using System.IO; + +namespace DiscordBot.Services.Recruitment; + +public static class RecruitmentPortfolioDomains +{ + private static readonly IdnMapping Idn = new(); + private static readonly Lazy> Rules = new(() => + { + using var stream = typeof(RecruitmentPortfolioDomains).Assembly.GetManifestResourceStream("Recruitment.PublicSuffixIcann") + ?? throw new InvalidDataException("Bundled ICANN public suffix rules are missing."); + using var reader = new StreamReader(stream); + var rules = new HashSet(StringComparer.Ordinal); + while (reader.ReadLine() is { } line) + { + line = line.Trim(); + if (line.Length == 0 || line.StartsWith("//", StringComparison.Ordinal)) continue; + var prefix = line.StartsWith('!') ? "!" : line.StartsWith("*.", StringComparison.Ordinal) ? "*." : ""; + rules.Add(prefix + Idn.GetAscii(line[prefix.Length..]).ToLowerInvariant()); + } + return rules; + }); + + private static readonly Lazy> Tlds = new(() => Rules.Value + .Select(r => r.Split('.').Last().TrimStart('!')).ToHashSet(StringComparer.Ordinal)); + + public static bool TryNormalize(string? input, out string host) + { + host = ""; + if (string.IsNullOrWhiteSpace(input)) return false; + var value = input.Trim(); + // Only hostnames, never an authority, URL, wildcard, port or trailing root dot. + if (value.IndexOfAny(['/', '\\', ':', '@', '*', '?', '#', '%']) >= 0 || value.EndsWith('.')) return false; + try { value = Idn.GetAscii(value).ToLowerInvariant(); } + catch (ArgumentException) { return false; } + if (value.Length > 253 || Uri.CheckHostName(value) != UriHostNameType.Dns || !value.Contains('.')) return false; + var labels = value.Split('.'); + if (labels.Any(l => l.Length is < 1 or > 63 || l[0] == '-' || l[^1] == '-' || l.Any(c => !char.IsAsciiLetterOrDigit(c) && c != '-'))) return false; + var rules = Rules.Value; + // An unlisted TLD is not an Internet registry boundary (e.g. a local search suffix). + if (!Tlds.Value.Contains(labels[^1])) return false; + var suffixLabels = 1; + for (var i = 0; i < labels.Length; i++) + { + var suffix = string.Join('.', labels[i..]); + if (rules.Contains("!" + suffix)) { suffixLabels = labels.Length - i - 1; break; } + if (rules.Contains(suffix)) suffixLabels = Math.Max(suffixLabels, labels.Length - i); + if (i + 1 < labels.Length && rules.Contains("*." + string.Join('.', labels[(i + 1)..]))) + suffixLabels = Math.Max(suffixLabels, labels.Length - i); + } + if (labels.Length <= suffixLabels) return false; + host = value; + return true; + } +} diff --git a/DiscordBot/Services/Recruitment/RecruitmentPortfolioUrls.cs b/DiscordBot/Services/Recruitment/Domain/Analysis/RecruitmentPortfolioUrls.cs similarity index 100% rename from DiscordBot/Services/Recruitment/RecruitmentPortfolioUrls.cs rename to DiscordBot/Services/Recruitment/Domain/Analysis/RecruitmentPortfolioUrls.cs diff --git a/DiscordBot/Services/Recruitment/RecruitmentSignals.cs b/DiscordBot/Services/Recruitment/Domain/Analysis/RecruitmentSignals.cs similarity index 100% rename from DiscordBot/Services/Recruitment/RecruitmentSignals.cs rename to DiscordBot/Services/Recruitment/Domain/Analysis/RecruitmentSignals.cs diff --git a/DiscordBot/Services/Recruitment/Domain/Models/AuthorHistory.cs b/DiscordBot/Services/Recruitment/Domain/Models/AuthorHistory.cs new file mode 100644 index 00000000..f869d1b0 --- /dev/null +++ b/DiscordBot/Services/Recruitment/Domain/Models/AuthorHistory.cs @@ -0,0 +1,18 @@ +using Newtonsoft.Json; +using Newtonsoft.Json.Converters; + +namespace DiscordBot.Services.Recruitment; + +/// Cross-forum cooldown and timeout history that survives removal of individual listings. +public sealed class AuthorHistory +{ + [JsonProperty(Required = Required.AllowNull)] + public DateTimeOffset? LastAcceptedCreation { get; set; } + [JsonProperty(Required = Required.AllowNull)] + public DateTimeOffset UpdatedAt { get; set; } + public int ConsecutiveTimeouts { get; set; } + public bool TimeoutAlerted { get; set; } + public List RecentCreations { get; set; } = new(); + public List RecentReports { get; set; } = new(); + public DateTimeOffset? AttemptPausedUntil { get; set; } +} diff --git a/DiscordBot/Services/Recruitment/Domain/Models/DeliveryReceipts.cs b/DiscordBot/Services/Recruitment/Domain/Models/DeliveryReceipts.cs new file mode 100644 index 00000000..d14ef16b --- /dev/null +++ b/DiscordBot/Services/Recruitment/Domain/Models/DeliveryReceipts.cs @@ -0,0 +1,31 @@ +using Newtonsoft.Json; +using Newtonsoft.Json.Converters; + +namespace DiscordBot.Services.Recruitment; + +/// The replacement is confirmed before the previous overview is retired; listing audit messages are separate. +public sealed class OverviewReceipt +{ + public string? MessageId { get; set; } + public string? Marker { get; set; } + public long Sequence { get; set; } + public bool SendPending { get; set; } + public DateTimeOffset? SendStartedAt { get; set; } + public string? PendingMarker { get; set; } + public bool PendingReplacement { get; set; } + public string? RetiredMessageId { get; set; } + public string? RetiredMarker { get; set; } + public DateTimeOffset? LastPostedAt { get; set; } + public DateTimeOffset? LastEditedAt { get; set; } + public RecruitmentFault? Fault { get; set; } +} + +/// Optional message pin work; never grants forum-post protection or listing capacity. +public sealed class MessagePinReceipt +{ + public string? MessageId { get; set; } + public bool Confirmed { get; set; } + public bool RestoreArchive { get; set; } + public bool RestoreLocked { get; set; } + public RecruitmentFault? Fault { get; set; } +} diff --git a/DiscordBot/Services/Recruitment/Domain/Models/ForumReceipt.cs b/DiscordBot/Services/Recruitment/Domain/Models/ForumReceipt.cs new file mode 100644 index 00000000..32b16047 --- /dev/null +++ b/DiscordBot/Services/Recruitment/Domain/Models/ForumReceipt.cs @@ -0,0 +1,40 @@ +using Newtonsoft.Json; +using Newtonsoft.Json.Converters; + +namespace DiscordBot.Services.Recruitment; + +/// Verified forum publication and any unfinished desired state; never contains an inferred success. +public sealed class ForumReceipt +{ + [JsonProperty(Required = Required.AllowNull)] + public string ForumId { get; set; } = ""; + public RecruitmentFault? Fault { get; set; } + public ArchiveCursor? ArchiveCursor { get; set; } + public ReferenceReceipt Reference { get; set; } = new(); + public string ConfirmedTopic { get; set; } = ""; + public string VisibleCode { get; set; } = ""; + public Dictionary Tags { get; set; } = new(); + // Persist the desired publication before Discord is called, including retries after ambiguous writes. + public bool PendingRepair { get; set; } + public string? PendingTopic { get; set; } + public string? PendingCode { get; set; } + public List? PendingTags { get; set; } +} + +/// A forum's reference thread, separate from member listings and the private feed overview. +public sealed class ReferenceReceipt +{ + public string? ThreadId { get; set; } + public bool BotOwned { get; set; } + public bool CreatePending { get; set; } + public DateTimeOffset? CreateStartedAt { get; set; } + public RecruitmentFault? Fault { get; set; } +} + +public sealed class ManagedTag +{ + [JsonProperty(Required = Required.AllowNull)] + public string Id { get; set; } = ""; + public string Name { get; set; } = ""; + public bool Moderated { get; set; } +} diff --git a/DiscordBot/Services/Recruitment/Domain/Models/Listing.cs b/DiscordBot/Services/Recruitment/Domain/Models/Listing.cs new file mode 100644 index 00000000..9f7df023 --- /dev/null +++ b/DiscordBot/Services/Recruitment/Domain/Models/Listing.cs @@ -0,0 +1,68 @@ +using Newtonsoft.Json; +using Newtonsoft.Json.Converters; + +namespace DiscordBot.Services.Recruitment; + +/// A retained attempt, including delivery/action receipts and the evidence needed to enforce policy safely. +public sealed class Listing +{ + [JsonProperty(Required = Required.AllowNull)] + public string Id { get; set; } = ""; + [JsonProperty(Required = Required.AllowNull)] + public string ForumId { get; set; } = ""; + [JsonProperty(Required = Required.AllowNull)] + public string OwnerId { get; set; } = ""; + public string Title { get; set; } = ""; + [JsonProperty(Required = Required.AllowNull)] + public DateTimeOffset CreatedAt { get; set; } + [JsonProperty(Required = Required.AllowNull)] + public DateTimeOffset UpdatedAt { get; set; } + [JsonProperty(Required = Required.AllowNull)] + public ListingStatus Status { get; set; } + public long Version { get; set; } + public long PortfolioRegistryVersion { get; set; } + [JsonProperty(Required = Required.AllowNull)] + public DateTimeOffset? AcceptedAt { get; set; } + public DateTimeOffset? AcknowledgedAt { get; set; } + public DateTimeOffset? PromptDeliveredAt { get; set; } + public DateTimeOffset? Deadline { get; set; } + public List ValidCodes { get; set; } = new(); + public DateTimeOffset? LastCodeAttemptAt { get; set; } + public DateTimeOffset? AttemptPausedUntil { get; set; } + public AcknowledgementNoticeReceipt AcknowledgementNotice { get; set; } = new(); + public MessagePinReceipt PublicPin { get; set; } = new(); + public string? PublicMessageId { get; set; } + public string? StaffMessageId { get; set; } + public DateTimeOffset? PublicSendStartedAt { get; set; } + public DateTimeOffset? StaffSendStartedAt { get; set; } + public bool PublicationPending { get; set; } + public bool PublicRestoreArchive { get; set; } + public bool PublicRestoreLocked { get; set; } + public string? PublicMediaAttachmentId { get; set; } + public string? PublicMediaFileName { get; set; } + public int TimeoutAlertCount { get; set; } + public bool PublicSendPending { get; set; } + public bool StaffSendPending { get; set; } + public bool Exempt { get; set; } + public bool Pinned { get; set; } + // Legacy v2 fields remain readable; reply history no longer determines listing lifetime. + public bool HasQualifyingReply { get; set; } + public bool ResponseCoverageComplete { get; set; } + public bool AvailabilityUnknown { get; set; } + public bool SuppressUnansweredClose { get; set; } + public RecruitmentFault? Fault { get; set; } + public string? Hold { get; set; } + public string LastOutcome { get; set; } = "Awaiting Guidelines acknowledgement"; + public string LastActor { get; set; } = "system"; + public string LastReason { get; set; } = "New listing"; + public RecruitmentAction? PendingAction { get; set; } + public List Audit { get; set; } = new(); + public InitialFacts Facts { get; set; } = new(); + public StarterVersion? OriginalStarter { get; set; } + public StarterVersion? LatestStarter { get; set; } + public List EarlierMaterialVersions { get; set; } = new(); + public int MaterialChanges { get; set; } + public int ReviewedMaterialChanges { get; set; } + public List Reports { get; set; } = new(); + public List Attention { get; set; } = new(); +} diff --git a/DiscordBot/Services/Recruitment/Domain/Models/ListingEvidence.cs b/DiscordBot/Services/Recruitment/Domain/Models/ListingEvidence.cs new file mode 100644 index 00000000..f6d74ab4 --- /dev/null +++ b/DiscordBot/Services/Recruitment/Domain/Models/ListingEvidence.cs @@ -0,0 +1,58 @@ +using Newtonsoft.Json; +using Newtonsoft.Json.Converters; + +namespace DiscordBot.Services.Recruitment; + +public sealed class AcknowledgementNoticeReceipt +{ + public string? MessageId { get; set; } + public bool SendPending { get; set; } + public DateTimeOffset? StartedAt { get; set; } + public RecruitmentFault? Fault { get; set; } +} + +public sealed class RecruitmentReport +{ + public string ReporterId { get; set; } = ""; + public DateTimeOffset At { get; set; } + public string Concern { get; set; } = ""; + public DateTimeOffset? ResolvedAt { get; set; } +} + +public sealed class AttentionNotice +{ + public string Id { get; set; } = Guid.NewGuid().ToString("N"); + public DateTimeOffset At { get; set; } + public string Reason { get; set; } = ""; + public string? MessageId { get; set; } + public bool SendPending { get; set; } + public RecruitmentFault? Fault { get; set; } +} + +public sealed record StarterVersion(DateTimeOffset ObservedAt, string Content, bool Truncated, string Fingerprint, + string Payment, string PaymentEvidence, List Links, List Attachments, string? TermsFingerprint = null); + +public sealed record RecruitmentAudit(DateTimeOffset At, string Actor, string Reason); + +/// Advisory member and starter-message facts, refreshed on edits and bounded recovery checks. +public sealed class InitialFacts +{ + public DateTimeOffset? AccountCreatedAt { get; set; } + public DateTimeOffset? JoinedAt { get; set; } + public string Activity { get; set; } = "unknown"; // Retained for v2 history compatibility; no longer collected or displayed. + public string Payment { get; set; } = "unknown"; + public string TagAdvice { get; set; } = ""; + public bool PriorAcceptedListing { get; set; } + public int? MemberRoleCount { get; set; } + public string Portfolio { get; set; } = "unknown"; + public bool? HasCustomAvatar { get; set; } + public string PaymentEvidence { get; set; } = ""; + public string? ServerNickname { get; set; } + public string? GlobalName { get; set; } + public string? Username { get; set; } + public string? AvatarUrl { get; set; } + public string? AvatarIdentity { get; set; } + public List Checks { get; set; } = new(); + public PortfolioEvidence PortfolioCandidates { get; set; } = new(false, false, Array.Empty()); + public List Observations { get; set; } = new(); +} diff --git a/DiscordBot/Services/Recruitment/Domain/Models/PortfolioRegistry.cs b/DiscordBot/Services/Recruitment/Domain/Models/PortfolioRegistry.cs new file mode 100644 index 00000000..78fa8a04 --- /dev/null +++ b/DiscordBot/Services/Recruitment/Domain/Models/PortfolioRegistry.cs @@ -0,0 +1,14 @@ +using System.Globalization; +using System.IO; + +namespace DiscordBot.Services.Recruitment; + +/// The effective set is persisted, including an intentionally empty set. +public sealed class PortfolioRegistry +{ + public long Version { get; set; } = 1; + [Newtonsoft.Json.JsonProperty(ObjectCreationHandling = Newtonsoft.Json.ObjectCreationHandling.Replace)] + public List Hosts { get; set; } = RecruitmentContent.SeedPortfolioHosts.Order(StringComparer.Ordinal).ToList(); + public List Audit { get; set; } = new(); + public IReadOnlySet EffectiveHosts() => new HashSet(Hosts, StringComparer.OrdinalIgnoreCase); +} diff --git a/DiscordBot/Services/Recruitment/Domain/Models/RecruitmentAction.cs b/DiscordBot/Services/Recruitment/Domain/Models/RecruitmentAction.cs new file mode 100644 index 00000000..19281da9 --- /dev/null +++ b/DiscordBot/Services/Recruitment/Domain/Models/RecruitmentAction.cs @@ -0,0 +1,21 @@ +using Newtonsoft.Json; +using Newtonsoft.Json.Converters; + +namespace DiscordBot.Services.Recruitment; + +/// Durable mutation intent. CallStarted may represent a successful write whose response was lost. +public sealed class RecruitmentAction +{ + [JsonProperty(Required = Required.AllowNull)] + public string Id { get; set; } = Guid.NewGuid().ToString("N"); + public RecruitmentActionKind Kind { get; set; } + public string ActorId { get; set; } = "system"; + public string Reason { get; set; } = ""; + public bool Automatic { get; set; } + public bool RequiresModerator { get; set; } + public string Condition { get; set; } = ""; + public bool GuidelineTimeout { get; set; } + public bool CallStarted { get; set; } + [JsonProperty(Required = Required.AllowNull)] + public DateTimeOffset CreatedAt { get; set; } +} diff --git a/DiscordBot/Services/Recruitment/RecruitmentFault.cs b/DiscordBot/Services/Recruitment/Domain/Models/RecruitmentFault.cs similarity index 100% rename from DiscordBot/Services/Recruitment/RecruitmentFault.cs rename to DiscordBot/Services/Recruitment/Domain/Models/RecruitmentFault.cs diff --git a/DiscordBot/Services/Recruitment/Domain/Models/RecruitmentState.cs b/DiscordBot/Services/Recruitment/Domain/Models/RecruitmentState.cs new file mode 100644 index 00000000..80b56621 --- /dev/null +++ b/DiscordBot/Services/Recruitment/Domain/Models/RecruitmentState.cs @@ -0,0 +1,38 @@ +using Newtonsoft.Json; +using Newtonsoft.Json.Converters; + +namespace DiscordBot.Services.Recruitment; + +[JsonConverter(typeof(StringEnumConverter))] +public enum ListingStatus { Open, Closed, Deleted, Reference } +[JsonConverter(typeof(StringEnumConverter))] +public enum RecruitmentActionKind { Delete, Close, Reopen, RemoveAdvisory } + +public sealed class RecruitmentState +{ + public const string Format = "udc-recruitment-v2"; + [JsonProperty(Required = Required.AllowNull)] + public string Schema { get; set; } = Format; + [JsonProperty(Required = Required.AllowNull)] + public string InstanceId { get; set; } = Guid.NewGuid().ToString("N"); + [JsonProperty(Required = Required.AllowNull)] + public string GuildId { get; set; } = ""; + [JsonProperty(Required = Required.AllowNull)] + public DateTimeOffset InitializedAt { get; set; } + [JsonProperty(Required = Required.AllowNull)] + public DateTimeOffset DiscoveryFloor { get; set; } + public PortfolioRegistry PortfolioSites { get; set; } = new(); + public OverviewReceipt Overview { get; set; } = new(); + public RecruitmentFault? RecoveryFault { get; set; } + public List SetupAudit { get; set; } = new(); + public string? SweepAfterId { get; set; } + public DateTimeOffset? LastSweepAt { get; set; } + public string RotationCode { get; set; } = ""; + public DateTimeOffset RotateAt { get; set; } + [JsonProperty(Required = Required.AllowNull)] + public Dictionary Forums { get; set; } = new(); + [JsonProperty(Required = Required.AllowNull)] + public Dictionary Listings { get; set; } = new(); + [JsonProperty(Required = Required.AllowNull)] + public Dictionary Authors { get; set; } = new(); +} diff --git a/DiscordBot/Services/Recruitment/Domain/Models/TagDefinition.cs b/DiscordBot/Services/Recruitment/Domain/Models/TagDefinition.cs new file mode 100644 index 00000000..facd2040 --- /dev/null +++ b/DiscordBot/Services/Recruitment/Domain/Models/TagDefinition.cs @@ -0,0 +1,14 @@ +using System.IO; +using System.Security.Cryptography; +using DiscordBot.Settings; +using Newtonsoft.Json; + +namespace DiscordBot.Services.Recruitment; + +public sealed class TagDefinition +{ + public string Key { get; set; } = ""; + public string Name { get; set; } = ""; + public string Group { get; set; } = ""; + public bool Closing { get; set; } +} diff --git a/DiscordBot/Services/Recruitment/Domain/Policies/RecruitmentPolicy.cs b/DiscordBot/Services/Recruitment/Domain/Policies/RecruitmentPolicy.cs new file mode 100644 index 00000000..3ef7c155 --- /dev/null +++ b/DiscordBot/Services/Recruitment/Domain/Policies/RecruitmentPolicy.cs @@ -0,0 +1,57 @@ +using Newtonsoft.Json; +using Newtonsoft.Json.Converters; + +namespace DiscordBot.Services.Recruitment; + +public sealed record Eligibility(string? BlockingListingId, DateTimeOffset? NextAllowedAt, DateTimeOffset? AttemptPausedUntil = null) +{ + public bool Allowed => BlockingListingId is null && NextAllowedAt is null && AttemptPausedUntil is null; +} + +public static class RecruitmentPolicy +{ + public static Eligibility Evaluate(RecruitmentState state, Listing candidate, DateTimeOffset now, int cooldownDays) + { + var blocker = state.Listings.Values + .Where(p => p.OwnerId == candidate.OwnerId && p.Id != candidate.Id && p.Status == ListingStatus.Open) + .Where(p => p.AcceptedAt.HasValue || p.AvailabilityUnknown || + p.CreatedAt < candidate.CreatedAt || (p.CreatedAt == candidate.CreatedAt && + ulong.Parse(p.Id) < ulong.Parse(candidate.Id))) + .OrderBy(p => p.CreatedAt).ThenBy(p => ulong.Parse(p.Id)).FirstOrDefault(); + var next = state.Authors.GetValueOrDefault(candidate.OwnerId)?.LastAcceptedCreation?.AddDays(cooldownDays); + return new Eligibility(blocker?.Id, next > now ? next : null, candidate.AttemptPausedUntil > now ? candidate.AttemptPausedUntil : null); + } + + public static bool TryAccept(RecruitmentState state, Listing post, DateTimeOffset now, int cooldownDays) + { + if (post.Status != ListingStatus.Open || post.AcceptedAt.HasValue || !post.AcknowledgedAt.HasValue || + post.AvailabilityUnknown || !Evaluate(state, post, now, cooldownDays).Allowed) return false; + post.AcceptedAt = now; + post.LastOutcome = "Accepted"; + post.Version++; + post.UpdatedAt = now; + Author(state, post.OwnerId, now).LastAcceptedCreation = post.CreatedAt; + return true; + } + + public static AuthorHistory Author(RecruitmentState state, string owner, DateTimeOffset now) + { + if (!state.Authors.TryGetValue(owner, out var history)) + state.Authors[owner] = history = new AuthorHistory(); + history.UpdatedAt = now; + return history; + } + + public static void Prune(RecruitmentState state, DateTimeOffset now, int postMonths, int authorMonths, int cooldownDays) + { + var floor = now.AddMonths(-postMonths); + if (floor > state.DiscoveryFloor) state.DiscoveryFloor = floor; + foreach (var p in state.Listings.Values.Where(p => p.Status is ListingStatus.Closed or ListingStatus.Deleted && + p.UpdatedAt < floor && p.PendingAction is null && p.Hold is null).ToArray()) + state.Listings.Remove(p.Id); + foreach (var (id, author) in state.Authors.ToArray()) + if (author.UpdatedAt < now.AddMonths(-authorMonths) && + !(author.LastAcceptedCreation?.AddDays(cooldownDays) > now) && + !state.Listings.Values.Any(p => p.OwnerId == id)) state.Authors.Remove(id); + } +} diff --git a/DiscordBot/Services/Recruitment/Infrastructure/Assets/RecruitmentAssets.cs b/DiscordBot/Services/Recruitment/Infrastructure/Assets/RecruitmentAssets.cs new file mode 100644 index 00000000..9fbc2d47 --- /dev/null +++ b/DiscordBot/Services/Recruitment/Infrastructure/Assets/RecruitmentAssets.cs @@ -0,0 +1,63 @@ +using System.IO; +using System.Security.Cryptography; +using DiscordBot.Settings; +using Newtonsoft.Json; + +namespace DiscordBot.Services.Recruitment; + +public sealed class RecruitmentAssets +{ + public const int GuidelinesMaxLength = 1000; + + public Dictionary Templates { get; } + public Dictionary> Tags { get; } + public RecruitmentAssets(string root, RecruitmentSettings settings) + { + var directory = Path.Combine(root, "recruitment"); + Tags = JsonConvert.DeserializeObject>>(File.ReadAllText(Path.Combine(directory, "tags.json"))) + ?? throw new InvalidDataException("Recruitment tag manifest is empty."); + Templates = new(); + foreach (var purpose in settings.Forums.Mapping().Keys) + { + var template = File.ReadAllText(Path.Combine(directory, "guidelines", purpose + ".md")); + if (template.Split("{{code}}").Length != 2 || + new[] { "acknowledgementMinutes", "cooldownDays" }.Any(p => !template.Contains("{{" + p + "}}"))) + throw new InvalidDataException($"Invalid Guidelines placeholders for {purpose}."); + Templates[purpose] = template; + if (!Tags.TryGetValue(purpose, out var tags) || tags.Count is < 1 or > 20 || + tags.Count(t => t.Closing) != 1 || tags.Any(t => string.IsNullOrWhiteSpace(t.Key) || + t.Key.Length > 32 || t.Group.Length > 32 || string.IsNullOrWhiteSpace(t.Name) || + t.Name.Length is < 1 or > 20 || (t.Closing && t.Group.Length != 0)) || + tags.Select(t => t.Key).Distinct().Count() != tags.Count || + tags.Select(t => t.Name).Distinct(StringComparer.OrdinalIgnoreCase).Count() != tags.Count) + throw new InvalidDataException($"Invalid managed tags for {purpose}."); + _ = Render(purpose, "ABCDE", settings); + } + } + + public string Render(string purpose, string code, RecruitmentSettings settings) + { + var text = Templates[purpose].Replace("{{code}}", code) + .Replace("{{acknowledgementMinutes}}", settings.AcknowledgementMinutes.ToString()) + .Replace("{{cooldownDays}}", settings.CooldownDays.ToString()) + .Replace("{{unansweredDays}}", settings.UnansweredDays.ToString()).Trim(); + if (text.Length > GuidelinesMaxLength) + throw new InvalidDataException($"Guidelines for {purpose} exceed the {GuidelinesMaxLength}-character budget after rendering."); + if (text.Contains("{{")) throw new InvalidDataException($"Invalid Guidelines placeholders for {purpose}."); + return text; + } + + public static string NewCode() + { + const string alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; + return new string(Enumerable.Range(0, 5).Select(_ => alphabet[RandomNumberGenerator.GetInt32(alphabet.Length)]).ToArray()); + } + + public string TagAdvice(string purpose, IEnumerable appliedIds, ForumReceipt receipt) + { + var groups = Tags[purpose].Where(t => !t.Closing && t.Group.Length > 0).GroupBy(t => t.Group); + var missing = groups.Where(g => !g.Any(t => receipt.Tags.TryGetValue(t.Key, out var managed) && appliedIds.Contains(managed.Id))) + .Select(g => g.Key).ToArray(); + return missing.Length == 0 ? "Recommended tag groups present" : "Consider adding tags for: " + string.Join(", ", missing); + } +} diff --git a/DiscordBot/Services/Recruitment/DiscordRecruitmentAdapter.cs b/DiscordBot/Services/Recruitment/Infrastructure/Discord/DiscordRecruitmentAdapter.cs similarity index 100% rename from DiscordBot/Services/Recruitment/DiscordRecruitmentAdapter.cs rename to DiscordBot/Services/Recruitment/Infrastructure/Discord/DiscordRecruitmentAdapter.cs diff --git a/DiscordBot/Services/Recruitment/DiscordRecruitmentDelivery.cs b/DiscordBot/Services/Recruitment/Infrastructure/Discord/DiscordRecruitmentDelivery.cs similarity index 100% rename from DiscordBot/Services/Recruitment/DiscordRecruitmentDelivery.cs rename to DiscordBot/Services/Recruitment/Infrastructure/Discord/DiscordRecruitmentDelivery.cs diff --git a/DiscordBot/Services/Recruitment/DiscordRecruitmentPins.cs b/DiscordBot/Services/Recruitment/Infrastructure/Discord/DiscordRecruitmentPins.cs similarity index 100% rename from DiscordBot/Services/Recruitment/DiscordRecruitmentPins.cs rename to DiscordBot/Services/Recruitment/Infrastructure/Discord/DiscordRecruitmentPins.cs diff --git a/DiscordBot/Services/Recruitment/DiscordRecruitmentReferences.cs b/DiscordBot/Services/Recruitment/Infrastructure/Discord/DiscordRecruitmentReferences.cs similarity index 100% rename from DiscordBot/Services/Recruitment/DiscordRecruitmentReferences.cs rename to DiscordBot/Services/Recruitment/Infrastructure/Discord/DiscordRecruitmentReferences.cs diff --git a/DiscordBot/Services/Recruitment/RecruitmentForumTransport.cs b/DiscordBot/Services/Recruitment/Infrastructure/Discord/Transport/RecruitmentForumTransport.cs similarity index 100% rename from DiscordBot/Services/Recruitment/RecruitmentForumTransport.cs rename to DiscordBot/Services/Recruitment/Infrastructure/Discord/Transport/RecruitmentForumTransport.cs diff --git a/DiscordBot/Services/Recruitment/RecruitmentMessageTransport.cs b/DiscordBot/Services/Recruitment/Infrastructure/Discord/Transport/RecruitmentMessageTransport.cs similarity index 100% rename from DiscordBot/Services/Recruitment/RecruitmentMessageTransport.cs rename to DiscordBot/Services/Recruitment/Infrastructure/Discord/Transport/RecruitmentMessageTransport.cs diff --git a/DiscordBot/Services/Recruitment/RecruitmentRequests.cs b/DiscordBot/Services/Recruitment/Infrastructure/Discord/Transport/RecruitmentRequests.cs similarity index 100% rename from DiscordBot/Services/Recruitment/RecruitmentRequests.cs rename to DiscordBot/Services/Recruitment/Infrastructure/Discord/Transport/RecruitmentRequests.cs diff --git a/DiscordBot/Services/Recruitment/RecruitmentStateStore.cs b/DiscordBot/Services/Recruitment/Infrastructure/Persistence/RecruitmentStateStore.cs similarity index 100% rename from DiscordBot/Services/Recruitment/RecruitmentStateStore.cs rename to DiscordBot/Services/Recruitment/Infrastructure/Persistence/RecruitmentStateStore.cs diff --git a/DiscordBot/Services/Recruitment/RecruitmentBanner.cs b/DiscordBot/Services/Recruitment/Presentation/RecruitmentBanner.cs similarity index 100% rename from DiscordBot/Services/Recruitment/RecruitmentBanner.cs rename to DiscordBot/Services/Recruitment/Presentation/RecruitmentBanner.cs diff --git a/DiscordBot/Services/Recruitment/RecruitmentPresentation.cs b/DiscordBot/Services/Recruitment/Presentation/RecruitmentPresentation.cs similarity index 100% rename from DiscordBot/Services/Recruitment/RecruitmentPresentation.cs rename to DiscordBot/Services/Recruitment/Presentation/RecruitmentPresentation.cs diff --git a/DiscordBot/Services/Recruitment/RecruitmentPortfolioRegistry.cs b/DiscordBot/Services/Recruitment/RecruitmentPortfolioRegistry.cs deleted file mode 100644 index 23ce4d53..00000000 --- a/DiscordBot/Services/Recruitment/RecruitmentPortfolioRegistry.cs +++ /dev/null @@ -1,98 +0,0 @@ -using System.Globalization; -using System.IO; - -namespace DiscordBot.Services.Recruitment; - -/// The effective set is persisted, including an intentionally empty set. -public sealed class PortfolioRegistry -{ - public long Version { get; set; } = 1; - [Newtonsoft.Json.JsonProperty(ObjectCreationHandling = Newtonsoft.Json.ObjectCreationHandling.Replace)] - public List Hosts { get; set; } = RecruitmentContent.SeedPortfolioHosts.Order(StringComparer.Ordinal).ToList(); - public List Audit { get; set; } = new(); - public IReadOnlySet EffectiveHosts() => new HashSet(Hosts, StringComparer.OrdinalIgnoreCase); -} - -public static class RecruitmentPortfolioDomains -{ - private static readonly IdnMapping Idn = new(); - private static readonly Lazy> Rules = new(() => - { - using var stream = typeof(RecruitmentPortfolioDomains).Assembly.GetManifestResourceStream("Recruitment.PublicSuffixIcann") - ?? throw new InvalidDataException("Bundled ICANN public suffix rules are missing."); - using var reader = new StreamReader(stream); - var rules = new HashSet(StringComparer.Ordinal); - while (reader.ReadLine() is { } line) - { - line = line.Trim(); - if (line.Length == 0 || line.StartsWith("//", StringComparison.Ordinal)) continue; - var prefix = line.StartsWith('!') ? "!" : line.StartsWith("*.", StringComparison.Ordinal) ? "*." : ""; - rules.Add(prefix + Idn.GetAscii(line[prefix.Length..]).ToLowerInvariant()); - } - return rules; - }); - - private static readonly Lazy> Tlds = new(() => Rules.Value - .Select(r => r.Split('.').Last().TrimStart('!')).ToHashSet(StringComparer.Ordinal)); - - public static bool TryNormalize(string? input, out string host) - { - host = ""; - if (string.IsNullOrWhiteSpace(input)) return false; - var value = input.Trim(); - // Only hostnames, never an authority, URL, wildcard, port or trailing root dot. - if (value.IndexOfAny(['/', '\\', ':', '@', '*', '?', '#', '%']) >= 0 || value.EndsWith('.')) return false; - try { value = Idn.GetAscii(value).ToLowerInvariant(); } - catch (ArgumentException) { return false; } - if (value.Length > 253 || Uri.CheckHostName(value) != UriHostNameType.Dns || !value.Contains('.')) return false; - var labels = value.Split('.'); - if (labels.Any(l => l.Length is < 1 or > 63 || l[0] == '-' || l[^1] == '-' || l.Any(c => !char.IsAsciiLetterOrDigit(c) && c != '-'))) return false; - var rules = Rules.Value; - // An unlisted TLD is not an Internet registry boundary (e.g. a local search suffix). - if (!Tlds.Value.Contains(labels[^1])) return false; - var suffixLabels = 1; - for (var i = 0; i < labels.Length; i++) - { - var suffix = string.Join('.', labels[i..]); - if (rules.Contains("!" + suffix)) { suffixLabels = labels.Length - i - 1; break; } - if (rules.Contains(suffix)) suffixLabels = Math.Max(suffixLabels, labels.Length - i); - if (i + 1 < labels.Length && rules.Contains("*." + string.Join('.', labels[(i + 1)..]))) - suffixLabels = Math.Max(suffixLabels, labels.Length - i); - } - if (labels.Length <= suffixLabels) return false; - host = value; - return true; - } -} - -public sealed partial class RecruitmentEngine -{ - public async Task PortfolioSitesAsync(string guild, string actor, string operation, string domain, string reason, CancellationToken ct) - { - if (guild != _bot.GuildId.ToString() || !await _discord.IsAdministratorAsync(guild, actor, ct)) - return "Administrator access is required in the configured server."; - if (State is null || !_settings.Enabled) return "Recruitment is unavailable."; - var registry = State.PortfolioSites; - if (operation == "list") - return "Recognized portfolio sites (recognition is not endorsement):\n" + - (registry.Hosts.Count == 0 ? "None configured." : string.Join("\n", registry.Hosts.Order(StringComparer.Ordinal))) + - $"\nRegistry version {registry.Version}. Subdomains match at a dot boundary."; - if (operation is not ("add" or "remove")) return "Choose list, add or remove."; - reason = RecruitmentPresentation.Safe(reason, 300).Trim(); - if (string.IsNullOrWhiteSpace(reason)) return "A reason is required for this change."; - if (!RecruitmentPortfolioDomains.TryNormalize(domain, out var host)) - return "Enter a DNS hostname below a public suffix, without a URL, path, port, credentials or wildcard."; - var exists = registry.Hosts.Contains(host, StringComparer.Ordinal); - if (exists == (operation == "add")) return exists ? "That site is already configured." : "That site is not configured."; - if (operation == "add" && registry.Hosts.Count >= 100) return "The portfolio-site limit is 100 domains. Remove a site first."; - if (registry.Version == long.MaxValue) return "Registry version limit reached; operator review required."; - if (operation == "add") registry.Hosts.Add(host); else registry.Hosts.Remove(host); - registry.Version++; - registry.Audit.Add(new RecruitmentAudit(Now, actor, $"{operation} {host}: {reason}")); - if (registry.Audit.Count > 100) registry.Audit.RemoveRange(0, registry.Audit.Count - 100); - // Version mismatch makes open listings due, using the existing bounded scheduling/retry path. - // This policy update does not change starter versions or manufacture member edits. - Save(); - return $"Portfolio site {host} {(operation == "add" ? "added" : "removed")}. Retained open listings will refresh in bounded batches; recognition is not endorsement."; - } -} diff --git a/DiscordBot/Services/Recruitment/RecruitmentState.cs b/DiscordBot/Services/Recruitment/RecruitmentState.cs deleted file mode 100644 index e88818cb..00000000 --- a/DiscordBot/Services/Recruitment/RecruitmentState.cs +++ /dev/null @@ -1,303 +0,0 @@ -using Newtonsoft.Json; -using Newtonsoft.Json.Converters; - -namespace DiscordBot.Services.Recruitment; - -[JsonConverter(typeof(StringEnumConverter))] -public enum ListingStatus { Open, Closed, Deleted, Reference } -[JsonConverter(typeof(StringEnumConverter))] -public enum RecruitmentActionKind { Delete, Close, Reopen, RemoveAdvisory } - -public sealed class RecruitmentState -{ - public const string Format = "udc-recruitment-v2"; - [JsonProperty(Required = Required.AllowNull)] - public string Schema { get; set; } = Format; - [JsonProperty(Required = Required.AllowNull)] - public string InstanceId { get; set; } = Guid.NewGuid().ToString("N"); - [JsonProperty(Required = Required.AllowNull)] - public string GuildId { get; set; } = ""; - [JsonProperty(Required = Required.AllowNull)] - public DateTimeOffset InitializedAt { get; set; } - [JsonProperty(Required = Required.AllowNull)] - public DateTimeOffset DiscoveryFloor { get; set; } - public PortfolioRegistry PortfolioSites { get; set; } = new(); - public OverviewReceipt Overview { get; set; } = new(); - public RecruitmentFault? RecoveryFault { get; set; } - public List SetupAudit { get; set; } = new(); - public string? SweepAfterId { get; set; } - public DateTimeOffset? LastSweepAt { get; set; } - public string RotationCode { get; set; } = ""; - public DateTimeOffset RotateAt { get; set; } - [JsonProperty(Required = Required.AllowNull)] - public Dictionary Forums { get; set; } = new(); - [JsonProperty(Required = Required.AllowNull)] - public Dictionary Listings { get; set; } = new(); - [JsonProperty(Required = Required.AllowNull)] - public Dictionary Authors { get; set; } = new(); -} - -/// Verified forum publication and any unfinished desired state; never contains an inferred success. -public sealed class ForumReceipt -{ - [JsonProperty(Required = Required.AllowNull)] - public string ForumId { get; set; } = ""; - public RecruitmentFault? Fault { get; set; } - public ArchiveCursor? ArchiveCursor { get; set; } - public ReferenceReceipt Reference { get; set; } = new(); - public string ConfirmedTopic { get; set; } = ""; - public string VisibleCode { get; set; } = ""; - public Dictionary Tags { get; set; } = new(); - // Persist the desired publication before Discord is called, including retries after ambiguous writes. - public bool PendingRepair { get; set; } - public string? PendingTopic { get; set; } - public string? PendingCode { get; set; } - public List? PendingTags { get; set; } -} - -/// A forum's reference thread, separate from member listings and the private feed overview. -public sealed class ReferenceReceipt -{ - public string? ThreadId { get; set; } - public bool BotOwned { get; set; } - public bool CreatePending { get; set; } - public DateTimeOffset? CreateStartedAt { get; set; } - public RecruitmentFault? Fault { get; set; } -} - -public sealed class ManagedTag -{ - [JsonProperty(Required = Required.AllowNull)] - public string Id { get; set; } = ""; - public string Name { get; set; } = ""; - public bool Moderated { get; set; } -} - -/// A retained attempt, including delivery/action receipts and the evidence needed to enforce policy safely. -public sealed class Listing -{ - [JsonProperty(Required = Required.AllowNull)] - public string Id { get; set; } = ""; - [JsonProperty(Required = Required.AllowNull)] - public string ForumId { get; set; } = ""; - [JsonProperty(Required = Required.AllowNull)] - public string OwnerId { get; set; } = ""; - public string Title { get; set; } = ""; - [JsonProperty(Required = Required.AllowNull)] - public DateTimeOffset CreatedAt { get; set; } - [JsonProperty(Required = Required.AllowNull)] - public DateTimeOffset UpdatedAt { get; set; } - [JsonProperty(Required = Required.AllowNull)] - public ListingStatus Status { get; set; } - public long Version { get; set; } - public long PortfolioRegistryVersion { get; set; } - [JsonProperty(Required = Required.AllowNull)] - public DateTimeOffset? AcceptedAt { get; set; } - public DateTimeOffset? AcknowledgedAt { get; set; } - public DateTimeOffset? PromptDeliveredAt { get; set; } - public DateTimeOffset? Deadline { get; set; } - public List ValidCodes { get; set; } = new(); - public DateTimeOffset? LastCodeAttemptAt { get; set; } - public DateTimeOffset? AttemptPausedUntil { get; set; } - public AcknowledgementNoticeReceipt AcknowledgementNotice { get; set; } = new(); - public MessagePinReceipt PublicPin { get; set; } = new(); - public string? PublicMessageId { get; set; } - public string? StaffMessageId { get; set; } - public DateTimeOffset? PublicSendStartedAt { get; set; } - public DateTimeOffset? StaffSendStartedAt { get; set; } - public bool PublicationPending { get; set; } - public bool PublicRestoreArchive { get; set; } - public bool PublicRestoreLocked { get; set; } - public string? PublicMediaAttachmentId { get; set; } - public string? PublicMediaFileName { get; set; } - public int TimeoutAlertCount { get; set; } - public bool PublicSendPending { get; set; } - public bool StaffSendPending { get; set; } - public bool Exempt { get; set; } - public bool Pinned { get; set; } - // Legacy v2 fields remain readable; reply history no longer determines listing lifetime. - public bool HasQualifyingReply { get; set; } - public bool ResponseCoverageComplete { get; set; } - public bool AvailabilityUnknown { get; set; } - public bool SuppressUnansweredClose { get; set; } - public RecruitmentFault? Fault { get; set; } - public string? Hold { get; set; } - public string LastOutcome { get; set; } = "Awaiting Guidelines acknowledgement"; - public string LastActor { get; set; } = "system"; - public string LastReason { get; set; } = "New listing"; - public RecruitmentAction? PendingAction { get; set; } - public List Audit { get; set; } = new(); - public InitialFacts Facts { get; set; } = new(); - public StarterVersion? OriginalStarter { get; set; } - public StarterVersion? LatestStarter { get; set; } - public List EarlierMaterialVersions { get; set; } = new(); - public int MaterialChanges { get; set; } - public int ReviewedMaterialChanges { get; set; } - public List Reports { get; set; } = new(); - public List Attention { get; set; } = new(); -} - -public sealed class AcknowledgementNoticeReceipt -{ - public string? MessageId { get; set; } - public bool SendPending { get; set; } - public DateTimeOffset? StartedAt { get; set; } - public RecruitmentFault? Fault { get; set; } -} - -public sealed class RecruitmentReport -{ - public string ReporterId { get; set; } = ""; - public DateTimeOffset At { get; set; } - public string Concern { get; set; } = ""; - public DateTimeOffset? ResolvedAt { get; set; } -} - -public sealed class AttentionNotice -{ - public string Id { get; set; } = Guid.NewGuid().ToString("N"); - public DateTimeOffset At { get; set; } - public string Reason { get; set; } = ""; - public string? MessageId { get; set; } - public bool SendPending { get; set; } - public RecruitmentFault? Fault { get; set; } -} - -public sealed record StarterVersion(DateTimeOffset ObservedAt, string Content, bool Truncated, string Fingerprint, - string Payment, string PaymentEvidence, List Links, List Attachments, string? TermsFingerprint = null); - -public sealed record RecruitmentAudit(DateTimeOffset At, string Actor, string Reason); - -/// Advisory member and starter-message facts, refreshed on edits and bounded recovery checks. -public sealed class InitialFacts -{ - public DateTimeOffset? AccountCreatedAt { get; set; } - public DateTimeOffset? JoinedAt { get; set; } - public string Activity { get; set; } = "unknown"; // Retained for v2 history compatibility; no longer collected or displayed. - public string Payment { get; set; } = "unknown"; - public string TagAdvice { get; set; } = ""; - public bool PriorAcceptedListing { get; set; } - public int? MemberRoleCount { get; set; } - public string Portfolio { get; set; } = "unknown"; - public bool? HasCustomAvatar { get; set; } - public string PaymentEvidence { get; set; } = ""; - public string? ServerNickname { get; set; } - public string? GlobalName { get; set; } - public string? Username { get; set; } - public string? AvatarUrl { get; set; } - public string? AvatarIdentity { get; set; } - public List Checks { get; set; } = new(); - public PortfolioEvidence PortfolioCandidates { get; set; } = new(false, false, Array.Empty()); - public List Observations { get; set; } = new(); -} - -/// Cross-forum cooldown and timeout history that survives removal of individual listings. -public sealed class AuthorHistory -{ - [JsonProperty(Required = Required.AllowNull)] - public DateTimeOffset? LastAcceptedCreation { get; set; } - [JsonProperty(Required = Required.AllowNull)] - public DateTimeOffset UpdatedAt { get; set; } - public int ConsecutiveTimeouts { get; set; } - public bool TimeoutAlerted { get; set; } - public List RecentCreations { get; set; } = new(); - public List RecentReports { get; set; } = new(); - public DateTimeOffset? AttemptPausedUntil { get; set; } -} - -/// Durable mutation intent. CallStarted may represent a successful write whose response was lost. -public sealed class RecruitmentAction -{ - [JsonProperty(Required = Required.AllowNull)] - public string Id { get; set; } = Guid.NewGuid().ToString("N"); - public RecruitmentActionKind Kind { get; set; } - public string ActorId { get; set; } = "system"; - public string Reason { get; set; } = ""; - public bool Automatic { get; set; } - public bool RequiresModerator { get; set; } - public string Condition { get; set; } = ""; - public bool GuidelineTimeout { get; set; } - public bool CallStarted { get; set; } - [JsonProperty(Required = Required.AllowNull)] - public DateTimeOffset CreatedAt { get; set; } -} - -public sealed record Eligibility(string? BlockingListingId, DateTimeOffset? NextAllowedAt, DateTimeOffset? AttemptPausedUntil = null) -{ - public bool Allowed => BlockingListingId is null && NextAllowedAt is null && AttemptPausedUntil is null; -} - -public static class RecruitmentPolicy -{ - public static Eligibility Evaluate(RecruitmentState state, Listing candidate, DateTimeOffset now, int cooldownDays) - { - var blocker = state.Listings.Values - .Where(p => p.OwnerId == candidate.OwnerId && p.Id != candidate.Id && p.Status == ListingStatus.Open) - .Where(p => p.AcceptedAt.HasValue || p.AvailabilityUnknown || - p.CreatedAt < candidate.CreatedAt || (p.CreatedAt == candidate.CreatedAt && - ulong.Parse(p.Id) < ulong.Parse(candidate.Id))) - .OrderBy(p => p.CreatedAt).ThenBy(p => ulong.Parse(p.Id)).FirstOrDefault(); - var next = state.Authors.GetValueOrDefault(candidate.OwnerId)?.LastAcceptedCreation?.AddDays(cooldownDays); - return new Eligibility(blocker?.Id, next > now ? next : null, candidate.AttemptPausedUntil > now ? candidate.AttemptPausedUntil : null); - } - - public static bool TryAccept(RecruitmentState state, Listing post, DateTimeOffset now, int cooldownDays) - { - if (post.Status != ListingStatus.Open || post.AcceptedAt.HasValue || !post.AcknowledgedAt.HasValue || - post.AvailabilityUnknown || !Evaluate(state, post, now, cooldownDays).Allowed) return false; - post.AcceptedAt = now; - post.LastOutcome = "Accepted"; - post.Version++; - post.UpdatedAt = now; - Author(state, post.OwnerId, now).LastAcceptedCreation = post.CreatedAt; - return true; - } - - public static AuthorHistory Author(RecruitmentState state, string owner, DateTimeOffset now) - { - if (!state.Authors.TryGetValue(owner, out var history)) - state.Authors[owner] = history = new AuthorHistory(); - history.UpdatedAt = now; - return history; - } - - public static void Prune(RecruitmentState state, DateTimeOffset now, int postMonths, int authorMonths, int cooldownDays) - { - var floor = now.AddMonths(-postMonths); - if (floor > state.DiscoveryFloor) state.DiscoveryFloor = floor; - foreach (var p in state.Listings.Values.Where(p => p.Status is ListingStatus.Closed or ListingStatus.Deleted && - p.UpdatedAt < floor && p.PendingAction is null && p.Hold is null).ToArray()) - state.Listings.Remove(p.Id); - foreach (var (id, author) in state.Authors.ToArray()) - if (author.UpdatedAt < now.AddMonths(-authorMonths) && - !(author.LastAcceptedCreation?.AddDays(cooldownDays) > now) && - !state.Listings.Values.Any(p => p.OwnerId == id)) state.Authors.Remove(id); - } -} - -/// The replacement is confirmed before the previous overview is retired; listing audit messages are separate. -public sealed class OverviewReceipt -{ - public string? MessageId { get; set; } - public string? Marker { get; set; } - public long Sequence { get; set; } - public bool SendPending { get; set; } - public DateTimeOffset? SendStartedAt { get; set; } - public string? PendingMarker { get; set; } - public bool PendingReplacement { get; set; } - public string? RetiredMessageId { get; set; } - public string? RetiredMarker { get; set; } - public DateTimeOffset? LastPostedAt { get; set; } - public DateTimeOffset? LastEditedAt { get; set; } - public RecruitmentFault? Fault { get; set; } -} - -/// Optional message pin work; never grants forum-post protection or listing capacity. -public sealed class MessagePinReceipt -{ - public string? MessageId { get; set; } - public bool Confirmed { get; set; } - public bool RestoreArchive { get; set; } - public bool RestoreLocked { get; set; } - public RecruitmentFault? Fault { get; set; } -} diff --git a/DiscordBot/Services/Recruitment/RecruitmentHealth.cs b/DiscordBot/Services/Recruitment/Runtime/RecruitmentHealth.cs similarity index 100% rename from DiscordBot/Services/Recruitment/RecruitmentHealth.cs rename to DiscordBot/Services/Recruitment/Runtime/RecruitmentHealth.cs From 96e5004c4a79b0c508d17dda6bfc17cf440ce433 Mon Sep 17 00:00:00 2001 From: James Kellie Date: Mon, 19 Oct 2026 21:04:01 +1000 Subject: [PATCH 25/31] Separate recruitment validation and policy decisions --- .../Analysis/RecruitmentPortfolioTests.cs | 3 +- .../Persistence/RecruitmentRetentionTests.cs | 55 ++++++++ .../Workflows/RecruitmentWorkflowTests.cs | 5 +- .../Application/RecruitmentAttempts.cs | 24 +--- .../Application/RecruitmentEngine.cs | 43 +++--- .../RecruitmentEngineScheduling.cs | 3 +- .../Application/RecruitmentReports.cs | 3 +- .../Domain/Policies/AttemptPolicy.cs | 17 +++ .../Policies/ListingEligibilityPolicy.cs | 22 +++ .../Domain/Policies/ListingTransitions.cs | 25 ++++ .../Domain/Policies/RecruitmentConditions.cs | 10 ++ .../Domain/Policies/RecruitmentPolicy.cs | 57 -------- .../Domain/Policies/RecruitmentRetention.cs | 22 +++ .../Persistence/RecruitmentStateStore.cs | 80 +---------- .../Persistence/RecruitmentStateValidator.cs | 127 ++++++++++++++++++ 15 files changed, 319 insertions(+), 177 deletions(-) create mode 100644 DiscordBot.Tests/Recruitment/Persistence/RecruitmentRetentionTests.cs create mode 100644 DiscordBot/Services/Recruitment/Domain/Policies/AttemptPolicy.cs create mode 100644 DiscordBot/Services/Recruitment/Domain/Policies/ListingEligibilityPolicy.cs create mode 100644 DiscordBot/Services/Recruitment/Domain/Policies/ListingTransitions.cs create mode 100644 DiscordBot/Services/Recruitment/Domain/Policies/RecruitmentConditions.cs delete mode 100644 DiscordBot/Services/Recruitment/Domain/Policies/RecruitmentPolicy.cs create mode 100644 DiscordBot/Services/Recruitment/Domain/Policies/RecruitmentRetention.cs create mode 100644 DiscordBot/Services/Recruitment/Infrastructure/Persistence/RecruitmentStateValidator.cs diff --git a/DiscordBot.Tests/Recruitment/Analysis/RecruitmentPortfolioTests.cs b/DiscordBot.Tests/Recruitment/Analysis/RecruitmentPortfolioTests.cs index 2fbde1b6..07fe0fca 100644 --- a/DiscordBot.Tests/Recruitment/Analysis/RecruitmentPortfolioTests.cs +++ b/DiscordBot.Tests/Recruitment/Analysis/RecruitmentPortfolioTests.cs @@ -1,3 +1,4 @@ +using DiscordBot.Services.Recruitment.Domain.Policies; using Discord; using Discord.Interactions; using Discord.WebSocket; @@ -238,7 +239,7 @@ public async Task ReuseHintsAreBoundedPrivateDifferentAuthorAndRetainedOnlyWithL } foreach (var post in rig.State.Listings.Values.Where(p => p.Id != "100")) { post.Status = ListingStatus.Deleted; post.UpdatedAt = rig.Clock.Now.AddYears(-2); rig.Discord.Threads.Remove(post.Id); } - RecruitmentPolicy.Prune(rig.State, rig.Clock.Now, 12, 24, 30); + RecruitmentRetention.Prune(rig.State, rig.Clock.Now, 12, 24, 30); Assert.DoesNotContain("Shared starter", await rig.Staff("lookup")); await rig.Post("108", "200"); rig.Discord.Threads["108"] = rig.Discord.Threads["108"] with { Starter = "Portfolio https://youtube.com/watch?v=AbCdEfGhI_1" }; diff --git a/DiscordBot.Tests/Recruitment/Persistence/RecruitmentRetentionTests.cs b/DiscordBot.Tests/Recruitment/Persistence/RecruitmentRetentionTests.cs new file mode 100644 index 00000000..66683b07 --- /dev/null +++ b/DiscordBot.Tests/Recruitment/Persistence/RecruitmentRetentionTests.cs @@ -0,0 +1,55 @@ +using DiscordBot.Services.Recruitment; +using DiscordBot.Services.Recruitment.Domain.Policies; + +namespace DiscordBot.Tests.Recruitment; + +public class RecruitmentRetentionTests +{ + [Theory] + [InlineData("report", true)] + [InlineData("material-change", true)] + [InlineData("publication", true)] + [InlineData("public-send", true)] + [InlineData("staff-send", true)] + [InlineData("attention", true)] + [InlineData("pin-restoration", true)] + [InlineData("notice", true)] + [InlineData("action", false)] + [InlineData("hold", false)] + public void TerminalRetentionPreservesExistingPendingWorkPolicy(string work, bool expires) + { + var now = new DateTimeOffset(2026, 10, 19, 0, 0, 0, TimeSpan.Zero); + var post = new Listing { Id = "100", Status = ListingStatus.Closed, UpdatedAt = now.AddYears(-2) }; + switch (work) + { + case "report": post.Reports.Add(new RecruitmentReport { Concern = "Unresolved concern" }); break; + case "material-change": post.MaterialChanges = 1; break; + case "publication": post.PublicationPending = true; break; + case "public-send": post.PublicSendPending = true; break; + case "staff-send": post.StaffSendPending = true; break; + case "attention": post.Attention.Add(new AttentionNotice { SendPending = true }); break; + case "pin-restoration": post.PublicPin.RestoreArchive = true; break; + case "notice": post.AcknowledgementNotice.SendPending = true; break; + case "action": post.PendingAction = new RecruitmentAction(); break; + case "hold": post.Hold = "Review required"; break; + } + var state = new RecruitmentState(); + state.Listings.Add(post.Id, post); + RecruitmentRetention.Prune(state, now, 12, 24, 30); + Assert.Equal(!expires, state.Listings.ContainsKey(post.Id)); + } + + [Theory] + [InlineData("delivery", "Delivery")] + [InlineData("evidence", "Evidence")] + public async Task ValidationIdentifiesStateAreaWithoutIncludingPrivateContent(string damage, string area) + { + using var rig = new RecruitmentRig(); await rig.Start(); var post = await rig.Post(); + if (damage == "delivery") post.PublicPin.RestoreLocked = true; + else post.Reports.Add(new RecruitmentReport { ReporterId = "invalid", Concern = "private content" }); + var forums = rig.State.Forums.ToDictionary(f => f.Key, f => f.Value.ForumId); + var error = Assert.Throws(() => RecruitmentStateStore.Validate(rig.State, "1", forums)); + Assert.Contains($"Listings[{post.Id}].{area}", error.Message); + Assert.DoesNotContain("private content", error.Message); + } +} diff --git a/DiscordBot.Tests/Recruitment/Workflows/RecruitmentWorkflowTests.cs b/DiscordBot.Tests/Recruitment/Workflows/RecruitmentWorkflowTests.cs index 511cc049..0f3d6690 100644 --- a/DiscordBot.Tests/Recruitment/Workflows/RecruitmentWorkflowTests.cs +++ b/DiscordBot.Tests/Recruitment/Workflows/RecruitmentWorkflowTests.cs @@ -1,3 +1,4 @@ +using DiscordBot.Services.Recruitment.Domain.Policies; using DiscordBot.Services.Recruitment; namespace DiscordBot.Tests.Recruitment; @@ -24,7 +25,7 @@ public async Task AcceptedDeletionKeepsCreationCooldown(int elapsedDays, int rem rig.Clock.Advance(TimeSpan.FromDays(elapsedDays)); rig.Discord.Threads.Remove(post.Id); await rig.Sweep(); var replacement = await rig.Post("101", forum: "14"); - var policy = RecruitmentPolicy.Evaluate(rig.State, replacement, rig.Clock.Now, 30); + var policy = ListingEligibilityPolicy.Evaluate(rig.State, replacement, rig.Clock.Now, 30); Assert.Equal(remainingDays == 0, policy.Allowed); Assert.Equal(post.CreatedAt, rig.State.Authors[post.OwnerId].LastAcceptedCreation); if (remainingDays > 0) Assert.Equal(rig.Clock.Now.AddDays(remainingDays), policy.NextAllowedAt); @@ -48,7 +49,7 @@ public async Task AllForumsShareOneSlot_EvenAfterThirtyDaysOrNaturalArchive() rig.Discord.Threads["100"] = rig.Discord.Threads["100"] with { Archived = true }; var second = await rig.Post("101", forum: "14"); await rig.Ack("101"); Assert.Null(second.AcceptedAt); Assert.Null(second.AcknowledgedAt); Assert.Equal(ListingStatus.Closed, second.Status); - Assert.Equal("100", RecruitmentPolicy.Evaluate(rig.State, second, rig.Clock.Now, 30).BlockingListingId); + Assert.Equal("100", ListingEligibilityPolicy.Evaluate(rig.State, second, rig.Clock.Now, 30).BlockingListingId); } [Fact] diff --git a/DiscordBot/Services/Recruitment/Application/RecruitmentAttempts.cs b/DiscordBot/Services/Recruitment/Application/RecruitmentAttempts.cs index f2639152..061fd69e 100644 --- a/DiscordBot/Services/Recruitment/Application/RecruitmentAttempts.cs +++ b/DiscordBot/Services/Recruitment/Application/RecruitmentAttempts.cs @@ -1,3 +1,4 @@ +using DiscordBot.Services.Recruitment.Domain.Policies; namespace DiscordBot.Services.Recruitment; public sealed partial class RecruitmentEngine @@ -27,24 +28,13 @@ public async Task ThreadCreatedAsync(ThreadCreation creation, CancellationToken // Called once per newly retained thread identity, regardless of later deletion or acknowledgement. private void RecordCreation(Listing post) { - var history = RecruitmentPolicy.Author(State!, post.OwnerId, Now); + var history = ListingTransitions.Author(State!, post.OwnerId, Now); if (post.Pinned || post.CreatedAt <= Now.AddMinutes(-_settings.AttemptWindowMinutes)) return; - if (history.AttemptPausedUntil > Now) - { - post.AttemptPausedUntil = history.AttemptPausedUntil; - return; // Extra attempts cannot prolong an existing pause. - } - if (history.AttemptPausedUntil.HasValue) - { - history.RecentCreations.Clear(); history.AttemptPausedUntil = null; - } - history.RecentCreations.RemoveAll(d => d <= Now.AddMinutes(-_settings.AttemptWindowMinutes)); - history.RecentCreations.Add(post.CreatedAt); - if (history.RecentCreations.Count > _settings.AttemptLimit) - { - post.AttemptPausedUntil = history.AttemptPausedUntil = Now.AddMinutes(_settings.AttemptPauseMinutes); - QueueAttention(post, "Repeated creations triggered a posting pause"); - } + var decision = AttemptPolicy.Record(history.RecentCreations, history.AttemptPausedUntil, post.CreatedAt, Now, + _settings.AttemptWindowMinutes, _settings.AttemptLimit, _settings.AttemptPauseMinutes); + history.RecentCreations = decision.Creations.ToList(); + post.AttemptPausedUntil = history.AttemptPausedUntil = decision.PausedUntil; + if (decision.PauseStarted) QueueAttention(post, "Repeated creations triggered a posting pause"); } private static string EligibilityReason(Eligibility eligibility) diff --git a/DiscordBot/Services/Recruitment/Application/RecruitmentEngine.cs b/DiscordBot/Services/Recruitment/Application/RecruitmentEngine.cs index e9d63fad..36aa7212 100644 --- a/DiscordBot/Services/Recruitment/Application/RecruitmentEngine.cs +++ b/DiscordBot/Services/Recruitment/Application/RecruitmentEngine.cs @@ -1,3 +1,4 @@ +using DiscordBot.Services.Recruitment.Domain.Policies; using System.IO; using DiscordBot.Settings; @@ -119,7 +120,7 @@ public async Task SweepAsync(CancellationToken ct, bool retryHeld = true) } if (batch.Length > 0) State.SweepAfterId = batch[^1].Id; await ReconcileReferencesAsync(ct, retryHeld); - RecruitmentPolicy.Prune(State, Now, _settings.PostRetentionMonths, _settings.AuthorRetentionMonths, _settings.CooldownDays); + RecruitmentRetention.Prune(State, Now, _settings.PostRetentionMonths, _settings.AuthorRetentionMonths, _settings.CooldownDays); if (EvidenceCurrent) State.LastSweepAt = Now; else _nextAudit = Now.AddSeconds(10); Save(); @@ -256,10 +257,10 @@ private async Task ProcessAsync(Listing post, CancellationToken ct) if (EvidenceCurrent && !post.AcceptedAt.HasValue && !post.Exempt && !post.Pinned) { await RefreshBlockersAsync(post, ct); - var eligibility = RecruitmentPolicy.Evaluate(State!, post, Now, _settings.CooldownDays); + var eligibility = ListingEligibilityPolicy.Evaluate(State!, post, Now, _settings.CooldownDays); if (!eligibility.Allowed) { - await BeginActionAsync(post, RecruitmentActionKind.Close, "system", EligibilityReason(eligibility), "ineligible", true, ct); + await BeginActionAsync(post, RecruitmentActionKind.Close, "system", EligibilityReason(eligibility), RecruitmentConditions.Ineligible, true, ct); return; } } @@ -283,17 +284,17 @@ private async Task ProcessAsync(Listing post, CancellationToken ct) await PublishAcknowledgementNoticeAsync(post, ct); if (EvidenceCurrent && post.AcknowledgedAt.HasValue && !post.AcceptedAt.HasValue) await RefreshBlockersAsync(post, ct); - if (EvidenceCurrent && RecruitmentPolicy.TryAccept(State, post, Now, _settings.CooldownDays)) + if (EvidenceCurrent && ListingTransitions.TryAccept(State, post, Now, _settings.CooldownDays)) { Save(); await PublishAsync(post, ct); } if (!EvidenceCurrent || post.Exempt || post.Pinned) return; if (live.Tags.Contains(ClosingTag(post))) - await BeginActionAsync(post, RecruitmentActionKind.Close, "system", "Closed tag applied", "closed-tag", true, ct); + await BeginActionAsync(post, RecruitmentActionKind.Close, "system", "Closed tag applied", RecruitmentConditions.ClosedTag, true, ct); else if (!post.AcceptedAt.HasValue && post.Deadline <= Now) { if (!post.AcknowledgedAt.HasValue) - await BeginActionAsync(post, RecruitmentActionKind.Delete, "system", "Guidelines acknowledgement timed out", "timeout", true, ct); + await BeginActionAsync(post, RecruitmentActionKind.Delete, "system", "Guidelines acknowledgement timed out", RecruitmentConditions.Timeout, true, ct); else - await BeginActionAsync(post, RecruitmentActionKind.Close, "system", "Acknowledged but still ineligible at deadline", "ineligible", true, ct); + await BeginActionAsync(post, RecruitmentActionKind.Close, "system", "Acknowledged but still ineligible at deadline", RecruitmentConditions.Ineligible, true, ct); } } @@ -303,7 +304,7 @@ private void RecordAbsence(Listing post) post.PublicationPending = true; post.PublicSendPending = false; post.Hold = null; post.Fault = null; post.AvailabilityUnknown = false; post.Deadline = null; _recoverWindows.Remove(post.Id); post.LastOutcome = "Thread absent; deletion actor unknown"; post.Version++; - RecruitmentPolicy.Author(State!, post.OwnerId, Now); + ListingTransitions.Author(State!, post.OwnerId, Now); Save(); } @@ -312,7 +313,7 @@ private async Task RefreshBlockersAsync(Listing candidate, CancellationToken ct) // Eligibility cannot depend on which half of a bounded sweep happened to run first. for (var count = 0; count < 10; count++) { - var id = RecruitmentPolicy.Evaluate(State!, candidate, Now, _settings.CooldownDays).BlockingListingId; + var id = ListingEligibilityPolicy.Evaluate(State!, candidate, Now, _settings.CooldownDays).BlockingListingId; if (id is null) return; var blocker = State!.Listings[id]; try @@ -401,7 +402,7 @@ public async Task AcknowledgeAsync(string guild, string thread, string o // Persist a timely valid submission before any processing can execute an overdue timeout. // Invalid/late submissions never extend the window or fence the deadline worker. post.AcknowledgedAt = submittedAt; post.Version++; post.UpdatedAt = Now; - var author = RecruitmentPolicy.Author(State!, owner, Now); + var author = ListingTransitions.Author(State!, owner, Now); author.ConsecutiveTimeouts = 0; author.TimeoutAlerted = false; Save(); _discord.InvalidateThread(post.Id); @@ -455,7 +456,7 @@ private async Task BeginActionAsync(Listing post, RecruitmentActionKind kind, st { if (post.PendingAction is not null) throw new InvalidOperationException("An earlier action still needs reconciliation."); post.PendingAction = new RecruitmentAction { Kind = kind, ActorId = actor, Reason = reason, Condition = condition, - Automatic = automatic, RequiresModerator = requiresModerator, GuidelineTimeout = condition == "timeout", CreatedAt = Now }; + Automatic = automatic, RequiresModerator = requiresModerator, GuidelineTimeout = condition == RecruitmentConditions.Timeout, CreatedAt = Now }; if (!automatic) Audit(post, actor, reason); Save(); await ExecuteActionAsync(post, ct); @@ -464,7 +465,7 @@ private async Task BeginActionAsync(Listing post, RecruitmentActionKind kind, st private async Task ExecuteActionAsync(Listing post, CancellationToken ct) { var action = post.PendingAction!; - if (action.Automatic && action.Condition == "unanswered") + if (action.Automatic && action.Condition == RecruitmentConditions.LegacyUnanswered) { // Cancel unsent legacy expiry; preserve a closure already applied before the upgrade. var previous = action.CallStarted ? await ReadAsync(post, ct, refresh: true) : null; @@ -507,9 +508,9 @@ private async Task ExecuteActionAsync(Listing post, CancellationToken ct) post.PublicationPending = true; Save(); await PublishAsync(post, ct); return; } if (action.Automatic && (!EvidenceCurrent || post.Exempt || post.Pinned || post.Status != ListingStatus.Open || - (action.Condition == "timeout" && (post.AcknowledgedAt.HasValue || post.AcceptedAt.HasValue || post.Deadline > Now || _recoverWindows.Contains(post.Id))) || - (action.Condition == "ineligible" && (post.AcceptedAt.HasValue || RecruitmentPolicy.Evaluate(State!, post, Now, _settings.CooldownDays).Allowed)) || - (action.Condition == "closed-tag" && !live.Tags.Contains(ClosingTag(post))))) + (action.Condition == RecruitmentConditions.Timeout && (post.AcknowledgedAt.HasValue || post.AcceptedAt.HasValue || post.Deadline > Now || _recoverWindows.Contains(post.Id))) || + (action.Condition == RecruitmentConditions.Ineligible && (post.AcceptedAt.HasValue || ListingEligibilityPolicy.Evaluate(State!, post, Now, _settings.CooldownDays).Allowed)) || + (action.Condition == RecruitmentConditions.ClosedTag && !live.Tags.Contains(ClosingTag(post))))) { post.PendingAction = null; Save(); return; } if (action.Kind == RecruitmentActionKind.Reopen && State!.Listings.Values.Any(p => p.OwnerId == post.OwnerId && p.Id != post.Id && p.Status == ListingStatus.Open)) throw new InvalidOperationException("Another listing occupies this author's slot."); @@ -527,7 +528,7 @@ private async Task ExecuteActionAsync(Listing post, CancellationToken ct) post.Status = ListingStatus.Deleted; if (action.GuidelineTimeout) { - var author = RecruitmentPolicy.Author(State!, post.OwnerId, Now); + var author = ListingTransitions.Author(State!, post.OwnerId, Now); author.ConsecutiveTimeouts++; if (author.ConsecutiveTimeouts >= 3 && !author.TimeoutAlerted) { post.TimeoutAlertCount = author.ConsecutiveTimeouts; author.TimeoutAlerted = true; QueueAttention(post, "Three consecutive confirmed Guidelines timeouts"); } @@ -543,7 +544,7 @@ private async Task ExecuteActionAsync(Listing post, CancellationToken ct) if (!post.AcceptedAt.HasValue) _recoverWindows.Add(post.Id); break; } - RecruitmentPolicy.Author(State!, post.OwnerId, Now); + ListingTransitions.Author(State!, post.OwnerId, Now); post.LastOutcome = action.Reason; post.PendingAction = null; post.UpdatedAt = Now; post.Version++; post.PublicationPending = true; Save(); await PublishAsync(post, ct); } @@ -612,17 +613,17 @@ public async Task StaffAsync(string guild, string actor, string operatio break; case "review-changes": post.ReviewedMaterialChanges = post.MaterialChanges; break; case "reset-attempts": - var attemptHistory = RecruitmentPolicy.Author(State, post.OwnerId, Now); + var attemptHistory = ListingTransitions.Author(State, post.OwnerId, Now); attemptHistory.RecentCreations.Clear(); attemptHistory.AttemptPausedUntil = null; foreach (var attempt in State.Listings.Values.Where(p => p.OwnerId == post.OwnerId)) attempt.AttemptPausedUntil = null; break; case "reset-timeouts": - var timeoutHistory = RecruitmentPolicy.Author(State, post.OwnerId, Now); + var timeoutHistory = ListingTransitions.Author(State, post.OwnerId, Now); timeoutHistory.ConsecutiveTimeouts = 0; timeoutHistory.TimeoutAlerted = false; break; case "reset": if (post.AcceptedAt.HasValue || post.Status != ListingStatus.Open) return "Only an open unaccepted attempt can have its acknowledgement reset."; post.AcknowledgedAt = null; post.ValidCodes.Clear(); _recoverWindows.Add(post.Id); - var history = RecruitmentPolicy.Author(State, post.OwnerId, Now); + var history = ListingTransitions.Author(State, post.OwnerId, Now); history.ConsecutiveTimeouts = 0; history.TimeoutAlerted = false; break; case "reference": if (post.Status == ListingStatus.Deleted) return "A deleted thread cannot become a reference post."; @@ -645,7 +646,7 @@ public async Task StaffAsync(string guild, string actor, string operatio if (!post.AvailabilityUnknown) return "Availability is known; use close or reconcile instead."; post.Status = ListingStatus.Deleted; post.AvailabilityUnknown = false; post.PendingAction = null; post.PublicSendPending = false; post.Hold = null; - RecruitmentPolicy.Author(State, post.OwnerId, Now); + ListingTransitions.Author(State, post.OwnerId, Now); post.LastOutcome = "Staff confirmed absence"; break; default: return "Unknown recruitment operation."; } diff --git a/DiscordBot/Services/Recruitment/Application/RecruitmentEngineScheduling.cs b/DiscordBot/Services/Recruitment/Application/RecruitmentEngineScheduling.cs index a06837fa..e6d8e204 100644 --- a/DiscordBot/Services/Recruitment/Application/RecruitmentEngineScheduling.cs +++ b/DiscordBot/Services/Recruitment/Application/RecruitmentEngineScheduling.cs @@ -1,3 +1,4 @@ +using DiscordBot.Services.Recruitment.Domain.Policies; namespace DiscordBot.Services.Recruitment; public sealed partial class RecruitmentEngine @@ -69,7 +70,7 @@ post.Status is ListingStatus.Open or ListingStatus.Closed && post.PublicMessageI } if (post.AcknowledgedAt.HasValue && !post.AcceptedAt.HasValue) { - var next = RecruitmentPolicy.Evaluate(State!, post, Now, _settings.CooldownDays); + var next = ListingEligibilityPolicy.Evaluate(State!, post, Now, _settings.CooldownDays); if (next.BlockingListingId is null) dates.Add(next.NextAllowedAt ?? Now); } return dates.Count == 0 ? null : dates.Min(); diff --git a/DiscordBot/Services/Recruitment/Application/RecruitmentReports.cs b/DiscordBot/Services/Recruitment/Application/RecruitmentReports.cs index b3572072..3b7dde9d 100644 --- a/DiscordBot/Services/Recruitment/Application/RecruitmentReports.cs +++ b/DiscordBot/Services/Recruitment/Application/RecruitmentReports.cs @@ -1,3 +1,4 @@ +using DiscordBot.Services.Recruitment.Domain.Policies; namespace DiscordBot.Services.Recruitment; public sealed partial class RecruitmentEngine @@ -32,7 +33,7 @@ public async Task ReportAsync(string guild, string thread, string report if (concern.Length is < 10 or > 1000) return "Describe the concern in 10–1,000 characters, including relevant context."; if (post.Reports.Any(r => r.ReporterId == reporter)) return "You have already reported this listing. Contact a moderator with additional evidence."; if (post.Reports.Count >= 20) return "This listing already has reports awaiting or recorded by moderators. Contact a moderator with additional evidence."; - var history = RecruitmentPolicy.Author(State!, reporter, Now); + var history = ListingTransitions.Author(State!, reporter, Now); history.RecentReports.RemoveAll(d => d <= Now.AddDays(-1)); if (history.RecentReports.Count >= 3) return "You have reached the limit of three listing reports in 24 hours. Contact a moderator for urgent concerns."; var firstOpen = post.Reports.All(r => r.ResolvedAt.HasValue); diff --git a/DiscordBot/Services/Recruitment/Domain/Policies/AttemptPolicy.cs b/DiscordBot/Services/Recruitment/Domain/Policies/AttemptPolicy.cs new file mode 100644 index 00000000..54688063 --- /dev/null +++ b/DiscordBot/Services/Recruitment/Domain/Policies/AttemptPolicy.cs @@ -0,0 +1,17 @@ +namespace DiscordBot.Services.Recruitment.Domain.Policies; + +public sealed record AttemptDecision(IReadOnlyList Creations, DateTimeOffset? PausedUntil, bool PauseStarted); + +public static class AttemptPolicy +{ + public static AttemptDecision Record(IReadOnlyList creations, DateTimeOffset? pausedUntil, + DateTimeOffset createdAt, DateTimeOffset now, int windowMinutes, int limit, int pauseMinutes) + { + // Additional attempts never extend a pause that is already in progress. + if (pausedUntil > now) return new(creations, pausedUntil, false); + var recent = pausedUntil.HasValue ? new List() : creations.Where(d => d > now.AddMinutes(-windowMinutes)).ToList(); + recent.Add(createdAt); + var started = recent.Count > limit; + return new(recent, started ? now.AddMinutes(pauseMinutes) : null, started); + } +} diff --git a/DiscordBot/Services/Recruitment/Domain/Policies/ListingEligibilityPolicy.cs b/DiscordBot/Services/Recruitment/Domain/Policies/ListingEligibilityPolicy.cs new file mode 100644 index 00000000..c82d9448 --- /dev/null +++ b/DiscordBot/Services/Recruitment/Domain/Policies/ListingEligibilityPolicy.cs @@ -0,0 +1,22 @@ +namespace DiscordBot.Services.Recruitment.Domain.Policies; + +public sealed record Eligibility(string? BlockingListingId, DateTimeOffset? NextAllowedAt, DateTimeOffset? AttemptPausedUntil = null) +{ + public bool Allowed => BlockingListingId is null && NextAllowedAt is null && AttemptPausedUntil is null; +} + +public static class ListingEligibilityPolicy +{ + public static Eligibility Evaluate(RecruitmentState state, Listing candidate, DateTimeOffset now, int cooldownDays) + { + var blocker = state.Listings.Values + .Where(p => p.OwnerId == candidate.OwnerId && p.Id != candidate.Id && p.Status == ListingStatus.Open) + .Where(p => p.AcceptedAt.HasValue || p.AvailabilityUnknown || + p.CreatedAt < candidate.CreatedAt || (p.CreatedAt == candidate.CreatedAt && + ulong.Parse(p.Id) < ulong.Parse(candidate.Id))) + .OrderBy(p => p.CreatedAt).ThenBy(p => ulong.Parse(p.Id)).FirstOrDefault(); + var next = state.Authors.GetValueOrDefault(candidate.OwnerId)?.LastAcceptedCreation?.AddDays(cooldownDays); + return new Eligibility(blocker?.Id, next > now ? next : null, candidate.AttemptPausedUntil > now ? candidate.AttemptPausedUntil : null); + } + +} diff --git a/DiscordBot/Services/Recruitment/Domain/Policies/ListingTransitions.cs b/DiscordBot/Services/Recruitment/Domain/Policies/ListingTransitions.cs new file mode 100644 index 00000000..961e42d4 --- /dev/null +++ b/DiscordBot/Services/Recruitment/Domain/Policies/ListingTransitions.cs @@ -0,0 +1,25 @@ +namespace DiscordBot.Services.Recruitment.Domain.Policies; + +public static class ListingTransitions +{ + public static bool TryAccept(RecruitmentState state, Listing post, DateTimeOffset now, int cooldownDays) + { + if (post.Status != ListingStatus.Open || post.AcceptedAt.HasValue || !post.AcknowledgedAt.HasValue || + post.AvailabilityUnknown || !ListingEligibilityPolicy.Evaluate(state, post, now, cooldownDays).Allowed) return false; + post.AcceptedAt = now; + post.LastOutcome = "Accepted"; + post.Version++; + post.UpdatedAt = now; + Author(state, post.OwnerId, now).LastAcceptedCreation = post.CreatedAt; + return true; + } + + public static AuthorHistory Author(RecruitmentState state, string owner, DateTimeOffset now) + { + if (!state.Authors.TryGetValue(owner, out var history)) + state.Authors[owner] = history = new AuthorHistory(); + history.UpdatedAt = now; + return history; + } + +} diff --git a/DiscordBot/Services/Recruitment/Domain/Policies/RecruitmentConditions.cs b/DiscordBot/Services/Recruitment/Domain/Policies/RecruitmentConditions.cs new file mode 100644 index 00000000..b948a267 --- /dev/null +++ b/DiscordBot/Services/Recruitment/Domain/Policies/RecruitmentConditions.cs @@ -0,0 +1,10 @@ +namespace DiscordBot.Services.Recruitment.Domain.Policies; + +public static class RecruitmentConditions +{ + public const string None = ""; + public const string Timeout = "timeout"; + public const string Ineligible = "ineligible"; + public const string ClosedTag = "closed-tag"; + public const string LegacyUnanswered = "unanswered"; +} diff --git a/DiscordBot/Services/Recruitment/Domain/Policies/RecruitmentPolicy.cs b/DiscordBot/Services/Recruitment/Domain/Policies/RecruitmentPolicy.cs deleted file mode 100644 index 3ef7c155..00000000 --- a/DiscordBot/Services/Recruitment/Domain/Policies/RecruitmentPolicy.cs +++ /dev/null @@ -1,57 +0,0 @@ -using Newtonsoft.Json; -using Newtonsoft.Json.Converters; - -namespace DiscordBot.Services.Recruitment; - -public sealed record Eligibility(string? BlockingListingId, DateTimeOffset? NextAllowedAt, DateTimeOffset? AttemptPausedUntil = null) -{ - public bool Allowed => BlockingListingId is null && NextAllowedAt is null && AttemptPausedUntil is null; -} - -public static class RecruitmentPolicy -{ - public static Eligibility Evaluate(RecruitmentState state, Listing candidate, DateTimeOffset now, int cooldownDays) - { - var blocker = state.Listings.Values - .Where(p => p.OwnerId == candidate.OwnerId && p.Id != candidate.Id && p.Status == ListingStatus.Open) - .Where(p => p.AcceptedAt.HasValue || p.AvailabilityUnknown || - p.CreatedAt < candidate.CreatedAt || (p.CreatedAt == candidate.CreatedAt && - ulong.Parse(p.Id) < ulong.Parse(candidate.Id))) - .OrderBy(p => p.CreatedAt).ThenBy(p => ulong.Parse(p.Id)).FirstOrDefault(); - var next = state.Authors.GetValueOrDefault(candidate.OwnerId)?.LastAcceptedCreation?.AddDays(cooldownDays); - return new Eligibility(blocker?.Id, next > now ? next : null, candidate.AttemptPausedUntil > now ? candidate.AttemptPausedUntil : null); - } - - public static bool TryAccept(RecruitmentState state, Listing post, DateTimeOffset now, int cooldownDays) - { - if (post.Status != ListingStatus.Open || post.AcceptedAt.HasValue || !post.AcknowledgedAt.HasValue || - post.AvailabilityUnknown || !Evaluate(state, post, now, cooldownDays).Allowed) return false; - post.AcceptedAt = now; - post.LastOutcome = "Accepted"; - post.Version++; - post.UpdatedAt = now; - Author(state, post.OwnerId, now).LastAcceptedCreation = post.CreatedAt; - return true; - } - - public static AuthorHistory Author(RecruitmentState state, string owner, DateTimeOffset now) - { - if (!state.Authors.TryGetValue(owner, out var history)) - state.Authors[owner] = history = new AuthorHistory(); - history.UpdatedAt = now; - return history; - } - - public static void Prune(RecruitmentState state, DateTimeOffset now, int postMonths, int authorMonths, int cooldownDays) - { - var floor = now.AddMonths(-postMonths); - if (floor > state.DiscoveryFloor) state.DiscoveryFloor = floor; - foreach (var p in state.Listings.Values.Where(p => p.Status is ListingStatus.Closed or ListingStatus.Deleted && - p.UpdatedAt < floor && p.PendingAction is null && p.Hold is null).ToArray()) - state.Listings.Remove(p.Id); - foreach (var (id, author) in state.Authors.ToArray()) - if (author.UpdatedAt < now.AddMonths(-authorMonths) && - !(author.LastAcceptedCreation?.AddDays(cooldownDays) > now) && - !state.Listings.Values.Any(p => p.OwnerId == id)) state.Authors.Remove(id); - } -} diff --git a/DiscordBot/Services/Recruitment/Domain/Policies/RecruitmentRetention.cs b/DiscordBot/Services/Recruitment/Domain/Policies/RecruitmentRetention.cs new file mode 100644 index 00000000..def237df --- /dev/null +++ b/DiscordBot/Services/Recruitment/Domain/Policies/RecruitmentRetention.cs @@ -0,0 +1,22 @@ +namespace DiscordBot.Services.Recruitment.Domain.Policies; + +public static class RecruitmentRetention +{ + public static void Prune(RecruitmentState state, DateTimeOffset now, int postMonths, int authorMonths, int cooldownDays) + { + var floor = now.AddMonths(-postMonths); + if (floor > state.DiscoveryFloor) state.DiscoveryFloor = floor; + foreach (var p in state.Listings.Values.Where(p => CanExpire(p, floor)).ToArray()) + state.Listings.Remove(p.Id); + foreach (var (id, author) in state.Authors.ToArray()) + if (author.UpdatedAt < now.AddMonths(-authorMonths) && + !(author.LastAcceptedCreation?.AddDays(cooldownDays) > now) && + !state.Listings.Values.Any(p => p.OwnerId == id)) state.Authors.Remove(id); + } + + // Compatibility: unresolved reports and optional delivery receipts do not extend retention. + // Changing this requires a retention-policy decision, not a storage refactor. + public static bool CanExpire(Listing post, DateTimeOffset floor) => + post.Status is ListingStatus.Closed or ListingStatus.Deleted && post.UpdatedAt < floor && + post.PendingAction is null && post.Hold is null; +} diff --git a/DiscordBot/Services/Recruitment/Infrastructure/Persistence/RecruitmentStateStore.cs b/DiscordBot/Services/Recruitment/Infrastructure/Persistence/RecruitmentStateStore.cs index 7a9971f8..2d3746d9 100644 --- a/DiscordBot/Services/Recruitment/Infrastructure/Persistence/RecruitmentStateStore.cs +++ b/DiscordBot/Services/Recruitment/Infrastructure/Persistence/RecruitmentStateStore.cs @@ -1,3 +1,4 @@ +using DiscordBot.Services.Recruitment.Infrastructure.Persistence; using System.IO; using System.Text; using Newtonsoft.Json; @@ -95,83 +96,8 @@ private static RecruitmentState Read(string path) catch (JsonException ex) { throw new InvalidDataException("Recruitment state is malformed or incomplete; operator recovery required.", ex); } } - public static void Validate(RecruitmentState state, string guildId, IReadOnlyDictionary forums) - { - static bool Id(string? id) => ulong.TryParse(id, out var value) && value != 0; - if (state.Schema != RecruitmentState.Format || !Guid.TryParseExact(state.InstanceId, "N", out _) || - state.GuildId != guildId || !Id(guildId) || state.InitializedAt == default || state.DiscoveryFloor == default || - state.Forums is null || state.Listings is null || state.Authors is null || state.Overview is null || - state.Forums.Count != 4 || forums.Count != 4 || forums.Values.Distinct().Count() != 4 || - forums.Any(f => !Id(f.Value) || !state.Forums.TryGetValue(f.Key, out var saved) || saved.ForumId != f.Value)) - throw new InvalidDataException("Recruitment state schema, identity or forum mapping is invalid."); - var registry = state.PortfolioSites; - if (registry is null || registry.Version < 1 || registry.Hosts is null || registry.Hosts.Count > 100 || - registry.Hosts.Distinct(StringComparer.Ordinal).Count() != registry.Hosts.Count || - registry.Hosts.Any(h => !RecruitmentPortfolioDomains.TryNormalize(h, out var normalized) || h != normalized) || - registry.Audit is null || registry.Audit.Count > 100 || registry.Audit.Any(a => a is null || a.At == default || - !Id(a.Actor) || string.IsNullOrWhiteSpace(a.Reason) || a.Reason.Length > 570)) - throw new InvalidDataException("Recruitment portfolio-site registry is invalid."); - var overview = state.Overview; - if (overview.Sequence < 0 || - overview.MessageId is not null && (!Id(overview.MessageId) || overview.Marker is null || !overview.Marker.StartsWith($"recruitment:overview:{state.InstanceId}:")) || - overview.RetiredMessageId is not null && (!Id(overview.RetiredMessageId) || overview.RetiredMessageId == overview.MessageId || overview.RetiredMarker is null || !overview.RetiredMarker.StartsWith($"recruitment:overview:{state.InstanceId}:")) || - overview.SendPending && (overview.SendStartedAt is null || overview.PendingMarker is null || !overview.PendingMarker.StartsWith($"recruitment:overview:{state.InstanceId}:"))) - throw new InvalidDataException("Recruitment overview receipt is invalid."); - foreach (var f in state.Forums.Values) - if (f is null || f.Tags is null || f.ConfirmedTopic is null || f.VisibleCode is null || f.Reference is null || - f.Reference.ThreadId is not null && !Id(f.Reference.ThreadId) || - f.Reference.CreatePending && (f.Reference.CreateStartedAt is null || f.Reference.ThreadId is not null) || - f.Tags.Values.Any(t => t is null || !Id(t.Id) || string.IsNullOrWhiteSpace(t.Name))) - throw new InvalidDataException("Recruitment forum receipt is invalid."); - foreach (var (id, p) in state.Listings) - if (p is null || id != p.Id || !Id(id) || !Id(p.OwnerId) || !forums.Values.Contains(p.ForumId) || - p.PublicMediaAttachmentId is not null && !Id(p.PublicMediaAttachmentId) || - (p.PublicMediaAttachmentId is null) != (p.PublicMediaFileName is null) || - p.PublicMediaFileName is { } fileName && (fileName.Length > 120 || !fileName.StartsWith("recruitment-observations-", StringComparison.Ordinal) || !fileName.EndsWith(".png", StringComparison.Ordinal)) || - p.PublicRestoreLocked && !p.PublicRestoreArchive || - p.AcknowledgementNotice is null || p.AcknowledgementNotice.MessageId is not null && !Id(p.AcknowledgementNotice.MessageId) || - p.AcknowledgementNotice.SendPending && p.AcknowledgementNotice.StartedAt is null || - p.PublicPin is null || p.PublicPin.MessageId is not null && !Id(p.PublicPin.MessageId) || - p.PublicPin.Confirmed && p.PublicPin.MessageId is null || - p.PublicPin.RestoreLocked && !p.PublicPin.RestoreArchive || - p.PortfolioRegistryVersion < 0 || p.PortfolioRegistryVersion > registry.Version || - p.CreatedAt == default || p.CreatedAt.Offset != TimeSpan.Zero || p.UpdatedAt == default || - !Enum.IsDefined(p.Status) || p.ValidCodes is null || p.Facts is null || p.Audit is null || - !state.Authors.ContainsKey(p.OwnerId) || p.Facts.Payment is null || p.Facts.Portfolio is null || - !FactsValid(p.Facts) || p.Facts.Observations is null || p.Facts.Observations.Count > 4 || - p.Facts.Observations.Any(o => o is null || o.Code is null || o.Description is null || o.Evidence is null || o.Evidence.Length > 180) || - p.Facts.PaymentEvidence is null || p.Facts.PaymentEvidence.Length > 370 || - p.EarlierMaterialVersions is null || p.EarlierMaterialVersions.Count > 3 || - p.MaterialChanges < 0 || p.ReviewedMaterialChanges < 0 || p.ReviewedMaterialChanges > p.MaterialChanges || - !VersionValid(p.OriginalStarter) || !VersionValid(p.LatestStarter) || p.EarlierMaterialVersions.Any(v => v is null || !VersionValid(v)) || - p.Reports is null || p.Reports.Count > 20 || p.Reports.Any(r => r is null || !Id(r.ReporterId) || r.At == default || r.Concern is null || r.Concern.Length > 1000) || - p.Attention is null || p.Attention.Count > 8 || p.Attention.Any(n => n is null || !Guid.TryParseExact(n.Id, "N", out _) || n.At == default || n.Reason is null || n.Reason.Length > 300 || n.MessageId is not null && !Id(n.MessageId)) || - (p.AcceptedAt.HasValue && !p.AcknowledgedAt.HasValue) || - (p.Deadline.HasValue && !p.PromptDeliveredAt.HasValue) || - (p.PendingAction is not null && !Enum.IsDefined(p.PendingAction.Kind))) - throw new InvalidDataException("Recruitment listing state is invalid."); - foreach (var (id, a) in state.Authors) - if (!Id(id) || a is null || a.ConsecutiveTimeouts < 0 || a.UpdatedAt == default || - a.RecentCreations is null || a.RecentCreations.Count > 21 || a.RecentCreations.Any(d => d == default) || a.RecentReports is null || a.RecentReports.Count > 3 || a.RecentReports.Any(d => d == default)) - throw new InvalidDataException("Recruitment author history is invalid."); - } - - private static bool FactsValid(InitialFacts f) => - f.ServerNickname is null or { Length: <= 100 } && f.GlobalName is null or { Length: <= 100 } && - f.Username is null or { Length: <= 100 } && f.AvatarIdentity is null or { Length: <= 200 } && - (f.AvatarUrl is null || RecruitmentSignals.SafeAvatarUrl(f.AvatarUrl) == f.AvatarUrl) && - f.Checks is { Count: <= 9 } && f.Checks.Select(c => c?.Id).Distinct().Count() == f.Checks.Count && - f.Checks.All(c => c is not null && c.Id is { Length: <= 30 } && c.Label is { Length: <= 30 } && c.Value is { Length: <= 100 } && - c.Evidence is null or { Length: <= 370 } && Enum.IsDefined(c.Severity) && Enum.IsDefined(c.Applicability)) && - f.PortfolioCandidates is { Candidates.Count: <= 8 } && f.PortfolioCandidates.Candidates.All(c => - c is not null && c.Url is { Length: <= 300 } && c.Host is { Length: <= 253 } && c.ReuseUrl is null or { Length: <= 300 }); - - private static bool VersionValid(StarterVersion? version) => version is null || - version.ObservedAt != default && version.Content is { Length: <= 2000 } && version.Fingerprint is { Length: 64 } && - (version.TermsFingerprint is null || version.TermsFingerprint.Length == 64) && - version.Payment is not null && version.PaymentEvidence is { Length: <= 370 } && version.Links is { Count: <= 8 } && - version.Links.All(l => l is { Length: <= 300 }) && version.Attachments is { Count: <= 10 } && - version.Attachments.All(a => a is not null && a.Name is { Length: <= 120 } && (a.ContentType is null || a.ContentType.Length <= 80)); + public static void Validate(RecruitmentState state, string guildId, IReadOnlyDictionary forums) => + RecruitmentStateValidator.Validate(state, guildId, forums); public void Dispose() => _lease.Dispose(); } diff --git a/DiscordBot/Services/Recruitment/Infrastructure/Persistence/RecruitmentStateValidator.cs b/DiscordBot/Services/Recruitment/Infrastructure/Persistence/RecruitmentStateValidator.cs new file mode 100644 index 00000000..4fd2bd98 --- /dev/null +++ b/DiscordBot/Services/Recruitment/Infrastructure/Persistence/RecruitmentStateValidator.cs @@ -0,0 +1,127 @@ +using System.IO; + +namespace DiscordBot.Services.Recruitment.Infrastructure.Persistence; + +/// Synchronous validation at every existing load and save boundary. +public static class RecruitmentStateValidator +{ + private static bool Id(string? id) => ulong.TryParse(id, out var value) && value != 0; + + public static void Validate(RecruitmentState state, string guildId, IReadOnlyDictionary forums) + { + ValidateIdentity(state, guildId, forums); + ValidateRegistry(state.PortfolioSites); + ValidateOverview(state); + foreach (var (purpose, receipt) in state.Forums) ValidateForum(purpose, receipt); + foreach (var (id, post) in state.Listings) ValidateListing(id, post, state, forums); + foreach (var (id, author) in state.Authors) ValidateAuthor(id, author); + } + + private static void ValidateIdentity(RecruitmentState state, string guildId, IReadOnlyDictionary forums) + { + if (state.Schema != RecruitmentState.Format || !Guid.TryParseExact(state.InstanceId, "N", out _) || + state.GuildId != guildId || !Id(guildId) || state.InitializedAt == default || state.DiscoveryFloor == default || + state.Forums is null || state.Listings is null || state.Authors is null || state.Overview is null || + state.Forums.Count != 4 || forums.Count != 4 || forums.Values.Distinct().Count() != 4 || + forums.Any(f => !Id(f.Value) || !state.Forums.TryGetValue(f.Key, out var saved) || saved is null || saved.ForumId != f.Value)) + throw new InvalidDataException("Recruitment state schema, identity or forum mapping is invalid."); + } + + private static void ValidateRegistry(PortfolioRegistry registry) + { + if (registry is null || registry.Version < 1 || registry.Hosts is null || registry.Hosts.Count > 100 || + registry.Hosts.Distinct(StringComparer.Ordinal).Count() != registry.Hosts.Count || + registry.Hosts.Any(h => !RecruitmentPortfolioDomains.TryNormalize(h, out var normalized) || h != normalized) || + registry.Audit is null || registry.Audit.Count > 100 || registry.Audit.Any(a => a is null || a.At == default || + !Id(a.Actor) || string.IsNullOrWhiteSpace(a.Reason) || a.Reason.Length > 570)) + throw new InvalidDataException("Recruitment portfolio-site registry is invalid."); + } + + private static void ValidateOverview(RecruitmentState state) + { + var overview = state.Overview; + if (overview.Sequence < 0 || + overview.MessageId is not null && (!Id(overview.MessageId) || overview.Marker is null || !overview.Marker.StartsWith($"recruitment:overview:{state.InstanceId}:")) || + overview.RetiredMessageId is not null && (!Id(overview.RetiredMessageId) || overview.RetiredMessageId == overview.MessageId || overview.RetiredMarker is null || !overview.RetiredMarker.StartsWith($"recruitment:overview:{state.InstanceId}:")) || + overview.SendPending && (overview.SendStartedAt is null || overview.PendingMarker is null || !overview.PendingMarker.StartsWith($"recruitment:overview:{state.InstanceId}:"))) + throw new InvalidDataException("Recruitment overview receipt is invalid."); + } + + private static void ValidateForum(string purpose, ForumReceipt f) + { + if (f is null || f.Tags is null || f.ConfirmedTopic is null || f.VisibleCode is null || f.Reference is null || + f.Reference.ThreadId is not null && !Id(f.Reference.ThreadId) || + f.Reference.CreatePending && (f.Reference.CreateStartedAt is null || f.Reference.ThreadId is not null) || + f.Tags.Values.Any(t => t is null || !Id(t.Id) || string.IsNullOrWhiteSpace(t.Name))) + throw new InvalidDataException($"Recruitment Forums[{purpose}] receipt is invalid."); + } + + private static void ValidateListing(string id, Listing p, RecruitmentState state, IReadOnlyDictionary forums) + { + if (p is null || id != p.Id || !Id(id) || !Id(p.OwnerId) || !forums.Values.Contains(p.ForumId)) + throw new InvalidDataException($"Recruitment Listings[{id}] identity is invalid."); + if (p.PortfolioRegistryVersion < 0 || p.PortfolioRegistryVersion > state.PortfolioSites.Version || + p.CreatedAt == default || p.CreatedAt.Offset != TimeSpan.Zero || p.UpdatedAt == default || + !Enum.IsDefined(p.Status) || p.ValidCodes is null || p.Facts is null || p.Audit is null || + !state.Authors.ContainsKey(p.OwnerId) || p.Facts.Payment is null || p.Facts.Portfolio is null) + throw new InvalidDataException($"Recruitment Listings[{id}] lifecycle is invalid."); + if ((p.AcceptedAt.HasValue && !p.AcknowledgedAt.HasValue) || + (p.Deadline.HasValue && !p.PromptDeliveredAt.HasValue) || + (p.PendingAction is not null && !Enum.IsDefined(p.PendingAction.Kind))) + throw new InvalidDataException($"Recruitment Listings[{id}] action is invalid."); + ValidateDelivery(id, p); + ValidateEvidence(id, p); + } + + private static void ValidateDelivery(string id, Listing p) + { + if (p.PublicMediaAttachmentId is not null && !Id(p.PublicMediaAttachmentId) || + (p.PublicMediaAttachmentId is null) != (p.PublicMediaFileName is null) || + p.PublicMediaFileName is { } fileName && (fileName.Length > 120 || !fileName.StartsWith("recruitment-observations-", StringComparison.Ordinal) || !fileName.EndsWith(".png", StringComparison.Ordinal)) || + p.PublicRestoreLocked && !p.PublicRestoreArchive || + p.AcknowledgementNotice is null || p.AcknowledgementNotice.MessageId is not null && !Id(p.AcknowledgementNotice.MessageId) || + p.AcknowledgementNotice.SendPending && p.AcknowledgementNotice.StartedAt is null || + p.PublicPin is null || p.PublicPin.MessageId is not null && !Id(p.PublicPin.MessageId) || + p.PublicPin.Confirmed && p.PublicPin.MessageId is null || + p.PublicPin.RestoreLocked && !p.PublicPin.RestoreArchive) + throw new InvalidDataException($"Recruitment Listings[{id}].Delivery is invalid."); + } + + private static void ValidateEvidence(string id, Listing p) + { + if (!FactsValid(p.Facts) || p.Facts.Observations is null || p.Facts.Observations.Count > 4 || + p.Facts.Observations.Any(o => o is null || o.Code is null || o.Description is null || o.Evidence is null || o.Evidence.Length > 180) || + p.Facts.PaymentEvidence is null || p.Facts.PaymentEvidence.Length > 370 || + p.EarlierMaterialVersions is null || p.EarlierMaterialVersions.Count > 3 || + p.MaterialChanges < 0 || p.ReviewedMaterialChanges < 0 || p.ReviewedMaterialChanges > p.MaterialChanges || + !VersionValid(p.OriginalStarter) || !VersionValid(p.LatestStarter) || p.EarlierMaterialVersions.Any(v => v is null || !VersionValid(v)) || + p.Reports is null || p.Reports.Count > 20 || p.Reports.Any(r => r is null || !Id(r.ReporterId) || r.At == default || r.Concern is null || r.Concern.Length > 1000) || + p.Attention is null || p.Attention.Count > 8 || p.Attention.Any(n => n is null || !Guid.TryParseExact(n.Id, "N", out _) || n.At == default || n.Reason is null || n.Reason.Length > 300 || n.MessageId is not null && !Id(n.MessageId))) + throw new InvalidDataException($"Recruitment Listings[{id}].Evidence is invalid."); + } + + private static void ValidateAuthor(string id, AuthorHistory a) + { + if (!Id(id) || a is null || a.ConsecutiveTimeouts < 0 || a.UpdatedAt == default || + a.RecentCreations is null || a.RecentCreations.Count > 21 || a.RecentCreations.Any(d => d == default) || a.RecentReports is null || a.RecentReports.Count > 3 || a.RecentReports.Any(d => d == default)) + throw new InvalidDataException($"Recruitment Authors[{id}] history is invalid."); + } + + private static bool FactsValid(InitialFacts f) => + f.ServerNickname is null or { Length: <= 100 } && f.GlobalName is null or { Length: <= 100 } && + f.Username is null or { Length: <= 100 } && f.AvatarIdentity is null or { Length: <= 200 } && + (f.AvatarUrl is null || RecruitmentSignals.SafeAvatarUrl(f.AvatarUrl) == f.AvatarUrl) && + f.Checks is { Count: <= 9 } && f.Checks.Select(c => c?.Id).Distinct().Count() == f.Checks.Count && + f.Checks.All(c => c is not null && c.Id is { Length: <= 30 } && c.Label is { Length: <= 30 } && c.Value is { Length: <= 100 } && + c.Evidence is null or { Length: <= 370 } && Enum.IsDefined(c.Severity) && Enum.IsDefined(c.Applicability)) && + f.PortfolioCandidates is { Candidates.Count: <= 8 } && f.PortfolioCandidates.Candidates.All(c => + c is not null && c.Url is { Length: <= 300 } && c.Host is { Length: <= 253 } && c.ReuseUrl is null or { Length: <= 300 }); + + private static bool VersionValid(StarterVersion? version) => version is null || + version.ObservedAt != default && version.Content is { Length: <= 2000 } && version.Fingerprint is { Length: 64 } && + (version.TermsFingerprint is null || version.TermsFingerprint.Length == 64) && + version.Payment is not null && version.PaymentEvidence is { Length: <= 370 } && version.Links is { Count: <= 8 } && + version.Links.All(l => l is { Length: <= 300 }) && version.Attachments is { Count: <= 10 } && + version.Attachments.All(a => a is not null && a.Name is { Length: <= 120 } && (a.ContentType is null || a.ContentType.Length <= 80)); + +} From 32a32e31631e14ba2efadd5054c75c447d783033 Mon Sep 17 00:00:00 2001 From: James Kellie Date: Mon, 19 Oct 2026 21:06:11 +1000 Subject: [PATCH 26/31] Reuse listing analysis and isolate public rendering --- .../Analysis/ListingAnalysisTests.cs | 36 +++++++++ .../Discord/RecruitmentPinTests.cs | 3 +- .../Application/RecruitmentAttempts.cs | 7 +- .../Application/RecruitmentAttention.cs | 3 +- .../Application/RecruitmentEngine.cs | 12 +-- .../Application/RecruitmentEvidence.cs | 29 ++++---- .../RecruitmentPortfolioCommands.cs | 3 +- .../Application/RecruitmentReports.cs | 3 +- .../Domain/Analysis/ListingAnalysis.cs | 23 ++++++ .../Domain/Analysis/RecruitmentContent.cs | 20 +++-- .../Analysis/RecruitmentPortfolioUrls.cs | 12 +-- .../Domain/Analysis/RecruitmentSignals.cs | 69 +++++++++++++---- .../Domain/Models/RecruitmentFault.cs | 3 +- .../Discord/DiscordRecruitmentAdapter.cs | 5 +- .../Presentation/PublicListingRenderer.cs | 46 ++++++++++++ .../Presentation/PublicListingView.cs | 30 ++++++++ .../Presentation/RecruitmentBanner.cs | 7 +- .../Presentation/RecruitmentPresentation.cs | 74 +++---------------- .../Recruitment/RecruitmentService.cs | 5 +- .../Recruitment/Runtime/RecruitmentHealth.cs | 11 +-- DiscordBot/Utils/DiscordDisplayText.cs | 9 +++ DiscordBot/Utils/DomainName.cs | 7 ++ 22 files changed, 287 insertions(+), 130 deletions(-) create mode 100644 DiscordBot.Tests/Recruitment/Analysis/ListingAnalysisTests.cs create mode 100644 DiscordBot/Services/Recruitment/Domain/Analysis/ListingAnalysis.cs create mode 100644 DiscordBot/Services/Recruitment/Presentation/PublicListingRenderer.cs create mode 100644 DiscordBot/Services/Recruitment/Presentation/PublicListingView.cs create mode 100644 DiscordBot/Utils/DiscordDisplayText.cs create mode 100644 DiscordBot/Utils/DomainName.cs diff --git a/DiscordBot.Tests/Recruitment/Analysis/ListingAnalysisTests.cs b/DiscordBot.Tests/Recruitment/Analysis/ListingAnalysisTests.cs new file mode 100644 index 00000000..fbc7ecac --- /dev/null +++ b/DiscordBot.Tests/Recruitment/Analysis/ListingAnalysisTests.cs @@ -0,0 +1,36 @@ +using DiscordBot.Services.Recruitment; +using DiscordBot.Services.Recruitment.Domain.Analysis; +using DiscordBot.Utils; + +namespace DiscordBot.Tests.Recruitment; + +public class ListingAnalysisTests +{ + [Theory] + [InlineData("github.com", true)] + [InlineData("USER.GitHub.Com", true)] + [InlineData("fakegithub.com", false)] + [InlineData("github.com.example.org", false)] + public void DomainMatchingRequiresRootOrDotBoundary(string host, bool expected) => + Assert.Equal(expected, DomainName.Matches(host, "github.com")); + + [Fact] + public async Task EmptyRegistryChangesChecksButPreservesSeedBasedSummaryAndEvidence() + { + using var rig = new RecruitmentRig(); await rig.Start(); + var post = await rig.Post(); + var live = rig.Discord.Threads[post.Id] with { Starter = "https://github.com/Example/CaseSensitive?utm_source=chat" }; + var analysis = ListingAnalysis.Read(live, "paid-for-hire", new HashSet()); + Assert.False(Assert.Single(analysis.Portfolio.Candidates).Supported); + Assert.Equal("work-sample link detected", analysis.PortfolioSummary); + Assert.Equal("https://github.com/Example/CaseSensitive", Assert.Single(analysis.ContactLinks)); + Assert.Equal(RecruitmentContent.Observations(live.Starter), analysis.Observations); + } + + [Fact] + public void LiteralDisplayTruncatesBeforeReplacingControlAndFormattingCharacters() + { + Assert.Equal("A ", DiscordDisplayText.Literal("A\n@*Z", 4)); + Assert.Equal("café 🦆", DiscordDisplayText.Literal("café 🦆")); + } +} diff --git a/DiscordBot.Tests/Recruitment/Discord/RecruitmentPinTests.cs b/DiscordBot.Tests/Recruitment/Discord/RecruitmentPinTests.cs index d4ffd519..285555e8 100644 --- a/DiscordBot.Tests/Recruitment/Discord/RecruitmentPinTests.cs +++ b/DiscordBot.Tests/Recruitment/Discord/RecruitmentPinTests.cs @@ -1,3 +1,4 @@ +using DiscordBot.Utils; using DiscordBot.Services.Recruitment; namespace DiscordBot.Tests.Recruitment; @@ -60,7 +61,7 @@ public async Task RateLimitRetriesClosedListingWithUnchangedPublicRender() rig.Discord.PinFailure = new RecruitmentDeferredException(retry); var post = await rig.Post(); await rig.Ack(); await rig.Staff("close"); Assert.Equal(ListingStatus.Closed, post.Status); Assert.Equal(retry, post.PublicPin.Fault!.RetryAt); - Assert.Contains(RecruitmentPresentation.Timestamp(retry), await rig.Staff("lookup")); + Assert.Contains(DiscordDisplayText.Timestamp(retry), await rig.Staff("lookup")); rig.Discord.PinFailure = null; rig.Clock.Advance(TimeSpan.FromMinutes(2)); await rig.Engine.RunDueAsync(default); Assert.True(post.PublicPin.Confirmed); Assert.Equal(ListingStatus.Closed, post.Status); diff --git a/DiscordBot/Services/Recruitment/Application/RecruitmentAttempts.cs b/DiscordBot/Services/Recruitment/Application/RecruitmentAttempts.cs index 061fd69e..1c179429 100644 --- a/DiscordBot/Services/Recruitment/Application/RecruitmentAttempts.cs +++ b/DiscordBot/Services/Recruitment/Application/RecruitmentAttempts.cs @@ -1,3 +1,4 @@ +using DiscordBot.Utils; using DiscordBot.Services.Recruitment.Domain.Policies; namespace DiscordBot.Services.Recruitment; @@ -16,7 +17,7 @@ public async Task ThreadCreatedAsync(ThreadCreation creation, CancellationToken var post = new Listing { Id = creation.Id, ForumId = creation.ForumId, OwnerId = creation.OwnerId, - Title = RecruitmentPresentation.Safe(creation.Title), CreatedAt = creation.CreatedAt.ToUniversalTime(), UpdatedAt = Now, + Title = DiscordDisplayText.Literal(creation.Title), CreatedAt = creation.CreatedAt.ToUniversalTime(), UpdatedAt = Now, Status = ListingStatus.Deleted, LastOutcome = "Thread absent before first read; creation observed, deletion actor unknown", PublicationPending = true }; @@ -55,9 +56,9 @@ public async Task EligibilityAsync(string guild, string owner, Cancellat if (State.Authors.TryGetValue(owner, out var author)) { if (author.LastAcceptedCreation?.AddDays(_settings.CooldownDays) is { } next && next > Now) - lines.Add($"Your accepted-listing cooldown ends {RecruitmentPresentation.Timestamp(next)}. Closing or deleting does not reset this cooldown."); + lines.Add($"Your accepted-listing cooldown ends {DiscordDisplayText.Timestamp(next)}. Closing or deleting does not reset this cooldown."); if (author.AttemptPausedUntil > Now) - lines.Add($"Posting attempts are paused until {RecruitmentPresentation.Timestamp(author.AttemptPausedUntil.Value)}. A moderator can review a mistake."); + lines.Add($"Posting attempts are paused until {DiscordDisplayText.Timestamp(author.AttemptPausedUntil.Value)}. A moderator can review a mistake."); } if (!EvidenceCurrent) lines.Add("Eligibility is being checked; the one-listing rule still applies."); diff --git a/DiscordBot/Services/Recruitment/Application/RecruitmentAttention.cs b/DiscordBot/Services/Recruitment/Application/RecruitmentAttention.cs index fff65fa3..c2142556 100644 --- a/DiscordBot/Services/Recruitment/Application/RecruitmentAttention.cs +++ b/DiscordBot/Services/Recruitment/Application/RecruitmentAttention.cs @@ -1,3 +1,4 @@ +using DiscordBot.Utils; namespace DiscordBot.Services.Recruitment; public sealed partial class RecruitmentEngine @@ -11,7 +12,7 @@ public sealed partial class RecruitmentEngine var reasons = new List(); if (post.Reports.Any(r => r.ResolvedAt is null)) reasons.Add("private reports"); if (post.MaterialChanges > post.ReviewedMaterialChanges) reasons.Add("changed terms/links"); - if (post.PublicPin.Fault is not null) reasons.Add("optional response pin: " + RecruitmentPresentation.Safe(post.PublicPin.Fault.Message, 100)); + if (post.PublicPin.Fault is not null) reasons.Add("optional response pin: " + DiscordDisplayText.Literal(post.PublicPin.Fault.Message, 100)); if (post.Hold is not null) reasons.Add("processing held"); if (post.AttemptPausedUntil > Now) reasons.Add("posting pause"); if (post.TimeoutAlertCount >= 3 && State.Authors.GetValueOrDefault(post.OwnerId)?.TimeoutAlerted == true) reasons.Add("repeated timeouts"); diff --git a/DiscordBot/Services/Recruitment/Application/RecruitmentEngine.cs b/DiscordBot/Services/Recruitment/Application/RecruitmentEngine.cs index 36aa7212..b66c73e4 100644 --- a/DiscordBot/Services/Recruitment/Application/RecruitmentEngine.cs +++ b/DiscordBot/Services/Recruitment/Application/RecruitmentEngine.cs @@ -1,3 +1,4 @@ +using DiscordBot.Utils; using DiscordBot.Services.Recruitment.Domain.Policies; using System.IO; using DiscordBot.Settings; @@ -184,14 +185,13 @@ public Task ObserveAsync(ThreadSnapshot live, bool observedFromCreation, Cancell var purpose = Purpose(live.ForumId); var post = new Listing { - Id = live.Id, ForumId = live.ForumId, OwnerId = live.OwnerId, Title = RecruitmentPresentation.Safe(live.Title), + Id = live.Id, ForumId = live.ForumId, OwnerId = live.OwnerId, Title = DiscordDisplayText.Literal(live.Title), CreatedAt = live.CreatedAt.ToUniversalTime(), UpdatedAt = Now, Pinned = live.Pinned, ResponseCoverageComplete = observedFromCreation, Facts = new InitialFacts { AccountCreatedAt = live.AccountCreatedAt, JoinedAt = live.JoinedAt, - Payment = RecruitmentContent.Payment(live.Starter), MemberRoleCount = live.MemberRoleCount, - Portfolio = RecruitmentContent.Portfolio(live.Starter), + MemberRoleCount = live.MemberRoleCount, TagAdvice = _assets!.TagAdvice(purpose, live.Tags, State.Forums[purpose]), PriorAcceptedListing = State.Authors.GetValueOrDefault(live.OwnerId)?.LastAcceptedCreation is not null } @@ -233,7 +233,7 @@ private async Task ProcessAsync(Listing post, CancellationToken ct) await BeginActionAsync(post, RecruitmentActionKind.RemoveAdvisory, "system", post.LastOutcome, "", false, ct); return; } - post.Title = RecruitmentPresentation.Safe(live.Title); + post.Title = DiscordDisplayText.Literal(live.Title); RefreshFacts(post, live); post.Facts.TagAdvice = _assets!.TagAdvice(Purpose(post.ForumId), live.Tags, State!.Forums[Purpose(post.ForumId)]); if (!post.PublicSendPending) @@ -433,7 +433,7 @@ public async Task CloseOwnerAsync(string guild, string thread, string ow _confirmations[token] = new RemovalConfirmation(guild, thread, owner, post.Version, Now.AddMinutes(2)); var next = State!.Authors.GetValueOrDefault(owner)?.LastAcceptedCreation?.AddDays(_settings.CooldownDays); var timing = !post.AcceptedAt.HasValue ? "Removing this unaccepted attempt adds no accepted-listing cooldown; its creation still counts toward the attempt limit." : next.HasValue - ? "Its creation cooldown remains " + RecruitmentPresentation.Timestamp(next.Value) + "." : "The accepted creation cooldown has been waived."; + ? "Its creation cooldown remains " + DiscordDisplayText.Timestamp(next.Value) + "." : "The accepted creation cooldown has been waived."; return ($"Permanently remove this forum post? {timing} This confirmation expires in two minutes.", token); } @@ -569,7 +569,7 @@ public async Task StaffAsync(string guild, string actor, string operatio return "No retained recruitment record for that thread or author."; } if (string.IsNullOrWhiteSpace(reason)) return "A reason is required for this change."; - reason = RecruitmentPresentation.Safe(reason, 300); + reason = DiscordDisplayText.Literal(reason, 300); if (operation == "repair") { if (target.Length > 0 && !State.Forums.ContainsKey(target)) return "Unknown recruitment forum purpose."; diff --git a/DiscordBot/Services/Recruitment/Application/RecruitmentEvidence.cs b/DiscordBot/Services/Recruitment/Application/RecruitmentEvidence.cs index 51f04192..e3322767 100644 --- a/DiscordBot/Services/Recruitment/Application/RecruitmentEvidence.cs +++ b/DiscordBot/Services/Recruitment/Application/RecruitmentEvidence.cs @@ -1,3 +1,5 @@ +using DiscordBot.Services.Recruitment.Domain.Analysis; +using DiscordBot.Utils; using System.Security.Cryptography; using System.Text; @@ -7,11 +9,12 @@ public sealed partial class RecruitmentEngine { private void RefreshFacts(Listing post, ThreadSnapshot live) { - var payment = RecruitmentContent.ReadPayment(live.Starter); var purpose = State!.Forums.First(f => f.Value.ForumId == post.ForumId).Key; - post.Facts.Checks = RecruitmentSignals.Evaluate(live, purpose, State.PortfolioSites.EffectiveHosts()); + var analysis = ListingAnalysis.Read(live, purpose, State.PortfolioSites.EffectiveHosts()); + var payment = analysis.Payment; + post.Facts.Checks = RecruitmentSignals.Evaluate(live, purpose, analysis); post.PortfolioRegistryVersion = State.PortfolioSites.Version; - post.Facts.PortfolioCandidates = RecruitmentContent.ReadPortfolio(live.Starter, live.Attachments, State.PortfolioSites.EffectiveHosts()); + post.Facts.PortfolioCandidates = analysis.Portfolio; post.Facts.ServerNickname = RecruitmentSignals.SafeIdentity(live.ServerNickname); post.Facts.GlobalName = RecruitmentSignals.SafeIdentity(live.GlobalName); post.Facts.Username = RecruitmentSignals.SafeIdentity(live.Username); @@ -20,16 +23,16 @@ private void RefreshFacts(Listing post, ThreadSnapshot live) post.Facts.AccountCreatedAt = live.AccountCreatedAt; post.Facts.JoinedAt = live.JoinedAt; post.Facts.MemberRoleCount = live.MemberRoleCount; post.Facts.HasCustomAvatar = live.HasCustomAvatar; post.Facts.Payment = payment.Summary; post.Facts.PaymentEvidence = payment.Evidence; - post.Facts.Portfolio = RecruitmentContent.Portfolio(live.Starter, live.Attachments); - post.Facts.Observations = RecruitmentContent.Observations(live.Starter); + post.Facts.Portfolio = analysis.PortfolioSummary; + post.Facts.Observations = analysis.Observations; if (live.Starter is null) return; // Missing data must not overwrite previously observed evidence. var attachments = live.Attachments?.Take(10).ToList() ?? new(); var normalized = string.Join(' ', live.Starter.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries)); var fingerprint = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(normalized + "\n" + string.Join('|', attachments.Select(a => a.Id))))); if (post.LatestStarter?.Fingerprint == fingerprint) return; var version = new StarterVersion(Now, live.Starter[..Math.Min(2000, live.Starter.Length)], live.Starter.Length > 2000, - fingerprint, payment.Summary, payment.Evidence, RecruitmentContent.ContactLinks(live.Starter), attachments, - Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(RecruitmentContent.CompensationTerms(live.Starter))))); + fingerprint, payment.Summary, payment.Evidence, analysis.ContactLinks, attachments, + Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(analysis.CompensationTerms)))); if (post.LatestStarter is { } previous && post.AcceptedAt.HasValue && (previous.Payment != version.Payment || previous.PaymentEvidence != version.PaymentEvidence || previous.TermsFingerprint is not null && previous.TermsFingerprint != version.TermsFingerprint || @@ -48,19 +51,19 @@ private void RefreshFacts(Listing post, ThreadSnapshot live) private string EvidenceDetails(Listing post) { var text = RecruitmentPresentation.Staff(State!, post, _settings).Content + "\n" + - $"Created {RecruitmentPresentation.Timestamp(post.CreatedAt)} · accepted {(post.AcceptedAt.HasValue ? RecruitmentPresentation.Timestamp(post.AcceptedAt.Value) : "no")}\n"; + $"Created {DiscordDisplayText.Timestamp(post.CreatedAt)} · accepted {(post.AcceptedAt.HasValue ? DiscordDisplayText.Timestamp(post.AcceptedAt.Value) : "no")}\n"; var pin = post.PublicPin; text += $"Optional response pin: {(pin.MessageId is null ? "not attempted" : pin.Confirmed ? "confirmed; later manual unpins respected" : "pending")} · message `{pin.MessageId ?? "none"}`" + (pin.RestoreArchive ? " · archive/lock restoration pending" : "") + "\n"; if (pin.Fault is { } pinFault) - text += $"Pin fault: {RecruitmentPresentation.Safe(pinFault.Message, 400)} · retry {(pinFault.RetryAt is { } retry ? RecruitmentPresentation.Timestamp(retry) : "after relevant access change or explicit recovery")}\n"; + text += $"Pin fault: {DiscordDisplayText.Literal(pinFault.Message, 400)} · retry {(pinFault.RetryAt is { } retry ? DiscordDisplayText.Timestamp(retry) : "after relevant access change or explicit recovery")}\n"; var versions = new[] { post.OriginalStarter }.Concat(post.EarlierMaterialVersions).Append(post.LatestStarter) .OfType().DistinctBy(v => (v.ObservedAt, v.Fingerprint)); foreach (var version in versions) { text += $"\n**Starter observed {version.ObservedAt:u}{(version.Truncated ? " (first 2,000 characters)" : "")}**\n" + - RecruitmentPresentation.Safe(version.Content, 2000) + "\n"; - if (version.Attachments.Count > 0) text += "Attachments: " + string.Join(", ", version.Attachments.Select(a => RecruitmentPresentation.Safe(a.Name, 120))) + "\n"; + DiscordDisplayText.Literal(version.Content, 2000) + "\n"; + if (version.Attachments.Count > 0) text += "Attachments: " + string.Join(", ", version.Attachments.Select(a => DiscordDisplayText.Literal(a.Name, 120))) + "\n"; } if (post.LatestStarter is { } latest) { @@ -73,8 +76,8 @@ static IEnumerable Retained(Listing p) => new[] { p.OriginalStar if (related.Length > 0) text += "\nShared starter text or link with: " + string.Join(", ", related) + ". Review context; shared projects can be legitimate.\n"; } foreach (var report in post.Reports) - text += $"\n**Private report · {(report.ResolvedAt.HasValue ? "resolved" : "open")} · {report.At:u} · reporter `{report.ReporterId}`**\n{RecruitmentPresentation.Safe(report.Concern, 1000)}\n"; - foreach (var audit in post.Audit.TakeLast(10)) text += $"\n{audit.At:u} · `{audit.Actor}` · {RecruitmentPresentation.Safe(audit.Reason, 300)}"; + text += $"\n**Private report · {(report.ResolvedAt.HasValue ? "resolved" : "open")} · {report.At:u} · reporter `{report.ReporterId}`**\n{DiscordDisplayText.Literal(report.Concern, 1000)}\n"; + foreach (var audit in post.Audit.TakeLast(10)) text += $"\n{audit.At:u} · `{audit.Actor}` · {DiscordDisplayText.Literal(audit.Reason, 300)}"; return text; } } diff --git a/DiscordBot/Services/Recruitment/Application/RecruitmentPortfolioCommands.cs b/DiscordBot/Services/Recruitment/Application/RecruitmentPortfolioCommands.cs index aea24c82..4754dc26 100644 --- a/DiscordBot/Services/Recruitment/Application/RecruitmentPortfolioCommands.cs +++ b/DiscordBot/Services/Recruitment/Application/RecruitmentPortfolioCommands.cs @@ -1,3 +1,4 @@ +using DiscordBot.Utils; using System.Globalization; using System.IO; @@ -16,7 +17,7 @@ public async Task PortfolioSitesAsync(string guild, string actor, string (registry.Hosts.Count == 0 ? "None configured." : string.Join("\n", registry.Hosts.Order(StringComparer.Ordinal))) + $"\nRegistry version {registry.Version}. Subdomains match at a dot boundary."; if (operation is not ("add" or "remove")) return "Choose list, add or remove."; - reason = RecruitmentPresentation.Safe(reason, 300).Trim(); + reason = DiscordDisplayText.Literal(reason, 300).Trim(); if (string.IsNullOrWhiteSpace(reason)) return "A reason is required for this change."; if (!RecruitmentPortfolioDomains.TryNormalize(domain, out var host)) return "Enter a DNS hostname below a public suffix, without a URL, path, port, credentials or wildcard."; diff --git a/DiscordBot/Services/Recruitment/Application/RecruitmentReports.cs b/DiscordBot/Services/Recruitment/Application/RecruitmentReports.cs index 3b7dde9d..5e007d16 100644 --- a/DiscordBot/Services/Recruitment/Application/RecruitmentReports.cs +++ b/DiscordBot/Services/Recruitment/Application/RecruitmentReports.cs @@ -1,3 +1,4 @@ +using DiscordBot.Utils; using DiscordBot.Services.Recruitment.Domain.Policies; namespace DiscordBot.Services.Recruitment; @@ -37,7 +38,7 @@ public async Task ReportAsync(string guild, string thread, string report history.RecentReports.RemoveAll(d => d <= Now.AddDays(-1)); if (history.RecentReports.Count >= 3) return "You have reached the limit of three listing reports in 24 hours. Contact a moderator for urgent concerns."; var firstOpen = post.Reports.All(r => r.ResolvedAt.HasValue); - post.Reports.Add(new RecruitmentReport { ReporterId = reporter, At = Now, Concern = RecruitmentPresentation.Safe(concern, 1000) }); + post.Reports.Add(new RecruitmentReport { ReporterId = reporter, At = Now, Concern = DiscordDisplayText.Literal(concern, 1000) }); history.RecentReports.Add(Now); if (firstOpen) QueueAttention(post, "A private reader report needs review"); post.UpdatedAt = Now; post.PublicationPending = true; Save(); diff --git a/DiscordBot/Services/Recruitment/Domain/Analysis/ListingAnalysis.cs b/DiscordBot/Services/Recruitment/Domain/Analysis/ListingAnalysis.cs new file mode 100644 index 00000000..11b3eb8e --- /dev/null +++ b/DiscordBot/Services/Recruitment/Domain/Analysis/ListingAnalysis.cs @@ -0,0 +1,23 @@ +using DiscordBot.Utils; + +namespace DiscordBot.Services.Recruitment.Domain.Analysis; + +/// One bounded parse per observed starter. Display summaries retain their seed-host semantics. +public sealed record ListingAnalysis(PaymentObservation Payment, PortfolioEvidence Portfolio, string PortfolioSummary, + string? Duration, string? DmRequest, int? NormalizedLength, List Observations, + List ContactLinks, string CompensationTerms) +{ + public static ListingAnalysis Read(ThreadSnapshot live, string purpose, IReadOnlySet? supportedHosts = null) + { + var payment = RecruitmentContent.ReadPayment(live.Starter); + var portfolio = RecruitmentContent.ReadPortfolio(live.Starter, live.Attachments, supportedHosts); + // Current checks use the persisted registry. The historical summary has always used seed hosts. + var legacy = portfolio with { Candidates = portfolio.Candidates.Select(c => c with + { Supported = RecruitmentContent.SeedPortfolioHosts.Any(h => DomainName.Matches(c.Host, h)) }).ToArray() }; + return new(payment, portfolio, RecruitmentContent.PortfolioSummary(legacy), + RecruitmentContent.Duration(live.Starter, purpose.EndsWith("for-hire", StringComparison.Ordinal)), + RecruitmentContent.DmRequest(live.Starter), RecruitmentContent.NormalizedLength(live.Starter), + RecruitmentContent.Observations(live.Starter, payment), RecruitmentContent.ContactLinks(portfolio), + live.Starter is null ? "" : RecruitmentContent.CompensationTerms(live.Starter)); + } +} diff --git a/DiscordBot/Services/Recruitment/Domain/Analysis/RecruitmentContent.cs b/DiscordBot/Services/Recruitment/Domain/Analysis/RecruitmentContent.cs index 2f6a28ba..710f4306 100644 --- a/DiscordBot/Services/Recruitment/Domain/Analysis/RecruitmentContent.cs +++ b/DiscordBot/Services/Recruitment/Domain/Analysis/RecruitmentContent.cs @@ -1,3 +1,4 @@ +using DiscordBot.Utils; using System.Text.RegularExpressions; namespace DiscordBot.Services.Recruitment; @@ -12,7 +13,7 @@ public sealed record StarterAttachment(string Id, string Name, string? ContentTy public static partial class RecruitmentContent { private static string Bounded(string text) => text[..Math.Min(text.Length, 8000)]; - private static string Excerpt(string text) => RecruitmentPresentation.Safe(text.Trim(), 180); + private static string Excerpt(string text) => DiscordDisplayText.Literal(text.Trim(), 180); public static string Payment(string? content) => ReadPayment(content).Summary; // An amount alone is not compensation. Labels and units provide context, never verification. @@ -64,7 +65,7 @@ public static PortfolioEvidence ReadPortfolio(string? content, IReadOnlyList Domain(identity.Host, d)), WorkLabel().IsMatch(line), NoWork().IsMatch(line), identity.ReuseUrl)); + (supportedHosts ?? SeedPortfolioHosts).Any(d => DomainName.Matches(identity.Host, d)), WorkLabel().IsMatch(line), NoWork().IsMatch(line), identity.ReuseUrl)); if (candidates.Count == 8) break; } return new(true, media, candidates); @@ -73,8 +74,10 @@ public static PortfolioEvidence ReadPortfolio(string? content, IReadOnlyList? attachments = null) + => PortfolioSummary(ReadPortfolio(content, attachments)); + + public static string PortfolioSummary(PortfolioEvidence facts) { - var facts = ReadPortfolio(content, attachments); if (facts.MediaSample) return "media sample attached"; if (!facts.Available) return "unknown"; if (facts.Candidates.Any(c => !c.Negated && c.Specific && (c.Supported || c.WorkContext))) return "work-sample link detected"; @@ -108,7 +111,9 @@ public static string Portfolio(string? content, IReadOnlyList catch (RegexMatchTimeoutException) { return null; } } - public static List Observations(string? content) + public static List Observations(string? content) => Observations(content, ReadPayment(content)); + + public static List Observations(string? content, PaymentObservation payment) { var observations = new List(); if (content is null) return observations; @@ -125,7 +130,6 @@ void Add(string code, string description, Func match) Add("payment-pressure", "Post combines payment with urgency", s => Urgency().IsMatch(s) && PaymentAction().IsMatch(s) && !NoPressure().IsMatch(s) && !SafetyQuote().IsMatch(s)); } catch (RegexMatchTimeoutException) { /* Observations are optional, never enforcement evidence. */ } - var payment = ReadPayment(content); if (payment.Kind == CompensationKind.Mixed) observations.Add(new("mixed-compensation", "Mixed compensation terms; clarify what is guaranteed", payment.Evidence[..Math.Min(payment.Evidence.Length, 180)])); return observations; } @@ -141,12 +145,14 @@ public static string CompensationTerms(string content) catch (RegexMatchTimeoutException) { return ""; } } - public static List ContactLinks(string? content) => ReadPortfolio(content).Candidates + public static List ContactLinks(string? content) => ContactLinks(ReadPortfolio(content)); + + public static List ContactLinks(PortfolioEvidence portfolio) => portfolio.Candidates .Where(c => c.Specific && !c.Negated && c.ReuseUrl is not null).Select(c => c.ReuseUrl!) .Distinct(StringComparer.Ordinal).Take(8).ToList(); private static bool TryLink(string value, out Uri? uri) => Uri.TryCreate(value.TrimEnd('.', ',', ';', '!', '?'), UriKind.Absolute, out uri) && uri.Scheme is "https" or "http" && uri.HostNameType == UriHostNameType.Dns; - private static bool Domain(string host, string domain) => host.Equals(domain, StringComparison.OrdinalIgnoreCase) || host.EndsWith("." + domain, StringComparison.OrdinalIgnoreCase); + [GeneratedRegex(@"(?:[$€£¥]\s*\d+(?:[.,]\d+)*(?:\s*[kK])?|\b\d+(?:[.,]\d+)*\s*(?:USD|AUD|CAD|EUR|GBP|JPY)\b|\b(?:USD|AUD|CAD|EUR|GBP|JPY)\s*\d+)", RegexOptions.IgnoreCase, 100)] private static partial Regex Concrete(); diff --git a/DiscordBot/Services/Recruitment/Domain/Analysis/RecruitmentPortfolioUrls.cs b/DiscordBot/Services/Recruitment/Domain/Analysis/RecruitmentPortfolioUrls.cs index 11082d7a..a9dde433 100644 --- a/DiscordBot/Services/Recruitment/Domain/Analysis/RecruitmentPortfolioUrls.cs +++ b/DiscordBot/Services/Recruitment/Domain/Analysis/RecruitmentPortfolioUrls.cs @@ -1,3 +1,4 @@ +using DiscordBot.Utils; using System.Text.RegularExpressions; namespace DiscordBot.Services.Recruitment; @@ -6,8 +7,7 @@ namespace DiscordBot.Services.Recruitment; public static class RecruitmentPortfolioUrls { public sealed record Identity(string DisplayUrl, string Host, bool Specific, string? ReuseUrl); - public static bool Domain(string host, string domain) => host.Equals(domain, StringComparison.OrdinalIgnoreCase) || - host.EndsWith("." + domain, StringComparison.OrdinalIgnoreCase); + public static Identity? Read(Uri uri) { @@ -16,12 +16,12 @@ public static bool Domain(string host, string domain) => host.Equals(domain, Str var path = uri.AbsolutePath; var route = path.Trim('/'); var parts = route.Split('/', StringSplitOptions.RemoveEmptyEntries); - if (Domain(host, "discord.com") || Domain(host, "discord.gg") || Domain(host, "discordapp.com") || - Domain(host, "t.me") || Domain(host, "telegram.me") || Domain(host, "wa.me") || + if (DomainName.Matches(host, "discord.com") || DomainName.Matches(host, "discord.gg") || DomainName.Matches(host, "discordapp.com") || + DomainName.Matches(host, "t.me") || DomainName.Matches(host, "telegram.me") || DomainName.Matches(host, "wa.me") || parts.Any(p => p.ToLowerInvariant() is "contact" or "login" or "signin" or "invite" or "join")) return null; var specific = parts.Length > 0; // Selected providers have creator-owned subdomains; their root is already a profile/project. - if (new[] { "itch.io", "artstation.com", "deviantart.com" }.Any(d => Domain(host, d) && host != d && host != "www." + d)) + if (new[] { "itch.io", "artstation.com", "deviantart.com" }.Any(d => DomainName.Matches(host, d) && host != d && host != "www." + d)) specific = true; // Shared platform entry points, discovery and authentication pages are not work samples. if (parts.Length > 0 && parts[0].ToLowerInvariant() is "explore" or "discover" or "search" or "marketplace" or "pricing" or "about" or "features" or "topics" or "trending" or "settings" or "notifications" or "issues" or "pulls" or "dashboard" or "help" or "support" or "feed" or "subscriptions") @@ -32,7 +32,7 @@ public static bool Domain(string host, string domain) => host.Equals(domain, Str if (query is null) return new(builder.Uri.AbsoluteUri, host, false, null); var identityQuery = query.Where(q => !Tracking(q.Key)).ToArray(); string? reuse = null; - if (Domain(host, "youtube.com") || host is "youtu.be" or "www.youtu.be") + if (DomainName.Matches(host, "youtube.com") || host is "youtu.be" or "www.youtu.be") { var videos = identityQuery.Where(q => q.Key == "v").ToArray(); var video = host.EndsWith("youtu.be", StringComparison.Ordinal) && parts.Length == 1 ? parts[0] : diff --git a/DiscordBot/Services/Recruitment/Domain/Analysis/RecruitmentSignals.cs b/DiscordBot/Services/Recruitment/Domain/Analysis/RecruitmentSignals.cs index f1991825..c96d355c 100644 --- a/DiscordBot/Services/Recruitment/Domain/Analysis/RecruitmentSignals.cs +++ b/DiscordBot/Services/Recruitment/Domain/Analysis/RecruitmentSignals.cs @@ -1,3 +1,5 @@ +using DiscordBot.Services.Recruitment.Domain.Analysis; +using DiscordBot.Utils; namespace DiscordBot.Services.Recruitment; public enum ObservationSeverity { Grey, Green, Yellow, Red } @@ -9,6 +11,9 @@ public sealed record RecruitmentObservation(string Id, string Label, Observation public static class RecruitmentSignals { public static List Evaluate(ThreadSnapshot live, string purpose, IReadOnlySet? supportedHosts = null) + => Evaluate(live, purpose, ListingAnalysis.Read(live, purpose, supportedHosts)); + + public static List Evaluate(ThreadSnapshot live, string purpose, ListingAnalysis analysis) { var paid = purpose.StartsWith("paid-", StringComparison.Ordinal); var hire = purpose.EndsWith("for-hire", StringComparison.Ordinal); @@ -28,54 +33,88 @@ void Age(string id, string label, DateTimeOffset? date, int highMonths, int lowM else Unknown(id, label); return; } - var high = origin.AddMonths(highMonths); var low = origin.AddMonths(lowMonths); - Add(id, label, posting >= high ? ObservationSeverity.Green : posting >= low ? ObservationSeverity.Yellow : ObservationSeverity.Red, - posting >= high ? $"At least {highMonths / 12} year{(highMonths == 12 ? "" : "s")}" : posting >= low ? $"At least {lowMonths} months" : $"Under {lowMonths} months"); + if (posting >= origin.AddMonths(highMonths)) + Add(id, label, ObservationSeverity.Green, $"At least {highMonths / 12} year{(highMonths == 12 ? "" : "s")}"); + else if (posting >= origin.AddMonths(lowMonths)) + Add(id, label, ObservationSeverity.Yellow, $"At least {lowMonths} months"); + else Add(id, label, ObservationSeverity.Red, $"Under {lowMonths} months"); } Age("account-age", "Account Age", live.AccountCreatedAt, 24, 6); Age("join-date", "Join Date", live.JoinedAt, 12, 2, true); if (live.MemberRoleCount is not { } roles || roles < 0) Unknown("server-roles", "Server Roles"); else Add("server-roles", "Server Roles", roles > 0 ? ObservationSeverity.Green : ObservationSeverity.Red, $"{roles} Role{(roles == 1 ? "" : "s")}"); - var payment = RecruitmentContent.ReadPayment(live.Starter); + var payment = analysis.Payment; var compensationRed = payment.Kind is CompensationKind.Missing or CompensationKind.Unpaid or CompensationKind.RevenueShare; if (!paid) Na("compensation", "Compensation"); else if (payment.Kind == CompensationKind.Unavailable) Unknown("compensation", "Compensation"); - else Add("compensation", "Compensation", payment.Kind == CompensationKind.Rate ? ObservationSeverity.Green : compensationRed ? ObservationSeverity.Red : ObservationSeverity.Yellow, + else Add("compensation", "Compensation", CompensationSeverity(payment.Kind), payment.Kind switch { CompensationKind.Rate => "Rate Stated", CompensationKind.Mixed => "Mixed Terms", CompensationKind.MonetaryPromise => "Rate Unclear", CompensationKind.Unpaid => "Unpaid / Exposure", CompensationKind.RevenueShare => "Revenue Share Only", _ => "No Monetary Pay Found" }, payment.Evidence); - var duration = RecruitmentContent.Duration(live.Starter, hire); + var duration = analysis.Duration; if (duration is null) Unknown("duration", "Job Duration"); - else Add("duration", "Job Duration", duration.Length > 0 ? ObservationSeverity.Green : paid && compensationRed ? ObservationSeverity.Red : ObservationSeverity.Yellow, + else Add("duration", "Job Duration", DurationSeverity(duration, paid && compensationRed), duration.Length > 0 ? "Window Stated" : "Timing Unclear", duration); - var portfolio = RecruitmentContent.ReadPortfolio(live.Starter, live.Attachments, supportedHosts); + var portfolio = analysis.Portfolio; if (!hire) Na("portfolio", "Portfolio"); else if (!portfolio.Available) Unknown("portfolio", "Portfolio"); else { var supported = portfolio.Candidates.FirstOrDefault(c => c.Specific && c.Supported && !c.Negated); var any = portfolio.Candidates.Any(c => !c.Negated && !IsContactOnly(c.Url)); - Add("portfolio", "Portfolio", supported is not null ? ObservationSeverity.Green : any || portfolio.MediaSample || !paid ? ObservationSeverity.Yellow : ObservationSeverity.Red, - supported is not null ? "Platform Link" : portfolio.MediaSample ? "Samples Attached" : any ? "External Link" : "No Samples Found", supported?.Url); + Add("portfolio", "Portfolio", PortfolioSeverity(supported is not null, any || portfolio.MediaSample || !paid), + PortfolioValue(supported is not null, portfolio.MediaSample, any), supported?.Url); } - var length = RecruitmentContent.NormalizedLength(live.Starter); - var dm = RecruitmentContent.DmRequest(live.Starter); + var length = analysis.NormalizedLength; + var dm = analysis.DmRequest; if (dm is null) Unknown("dm-requests", "DM Requests"); - else Add("dm-requests", "DM Requests", dm.Length == 0 ? ObservationSeverity.Green : length < 120 ? ObservationSeverity.Red : ObservationSeverity.Yellow, + else Add("dm-requests", "DM Requests", DmSeverity(dm, length), dm.Length == 0 ? "No DM-first Request" : "DM-first Request", dm); if (live.HasCustomAvatar is not { } avatar) Unknown("profile-image", "Profile Image"); else Add("profile-image", "Profile Image", avatar ? ObservationSeverity.Green : ObservationSeverity.Yellow, avatar ? "Custom Avatar" : "Default Avatar"); if (length is null) Unknown("post-detail", "Post Detail"); - else Add("post-detail", "Post Detail", length >= 300 ? ObservationSeverity.Green : length >= 120 ? ObservationSeverity.Yellow : ObservationSeverity.Red, + else Add("post-detail", "Post Detail", DetailSeverity(length.Value), $"{length} Characters", "Normalized body length is a heuristic, not proof of completeness or quality."); return result; } + private static ObservationSeverity CompensationSeverity(CompensationKind kind) => kind switch + { + CompensationKind.Rate => ObservationSeverity.Green, + CompensationKind.Missing or CompensationKind.Unpaid or CompensationKind.RevenueShare => ObservationSeverity.Red, + _ => ObservationSeverity.Yellow + }; + private static ObservationSeverity DurationSeverity(string duration, bool lacksPay) + { + if (duration.Length > 0) return ObservationSeverity.Green; + return lacksPay ? ObservationSeverity.Red : ObservationSeverity.Yellow; + } + private static ObservationSeverity PortfolioSeverity(bool supported, bool hasPossibleSamples) + { + if (supported) return ObservationSeverity.Green; + return hasPossibleSamples ? ObservationSeverity.Yellow : ObservationSeverity.Red; + } + private static string PortfolioValue(bool supported, bool media, bool link) + { + if (supported) return "Platform Link"; + if (media) return "Samples Attached"; + return link ? "External Link" : "No Samples Found"; + } + private static ObservationSeverity DmSeverity(string request, int? length) + { + if (request.Length == 0) return ObservationSeverity.Green; + return length < 120 ? ObservationSeverity.Red : ObservationSeverity.Yellow; + } + private static ObservationSeverity DetailSeverity(int length) => length switch + { + >= 300 => ObservationSeverity.Green, >= 120 => ObservationSeverity.Yellow, _ => ObservationSeverity.Red + }; + private static bool IsContactOnly(string url) => Uri.TryCreate(url, UriKind.Absolute, out var uri) && new[] { "/contact", "/login", "/signin", "/invite", "/join" }.Contains(uri.AbsolutePath.TrimEnd('/').ToLowerInvariant()); public static string? SafeIdentity(string? value, int max = 100) { if (string.IsNullOrWhiteSpace(value)) return null; - var safe = RecruitmentPresentation.Safe(value, max).Trim(); + var safe = DiscordDisplayText.Literal(value, max).Trim(); return safe.Length == 0 ? null : safe; } public static string? SafeAvatarUrl(string? value) => Uri.TryCreate(value, UriKind.Absolute, out var uri) && uri.Scheme == "https" && diff --git a/DiscordBot/Services/Recruitment/Domain/Models/RecruitmentFault.cs b/DiscordBot/Services/Recruitment/Domain/Models/RecruitmentFault.cs index 1fb53eff..a808b42a 100644 --- a/DiscordBot/Services/Recruitment/Domain/Models/RecruitmentFault.cs +++ b/DiscordBot/Services/Recruitment/Domain/Models/RecruitmentFault.cs @@ -1,3 +1,4 @@ +using DiscordBot.Utils; using Discord.Net; using System.Net; @@ -18,7 +19,7 @@ public static RecruitmentFault From(Exception error, DateTimeOffset now, Recruit : error.Message; var retry = error is RecruitmentDeferredException deferred ? deferred.RetryAt : permanent ? (DateTimeOffset?)null : now.AddSeconds(Math.Min(900, 15 * Math.Pow(2, attempts - 1)) + Random.Shared.Next(1, 6)); - return new RecruitmentFault(RecruitmentPresentation.Safe(message, 400), retry, attempts); + return new RecruitmentFault(DiscordDisplayText.Literal(message, 400), retry, attempts); } } diff --git a/DiscordBot/Services/Recruitment/Infrastructure/Discord/DiscordRecruitmentAdapter.cs b/DiscordBot/Services/Recruitment/Infrastructure/Discord/DiscordRecruitmentAdapter.cs index 08df3d32..b4adaa4a 100644 --- a/DiscordBot/Services/Recruitment/Infrastructure/Discord/DiscordRecruitmentAdapter.cs +++ b/DiscordBot/Services/Recruitment/Infrastructure/Discord/DiscordRecruitmentAdapter.cs @@ -1,3 +1,4 @@ +using DiscordBot.Utils; using System.IO; using System.Collections.Concurrent; using Discord.Net; @@ -231,7 +232,7 @@ private async Task SnapshotAsync(RestThreadChannel thread, Cance Array.Empty(), starter?.Content, SnowflakeUtils.FromSnowflake(ownerId), user?.JoinedAt, user is not null && (user.GuildPermissions.Administrator || user.RoleIds.Contains(_settings.Roles.Moderator)), user?.RoleIds.Count(r => r != _settings.GuildId), user is null ? null : !string.IsNullOrEmpty(user.DisplayAvatarId), - starter?.Attachments.Take(10).Select(a => new StarterAttachment(a.Id.ToString(), RecruitmentPresentation.Safe(a.Filename, 120), a.ContentType?[..Math.Min(a.ContentType.Length, 80)])).ToArray(), + starter?.Attachments.Take(10).Select(a => new StarterAttachment(a.Id.ToString(), DiscordDisplayText.Literal(a.Filename, 120), a.ContentType?[..Math.Min(a.ContentType.Length, 80)])).ToArray(), RecruitmentSignals.SafeIdentity(user?.Nickname), RecruitmentSignals.SafeIdentity(user?.GlobalName), RecruitmentSignals.SafeIdentity(user?.Username ?? starter?.Author.Username), RecruitmentSignals.SafeAvatarUrl(user?.GetDisplayAvatarUrl(ImageFormat.Png, 128)), @@ -376,7 +377,7 @@ public async Task DeleteAsync(ThreadSnapshot thread, string reason, bool protect var live = await ThreadAsync(thread.Id, ct); GuardWrite(live, thread, protectPinned); if (allowWrite?.Invoke() == false) throw new InvalidOperationException("New recruitment evidence requires reconciliation before this action."); - var options = Options(ct); options.AuditLogReason = "Recruitment: " + RecruitmentPresentation.Safe(reason); + var options = Options(ct); options.AuditLogReason = "Recruitment: " + DiscordDisplayText.Literal(reason); await live.DeleteAsync(options); InvalidateThread(thread.Id); } diff --git a/DiscordBot/Services/Recruitment/Presentation/PublicListingRenderer.cs b/DiscordBot/Services/Recruitment/Presentation/PublicListingRenderer.cs new file mode 100644 index 00000000..43ad7b9b --- /dev/null +++ b/DiscordBot/Services/Recruitment/Presentation/PublicListingRenderer.cs @@ -0,0 +1,46 @@ +using System.IO; +using static DiscordBot.Utils.DiscordDisplayText; + +namespace DiscordBot.Services.Recruitment.Presentation; + +public static class PublicListingRenderer +{ + public static RecruitmentMessage Render(PublicListingView view, Func, byte[]>? render = null) + { + var (checks, alt, needsAck, identity, avatar, marker, visible, foot, _, _) = view; + string? fileName = null; + MessageComponent Build(bool image) + { + var children = new List { new TextDisplayBuilder($"## {RecruitmentPresentation.Heading}") }; + if (image) children.Add(new MediaGalleryBuilder().AddItem("attachment://" + fileName, RecruitmentBanner.GalleryAlt(checks))); + else children.Add(new TextDisplayBuilder(string.Join("\n", checks.Select(c => $"{RecruitmentBanner.Icon(c)} **{c.Label}:** {Literal(c.Value, 60)}")))); + children.Add(new TextDisplayBuilder($"*{RecruitmentPresentation.Guidance}*")); + if (foot.Length > 0) children.Add(new TextDisplayBuilder(foot)); + if (image && avatar is not null) children.Add(new SectionBuilder(new ThumbnailBuilder(new UnfurledMediaItemProperties(avatar), "Author avatar"), new[] { new TextDisplayBuilder(identity) })); + else children.Add(new TextDisplayBuilder(identity + (avatar is null ? "\nAvatar unavailable." : "\nAvatar image omitted; see Profile Image observation."))); + var row = new ActionRowBuilder().WithButton("Legend", $"recruit:details:{view.ListingId}", ButtonStyle.Secondary) + .WithButton("Report concern", $"recruit:report:{view.ListingId}", ButtonStyle.Secondary) + .WithButton("Your listing", $"recruit:owner:{view.ListingId}", ButtonStyle.Secondary); + if (needsAck) row.WithButton("Acknowledge", $"recruit:ack:{view.ListingId}", ButtonStyle.Primary); + children.Add(row); + children.Add(new TextDisplayBuilder($"||{marker}||")); + return new ComponentBuilderV2(new[] { new ContainerBuilder(children) }).Build(); + } + RecruitmentMedia? media = null; + try + { + var bytes = (render ?? (c => RecruitmentBanner.Render(c)))(checks); + fileName = "recruitment-observations-" + Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(bytes)).ToLowerInvariant() + ".png"; + media = new(fileName, bytes, alt); + } + catch (Exception ex) when (ex is ImageMagick.MagickException or IOException or ArgumentException or InvalidOperationException) { /* Text remains complete and operable. */ } + // Deliberately excludes CDN signatures and SDK-generated component IDs. + var avatarKey = avatar is null ? "" : new Uri(avatar).GetLeftPart(UriPartial.Path); + var key = RecruitmentBanner.Identity(RecruitmentBanner.Version + "\n" + view.RegistryVersion + "\n" + + alt + "\n" + visible + foot + identity + avatarKey + "\n" + needsAck + "\n" + marker + "\n" + fileName); + var fallback = Build(false); + return new RecruitmentMessage("", marker, true, AcknowledgementRequired: needsAck, ReaderControls: true, + Format: RecruitmentMessageFormat.ComponentsV2, RenderIdentity: key, Components: media is null ? fallback : Build(true), + FallbackComponents: fallback, Media: media); + } +} diff --git a/DiscordBot/Services/Recruitment/Presentation/PublicListingView.cs b/DiscordBot/Services/Recruitment/Presentation/PublicListingView.cs new file mode 100644 index 00000000..35951150 --- /dev/null +++ b/DiscordBot/Services/Recruitment/Presentation/PublicListingView.cs @@ -0,0 +1,30 @@ +using static DiscordBot.Utils.DiscordDisplayText; + +namespace DiscordBot.Services.Recruitment.Presentation; + +/// Public fields only; private reports, audits and captured starter history cannot reach the renderer. +public sealed record PublicListingView(IReadOnlyList Checks, string Alt, bool NeedsAcknowledgement, + string Identity, string? Avatar, string Marker, string Visible, string Foot, string ListingId, long RegistryVersion) +{ + public static PublicListingView From(RecruitmentState state, Listing post) + { + var checks = post.Facts.Checks.ToArray(); + var alt = RecruitmentBanner.Alt(checks); + var needsAck = post.Status == ListingStatus.Open && !post.AcceptedAt.HasValue && !post.AcknowledgedAt.HasValue; + var operations = new List(); + if (needsAck) operations.Add("**Author:** Read this forum's Guidelines and enter its code with **Acknowledge**." + + (post.Deadline is { } deadline ? $" Due {Timestamp(deadline)}." : " Your window starts when these controls are available.")); + if (post.Status == ListingStatus.Closed) operations.Add("**Closed:** " + Literal(post.LastOutcome, 220)); + if (post.Hold is not null || post.AvailabilityUnknown) operations.Add("Bot processing is paused; listing details may be incomplete."); + var warnings = string.Join("\n", post.Facts.Observations.Take(4).Select(o => $"• {Literal(o.Description, 100)}: “{Literal(o.Evidence, 65)}”.")); + var name = new[] { post.Facts.ServerNickname, post.Facts.GlobalName, post.Facts.Username } + .Select(v => RecruitmentSignals.SafeIdentity(v)).FirstOrDefault(v => v is not null) ?? "Author unavailable"; + var identity = $"**{name}**\nUsername: {Literal(post.Facts.Username ?? "unavailable", 100)} · ID: {Literal(post.OwnerId, 30)}"; + var avatar = RecruitmentSignals.SafeAvatarUrl(post.Facts.AvatarUrl); + if (post.AvailabilityUnknown) identity += "\nIdentity last observed; current membership unavailable."; + var marker = $"recruitment:{post.Id}:public"; + var visible = $"## {RecruitmentPresentation.Heading}\n*{RecruitmentPresentation.Guidance}*\n"; + var foot = string.Join("\n", operations.Append(warnings).Where(v => v.Length > 0)); + return new(checks, alt, needsAck, identity, avatar, marker, visible, foot, post.Id, state.PortfolioSites.Version); + } +} diff --git a/DiscordBot/Services/Recruitment/Presentation/RecruitmentBanner.cs b/DiscordBot/Services/Recruitment/Presentation/RecruitmentBanner.cs index aa2ae3a4..cde80d31 100644 --- a/DiscordBot/Services/Recruitment/Presentation/RecruitmentBanner.cs +++ b/DiscordBot/Services/Recruitment/Presentation/RecruitmentBanner.cs @@ -1,3 +1,4 @@ +using DiscordBot.Utils; using ImageMagick; using ImageMagick.Drawing; using System.IO; @@ -18,11 +19,11 @@ public static string Status(RecruitmentObservation c) => c.Applicability != Obse c.Severity == ObservationSeverity.Yellow ? "Amber" : c.Severity.ToString(); public static string Icon(RecruitmentObservation c) => Status(c) switch { "Green" => "🟢", "Amber" => "🔶", "Red" => "🟥", _ => "⚪" }; - public static string Alt(IReadOnlyList checks) => string.Join("; ", checks.Select(c => $"{c.Label}: {RecruitmentPresentation.Safe(c.Value, 60)} ({Status(c)})")); + public static string Alt(IReadOnlyList checks) => string.Join("; ", checks.Select(c => $"{c.Label}: {DiscordDisplayText.Literal(c.Value, 60)} ({Status(c)})")); public static string GalleryAlt(IReadOnlyList checks) { var labels = new[] { "Age", "Joined", "Roles", "Pay", "Job Duration", "Portfolio", "DM", "Avatar", "Detail" }; - var text = string.Join("; ", checks.Select((c, i) => $"{labels[i]}: {RecruitmentPresentation.Safe(c.Value, 60)}")); + var text = string.Join("; ", checks.Select((c, i) => $"{labels[i]}: {DiscordDisplayText.Literal(c.Value, 60)}")); // The installed Discord.Net gallery builder caps descriptions at 256. The attachment // description and Legend retain full labels/statuses even for unexpected long values. return text.Length <= 256 ? text : "Nine automated observations. Complete values and explanations: Legend."; @@ -54,7 +55,7 @@ public static byte[] Render(IReadOnlyList checks, string else if (status == "Red") d.Rectangle(x + 172, y + 189, x + 204, y + 221); else d.FillColor(MagickColors.Transparent).StrokeColor(new MagickColor(color)).StrokeWidth(3) .Circle(x + 188, y + 205, x + 204, y + 205).StrokeWidth(0).StrokeColor(MagickColors.Transparent); - var words = RecruitmentPresentation.Safe(c.Value, 60).Split(' ', StringSplitOptions.RemoveEmptyEntries); + var words = DiscordDisplayText.Literal(c.Value, 60).Split(' ', StringSplitOptions.RemoveEmptyEntries); var lines = new List(); var line = ""; foreach (var word in words) { if (line.Length + word.Length > 16 && line.Length > 0) { lines.Add(line); line = ""; } line += (line.Length > 0 ? " " : "") + word; } lines.Add(line); diff --git a/DiscordBot/Services/Recruitment/Presentation/RecruitmentPresentation.cs b/DiscordBot/Services/Recruitment/Presentation/RecruitmentPresentation.cs index 483671e3..11c7ad19 100644 --- a/DiscordBot/Services/Recruitment/Presentation/RecruitmentPresentation.cs +++ b/DiscordBot/Services/Recruitment/Presentation/RecruitmentPresentation.cs @@ -1,3 +1,5 @@ +using DiscordBot.Services.Recruitment.Presentation; +using static DiscordBot.Utils.DiscordDisplayText; using DiscordBot.Settings; using System.IO; @@ -5,75 +7,21 @@ namespace DiscordBot.Services.Recruitment; public static class RecruitmentPresentation { - public static string Timestamp(DateTimeOffset date) => $""; - public static string Safe(string value, int max = 180) => new string(value.Take(max) - .Select(c => char.IsControl(c) || "`*_~|<>@[]\\".Contains(c) ? ' ' : c).ToArray()); - public const string Heading = "Recruitment · Automated Observations"; public const string Guidance = "Automated observations; neither endorsement nor judgment. Verify claims. Keep first contact and key terms here; use DMs only for private information."; public static RecruitmentMessage PublicV2(RecruitmentState state, Listing post, RecruitmentSettings settings, DateTimeOffset now, Func, byte[]>? render = null) { - var checks = post.Facts.Checks; - var alt = RecruitmentBanner.Alt(checks); - var needsAck = post.Status == ListingStatus.Open && !post.AcceptedAt.HasValue && !post.AcknowledgedAt.HasValue; - var operations = new List(); - if (needsAck) operations.Add("**Author:** Read this forum's Guidelines and enter its code with **Acknowledge**." + - (post.Deadline is { } deadline ? $" Due {Timestamp(deadline)}." : " Your window starts when these controls are available.")); - if (post.Status == ListingStatus.Closed) operations.Add("**Closed:** " + Safe(post.LastOutcome, 220)); - if (post.Hold is not null || post.AvailabilityUnknown) operations.Add("Bot processing is paused; listing details may be incomplete."); - var warnings = string.Join("\n", post.Facts.Observations.Take(4).Select(o => $"• {Safe(o.Description, 100)}: “{Safe(o.Evidence, 65)}”.")); - var name = new[] { post.Facts.ServerNickname, post.Facts.GlobalName, post.Facts.Username } - .Select(v => RecruitmentSignals.SafeIdentity(v)).FirstOrDefault(v => v is not null) ?? "Author unavailable"; - var identity = $"**{name}**\nUsername: {Safe(post.Facts.Username ?? "unavailable", 100)} · ID: {Safe(post.OwnerId, 30)}"; - var avatar = RecruitmentSignals.SafeAvatarUrl(post.Facts.AvatarUrl); - if (post.AvailabilityUnknown) identity += "\nIdentity last observed; current membership unavailable."; - var marker = $"recruitment:{post.Id}:public"; - var visible = $"## {Heading}\n*{Guidance}*\n"; - var foot = string.Join("\n", operations.Append(warnings).Where(v => v.Length > 0)); - string? fileName = null; - MessageComponent Build(bool image) - { - var children = new List { new TextDisplayBuilder($"## {Heading}") }; - if (image) children.Add(new MediaGalleryBuilder().AddItem("attachment://" + fileName, RecruitmentBanner.GalleryAlt(checks))); - else children.Add(new TextDisplayBuilder(string.Join("\n", checks.Select(c => $"{RecruitmentBanner.Icon(c)} **{c.Label}:** {Safe(c.Value, 60)}")))); - children.Add(new TextDisplayBuilder($"*{Guidance}*")); - if (foot.Length > 0) children.Add(new TextDisplayBuilder(foot)); - if (image && avatar is not null) children.Add(new SectionBuilder(new ThumbnailBuilder(new UnfurledMediaItemProperties(avatar), "Author avatar"), new[] { new TextDisplayBuilder(identity) })); - else children.Add(new TextDisplayBuilder(identity + (avatar is null ? "\nAvatar unavailable." : "\nAvatar image omitted; see Profile Image observation."))); - var row = new ActionRowBuilder().WithButton("Legend", $"recruit:details:{post.Id}", ButtonStyle.Secondary) - .WithButton("Report concern", $"recruit:report:{post.Id}", ButtonStyle.Secondary) - .WithButton("Your listing", $"recruit:owner:{post.Id}", ButtonStyle.Secondary); - if (needsAck) row.WithButton("Acknowledge", $"recruit:ack:{post.Id}", ButtonStyle.Primary); - children.Add(row); - children.Add(new TextDisplayBuilder($"||{marker}||")); - return new ComponentBuilderV2(new[] { new ContainerBuilder(children) }).Build(); - } - RecruitmentMedia? media = null; - try - { - var bytes = (render ?? (c => RecruitmentBanner.Render(c)))(checks); - fileName = "recruitment-observations-" + Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(bytes)).ToLowerInvariant() + ".png"; - media = new(fileName, bytes, alt); - } - catch (Exception ex) when (ex is ImageMagick.MagickException or IOException or ArgumentException or InvalidOperationException) { /* Text remains complete and operable. */ } - // Deliberately excludes CDN signatures and SDK-generated component IDs. - var avatarKey = avatar is null ? "" : new Uri(avatar).GetLeftPart(UriPartial.Path); - var key = RecruitmentBanner.Identity(RecruitmentBanner.Version + "\n" + state.PortfolioSites.Version + "\n" + - alt + "\n" + visible + foot + identity + avatarKey + "\n" + needsAck + "\n" + marker + "\n" + fileName); - var fallback = Build(false); - return new RecruitmentMessage("", marker, true, AcknowledgementRequired: needsAck, ReaderControls: true, - Format: RecruitmentMessageFormat.ComponentsV2, RenderIdentity: key, Components: media is null ? fallback : Build(true), - FallbackComponents: fallback, Media: media); + return PublicListingRenderer.Render(PublicListingView.From(state, post), render); } public static string Details(Listing post) { return "**Legend · Recruitment observations**\n" + string.Join("\n", post.Facts.Checks.Select(c => - $"{RecruitmentBanner.Icon(c)} **{c.Label}:** {Safe(c.Value, 60)}" + - (c.Id == "post-detail" || string.IsNullOrWhiteSpace(c.Evidence) ? "" : $" — {Safe(c.Evidence, 120)}"))) + - (post.Facts.Observations.Count == 0 ? "" : "\n\n**Additional evidence**\n" + string.Join("\n", post.Facts.Observations.Take(4).Select(o => $"• **{Safe(o.Description, 80)}:** “{Safe(o.Evidence, 120)}”."))) + + $"{RecruitmentBanner.Icon(c)} **{c.Label}:** {Literal(c.Value, 60)}" + + (c.Id == "post-detail" || string.IsNullOrWhiteSpace(c.Evidence) ? "" : $" — {Literal(c.Evidence, 120)}"))) + + (post.Facts.Observations.Count == 0 ? "" : "\n\n**Additional evidence**\n" + string.Join("\n", post.Facts.Observations.Take(4).Select(o => $"• **{Literal(o.Description, 80)}:** “{Literal(o.Evidence, 120)}”."))) + "\n\n🟢 Observed fact · 🔶 Clarification needed · 🟥 Missing detail or concern · ⚪ Unavailable or not applicable" + "\nAges are measured at posting. Job Duration looks for a work window or availability. Portfolio links are detected, not verified. Post Detail excludes links and formatting; length does not prove quality." + "\nThese observations do not establish trust or authorship; unavailable data is not evidence against the author. Use **Report concern** to contact moderators privately."; @@ -81,15 +29,15 @@ public static string Details(Listing post) public static RecruitmentMessage Staff(RecruitmentState state, Listing post, RecruitmentSettings settings) { - var text = $"**Recruitment · {post.Status}** · {Safe(post.Title, 100)}\n" + + var text = $"**Recruitment · {post.Status}** · {Literal(post.Title, 100)}\n" + $"<#{post.Id}> · author `{post.OwnerId}` · {(post.AcceptedAt.HasValue ? "acknowledged" : "unaccepted")}\n" + - $"{Safe(post.LastOutcome, 260)}\n"; - if (post.PendingAction is { } action) text += $"Pending {action.Kind}: {Safe(action.Reason)}\n"; - if (post.Hold is not null) text += $"Hold: {Safe(post.Hold)}\n"; + $"{Literal(post.LastOutcome, 260)}\n"; + if (post.PendingAction is { } action) text += $"Pending {action.Kind}: {Literal(action.Reason)}\n"; + if (post.Hold is not null) text += $"Hold: {Literal(post.Hold)}\n"; if (post.TimeoutAlertCount >= 3) text += $"Moderator attention: {post.TimeoutAlertCount} confirmed Guidelines timeouts.\n"; if (post.Reports.Any(r => r.ResolvedAt is null)) text += "Private reports awaiting review.\n"; if (post.MaterialChanges > post.ReviewedMaterialChanges) text += "Material starter changes awaiting review.\n"; - if (post.Audit.LastOrDefault() is { } audit) text += $"Latest action · `{audit.Actor}` · {Safe(audit.Reason)}\n"; + if (post.Audit.LastOrDefault() is { } audit) text += $"Latest action · `{audit.Actor}` · {Literal(audit.Reason)}\n"; text += $"Details: `/recruitment lookup {post.Id}`\n"; var marker = $"recruitment:{post.Id}:staff"; if (text.Length > 1900) text = text[..1880] + "\n… more in stored audit\n"; diff --git a/DiscordBot/Services/Recruitment/RecruitmentService.cs b/DiscordBot/Services/Recruitment/RecruitmentService.cs index 2520b1d9..eb5c78ed 100644 --- a/DiscordBot/Services/Recruitment/RecruitmentService.cs +++ b/DiscordBot/Services/Recruitment/RecruitmentService.cs @@ -1,3 +1,4 @@ +using DiscordBot.Utils; using System.Threading.Channels; using System.Collections.Concurrent; using Discord.WebSocket; @@ -315,11 +316,11 @@ public string QueueRecovery(string guild, string actor, string operation, string try { var result = await _engine.StaffAsync(guild, actor, operation, target, reason, ct); - _job = id + ": " + RecruitmentPresentation.Safe(result, 300); + _job = id + ": " + DiscordDisplayText.Literal(result, 300); } catch (Exception ex) { - _job = id + ": held — " + RecruitmentPresentation.Safe(ex.Message, 250); + _job = id + ": held — " + DiscordDisplayText.Literal(ex.Message, 250); throw; } }, WorkKind.Recovery); diff --git a/DiscordBot/Services/Recruitment/Runtime/RecruitmentHealth.cs b/DiscordBot/Services/Recruitment/Runtime/RecruitmentHealth.cs index 8c87eaf6..764265ff 100644 --- a/DiscordBot/Services/Recruitment/Runtime/RecruitmentHealth.cs +++ b/DiscordBot/Services/Recruitment/Runtime/RecruitmentHealth.cs @@ -1,3 +1,4 @@ +using DiscordBot.Utils; namespace DiscordBot.Services.Recruitment; public sealed record ForumHealth(string Purpose, string Id, string Setup, string? Fault, DateTimeOffset? RetryAt, string? ReferenceId = null, string? ReferenceFault = null); @@ -21,15 +22,15 @@ public Embed Embed(bool includeFreshness = true) public string Text() { var forums = string.Join("\n", Forums.Select(f => $"• <#{f.Id}> · {f.Purpose}: {f.Setup}" + - (f.Fault is null ? "" : $" — {RecruitmentPresentation.Safe(f.Fault, 250)}" + (f.RetryAt.HasValue ? $"; retry {RecruitmentPresentation.Timestamp(f.RetryAt.Value)}" : "; preview and repair required")) + + (f.Fault is null ? "" : $" — {DiscordDisplayText.Literal(f.Fault, 250)}" + (f.RetryAt.HasValue ? $"; retry {DiscordDisplayText.Timestamp(f.RetryAt.Value)}" : "; preview and repair required")) + (f.ReferenceId is null ? " · reference pending" : $" · reference <#{f.ReferenceId}>") + - (f.ReferenceFault is null ? "" : " · reference held: " + RecruitmentPresentation.Safe(f.ReferenceFault, 180)))); - return RecruitmentPresentation.Safe(Status, 400) + $"\nOpen: {Open} · awaiting acknowledgement: {Pending} · held: {Held}\n" + + (f.ReferenceFault is null ? "" : " · reference held: " + DiscordDisplayText.Literal(f.ReferenceFault, 180)))); + return DiscordDisplayText.Literal(Status, 400) + $"\nOpen: {Open} · awaiting acknowledgement: {Pending} · held: {Held}\n" + $"Inventory: {(InventoryComplete ? "complete" : "recovery needed")} · work: {Work}\n" + (Attention is null ? "" : "**Needs review**\n" + Attention + "\n") + (Metrics is null ? "" : Metrics + "\n") + - (LastRecovery.HasValue ? $"Last recovery: {RecruitmentPresentation.Timestamp(LastRecovery.Value)}\n" : "No completed recovery yet.\n") + - (NextDue.HasValue ? $"Next scheduled work: {RecruitmentPresentation.Timestamp(NextDue.Value)}\n" : "") + forums + + (LastRecovery.HasValue ? $"Last recovery: {DiscordDisplayText.Timestamp(LastRecovery.Value)}\n" : "No completed recovery yet.\n") + + (NextDue.HasValue ? $"Next scheduled work: {DiscordDisplayText.Timestamp(NextDue.Value)}\n" : "") + forums + (OverviewFault is null ? "" : "\nOverview: " + OverviewFault) + (Job is null ? "" : "\nJob: " + Job) + "\nUse /recruitment status, lookup, preview, repair or reconcile. Repairs and changes require a reason."; diff --git a/DiscordBot/Utils/DiscordDisplayText.cs b/DiscordBot/Utils/DiscordDisplayText.cs new file mode 100644 index 00000000..94b924b7 --- /dev/null +++ b/DiscordBot/Utils/DiscordDisplayText.cs @@ -0,0 +1,9 @@ +namespace DiscordBot.Utils; + +/// Literal display text for Discord; truncation precedes character replacement. +public static class DiscordDisplayText +{ + public static string Literal(string value, int max = 180) => new string(value.Take(max) + .Select(c => char.IsControl(c) || "`*_~|<>@[]\\".Contains(c) ? ' ' : c).ToArray()); + public static string Timestamp(DateTimeOffset date) => $""; +} diff --git a/DiscordBot/Utils/DomainName.cs b/DiscordBot/Utils/DomainName.cs new file mode 100644 index 00000000..c844a298 --- /dev/null +++ b/DiscordBot/Utils/DomainName.cs @@ -0,0 +1,7 @@ +namespace DiscordBot.Utils; + +public static class DomainName +{ + public static bool Matches(string host, string domain) => host.Equals(domain, StringComparison.OrdinalIgnoreCase) || + host.EndsWith("." + domain, StringComparison.OrdinalIgnoreCase); +} From 473beaf7909e87c40792318d74d757a4025419bd Mon Sep 17 00:00:00 2001 From: James Kellie Date: Mon, 19 Oct 2026 21:09:16 +1000 Subject: [PATCH 27/31] Share message recovery and scope Discord compatibility requests --- .../Discord/RecruitmentPinTests.cs | 1 + .../Discord/RecruitmentPublicationTests.cs | 96 +++++++++------ .../Discord/RecruitmentRequestScopeTests.cs | 62 ++++++++++ .../Discord/RecruitmentTransportTests.cs | 39 +++--- DiscordBot/Program.cs | 2 +- .../Application/RecruitmentAcknowledgement.cs | 3 +- .../Application/RecruitmentEngine.cs | 3 +- .../RecruitmentEngineScheduling.cs | 3 +- .../Application/RecruitmentOverview.cs | 3 +- .../Application/RecruitmentPins.cs | 5 +- .../Application/RecruitmentReferences.cs | 3 +- .../Application/RecruitmentReports.cs | 3 +- .../Application/RecruitmentSetup.cs | 3 +- .../Domain/Models/RecruitmentFault.cs | 22 ---- .../Discord/DiscordRecruitmentAdapter.cs | 116 +++++------------- .../Discord/DiscordRecruitmentPins.cs | 5 +- .../Discord/DiscordRecruitmentReferences.cs | 2 + .../Discord/OwnedMessageLookup.cs | 60 +++++++++ .../Discord/RecruitmentFaultClassifier.cs | 31 +++++ .../Discord/StandardMessageWriter.cs | 67 ++++++++++ ...rt.cs => DiscordCompatibilityTransport.cs} | 6 +- .../Transport/DiscordRetryDeadlines.cs | 66 ++++++++++ .../Discord/Transport/RecruitmentRequests.cs | 73 +++-------- ...ruitmentDelivery.cs => V2MessageWriter.cs} | 49 ++------ 24 files changed, 449 insertions(+), 274 deletions(-) create mode 100644 DiscordBot.Tests/Recruitment/Discord/RecruitmentRequestScopeTests.cs create mode 100644 DiscordBot/Services/Recruitment/Infrastructure/Discord/OwnedMessageLookup.cs create mode 100644 DiscordBot/Services/Recruitment/Infrastructure/Discord/RecruitmentFaultClassifier.cs create mode 100644 DiscordBot/Services/Recruitment/Infrastructure/Discord/StandardMessageWriter.cs rename DiscordBot/Services/Recruitment/Infrastructure/Discord/Transport/{RecruitmentForumTransport.cs => DiscordCompatibilityTransport.cs} (95%) create mode 100644 DiscordBot/Services/Recruitment/Infrastructure/Discord/Transport/DiscordRetryDeadlines.cs rename DiscordBot/Services/Recruitment/Infrastructure/Discord/{DiscordRecruitmentDelivery.cs => V2MessageWriter.cs} (66%) diff --git a/DiscordBot.Tests/Recruitment/Discord/RecruitmentPinTests.cs b/DiscordBot.Tests/Recruitment/Discord/RecruitmentPinTests.cs index 285555e8..d0f97be3 100644 --- a/DiscordBot.Tests/Recruitment/Discord/RecruitmentPinTests.cs +++ b/DiscordBot.Tests/Recruitment/Discord/RecruitmentPinTests.cs @@ -1,3 +1,4 @@ +using DiscordBot.Services.Recruitment.Infrastructure.Discord; using DiscordBot.Utils; using DiscordBot.Services.Recruitment; diff --git a/DiscordBot.Tests/Recruitment/Discord/RecruitmentPublicationTests.cs b/DiscordBot.Tests/Recruitment/Discord/RecruitmentPublicationTests.cs index d8382b90..fa90242b 100644 --- a/DiscordBot.Tests/Recruitment/Discord/RecruitmentPublicationTests.cs +++ b/DiscordBot.Tests/Recruitment/Discord/RecruitmentPublicationTests.cs @@ -1,3 +1,4 @@ +using DiscordBot.Services.Recruitment.Infrastructure.Discord; using System.Net; using System.Text; using Discord; @@ -13,6 +14,27 @@ namespace DiscordBot.Tests.Recruitment; [Collection("Recruitment transport")] public class RecruitmentPublicationTests { + [Theory] + [InlineData(RecruitmentMessageFormat.Standard, false)] + [InlineData(RecruitmentMessageFormat.Standard, true)] + [InlineData(RecruitmentMessageFormat.ComponentsV2, false)] + [InlineData(RecruitmentMessageFormat.ComponentsV2, true)] + public async Task SharedRecoveryDistinguishesCoveredAbsenceFromExhaustedHistory(RecruitmentMessageFormat format, bool incomplete) + { + var transport = new PublicationTransport { MessageMissing = true, LongHistory = incomplete }; + using var client = await Client(transport); + var channel = (IMessageChannel)(await client.Rest.GetChannelAsync(100))!; + transport.Message["author"]!["id"] = "200"; + transport.Message["timestamp"] = DateTimeOffset.UtcNow.ToString("O"); + var result = await OwnedMessageLookup.FindAsync(channel, 999, null, true, DateTimeOffset.UtcNow.AddDays(-1), + "recruitment:100:public", format, default); + Assert.Equal(incomplete ? MessageLookupStatus.Incomplete : MessageLookupStatus.Absent, result.Status); + Assert.Null(result.Message); + if (incomplete) Assert.Throws(() => result.RequireCovered()); + else Assert.Null(result.RequireCovered()); + Assert.Empty(transport.MessageWrites); + } + [Theory] [InlineData("50", false, true, 1)] [InlineData("51", false, true, 1)] @@ -177,7 +199,7 @@ public async Task V2InstalledSdkCreatesRetainsAndReplacesMediaWithoutAccumulatio var transport = new PublicationTransport { MessageMissing = true }; using var client = await Client(transport); var channel = (IMessageChannel)(await client.Rest.GetChannelAsync(100))!; var writes = 0; - var first = await DiscordRecruitmentAdapter.UpsertV2Async(channel, 999, null, false, DateTimeOffset.UtcNow, V2(), true, () => writes++, default); + var first = await V2MessageWriter.UpsertAsync(channel, 999, null, false, DateTimeOffset.UtcNow, V2(), true, () => writes++, default); Assert.True(first.NewlySent); Assert.Equal("901", first.MediaAttachmentId); Assert.Equal(1, transport.Uploads); var create = Assert.Single(transport.MessageWrites); @@ -185,15 +207,15 @@ public async Task V2InstalledSdkCreatesRetainsAndReplacesMediaWithoutAccumulatio Assert.Null(create["content"]); Assert.Null(create["embeds"]); Assert.True(create["allowed_mentions"]!["parse"] is null or { Type: JTokenType.Null } || !create["allowed_mentions"]!["parse"]!.Any()); - var same = await DiscordRecruitmentAdapter.UpsertV2Async(channel, 999, first.Id, false, DateTimeOffset.UtcNow, V2(), true, () => writes++, default); + var same = await V2MessageWriter.UpsertAsync(channel, 999, first.Id, false, DateTimeOffset.UtcNow, V2(), true, () => writes++, default); Assert.False(same.NewlySent); Assert.Equal(1, writes); Assert.Equal(1, transport.Uploads); - await DiscordRecruitmentAdapter.UpsertV2Async(channel, 999, first.Id, false, DateTimeOffset.UtcNow, V2(ack: false), true, () => writes++, default); + await V2MessageWriter.UpsertAsync(channel, 999, first.Id, false, DateTimeOffset.UtcNow, V2(ack: false), true, () => writes++, default); Assert.Equal(2, writes); Assert.Equal(1, transport.Uploads); var retain = transport.MessageWrites.Last(); Assert.Equal("901", (string?)retain["attachments"]![0]!["id"]); Assert.True(retain["allowed_mentions"]!["parse"] is null or { Type: JTokenType.Null } || !retain["allowed_mentions"]!["parse"]!.Any()); Assert.DoesNotContain("Acknowledge", retain["components"]!.ToString()); - await DiscordRecruitmentAdapter.UpsertV2Async(channel, 999, first.Id, false, DateTimeOffset.UtcNow, V2("Changed"), true, () => writes++, default); + await V2MessageWriter.UpsertAsync(channel, 999, first.Id, false, DateTimeOffset.UtcNow, V2("Changed"), true, () => writes++, default); Assert.Equal(2, transport.Uploads); Assert.Single((JArray)transport.Message["attachments"]!); Assert.Equal("902", (string?)transport.Message["attachments"]![0]!["id"]); } @@ -206,7 +228,7 @@ public async Task V2OptionalMediaUnavailableKeepsTextAndControls(bool mediaAvail { var transport = new PublicationTransport { MessageMissing = true }; using var client = await Client(transport); var channel = (IMessageChannel)(await client.Rest.GetChannelAsync(100))!; - await DiscordRecruitmentAdapter.UpsertV2Async(channel, 999, null, false, DateTimeOffset.UtcNow, V2(media: renderSucceeded), mediaAvailable, () => { }, default); + await V2MessageWriter.UpsertAsync(channel, 999, null, false, DateTimeOffset.UtcNow, V2(media: renderSucceeded), mediaAvailable, () => { }, default); Assert.Equal(0, transport.Uploads); var create = Assert.Single(transport.MessageWrites); Assert.Equal(32768, (int)create["flags"]!); @@ -220,15 +242,15 @@ public async Task V2RecoveryUsesExactOwnedMarkerAndHoldsOnIncompleteScan() var transport = new PublicationTransport { MessageMissing = true }; using var client = await Client(transport); var channel = (IMessageChannel)(await client.Rest.GetChannelAsync(100))!; transport.LoseCreateResponse = true; - await Assert.ThrowsAsync(() => DiscordRecruitmentAdapter.UpsertV2Async(channel, 999, null, false, DateTimeOffset.UtcNow, V2(media: false), true, () => { }, default)); + await Assert.ThrowsAsync(() => V2MessageWriter.UpsertAsync(channel, 999, null, false, DateTimeOffset.UtcNow, V2(media: false), true, () => { }, default)); // Restart/lost create response: no stored ID, recover from component marker. - var recovered = await DiscordRecruitmentAdapter.UpsertV2Async(channel, 999, null, true, DateTimeOffset.UtcNow, V2(media: false), true, () => { }, default); + var recovered = await V2MessageWriter.UpsertAsync(channel, 999, null, true, DateTimeOffset.UtcNow, V2(media: false), true, () => { }, default); Assert.Equal("101", recovered.Id); Assert.False(recovered.NewlySent); Assert.Single(transport.MessageWrites); transport.Message["author"]!["id"] = "200"; - await Assert.ThrowsAsync(() => DiscordRecruitmentAdapter.UpsertV2Async(channel, 999, "101", false, + await Assert.ThrowsAsync(() => V2MessageWriter.UpsertAsync(channel, 999, "101", false, DateTimeOffset.UtcNow, V2(media: false), true, () => { }, default)); transport.LongHistory = true; transport.Message["timestamp"] = DateTimeOffset.UtcNow.ToString("O"); - await Assert.ThrowsAsync(() => DiscordRecruitmentAdapter.UpsertV2Async(channel, 999, null, true, + await Assert.ThrowsAsync(() => V2MessageWriter.UpsertAsync(channel, 999, null, true, DateTimeOffset.UtcNow.AddDays(-1), V2(media: false), true, () => { }, default)); Assert.Single(transport.MessageWrites); } @@ -238,14 +260,14 @@ public async Task V2LostEditResponseRestoresArchiveAndRetainsOneUploadAfterResta { var transport = new PublicationTransport { MessageMissing = true }; using var client = await Client(transport); var channel = (IMessageChannel)(await client.Rest.GetChannelAsync(100))!; - await DiscordRecruitmentAdapter.UpsertV2Async(channel, 999, null, false, DateTimeOffset.UtcNow, V2(), true, () => { }, default); + await V2MessageWriter.UpsertAsync(channel, 999, null, false, DateTimeOffset.UtcNow, V2(), true, () => { }, default); transport.Thread["thread_metadata"]!["archived"] = true; transport.Thread["thread_metadata"]!["locked"] = true; channel = (IMessageChannel)(await client.Rest.GetChannelAsync(100))!; transport.LoseEditResponse = true; - await Assert.ThrowsAsync(() => DiscordRecruitmentAdapter.UpsertV2Async(channel, 999, "101", false, + await Assert.ThrowsAsync(() => V2MessageWriter.UpsertAsync(channel, 999, "101", false, DateTimeOffset.UtcNow, V2("Changed") with { RestoreArchive = true, RestoreLocked = true }, true, () => { }, default)); channel = (IMessageChannel)(await client.Rest.GetChannelAsync(100))!; - var recovered = await DiscordRecruitmentAdapter.UpsertV2Async(channel, 999, "101", true, DateTimeOffset.UtcNow, + var recovered = await V2MessageWriter.UpsertAsync(channel, 999, "101", true, DateTimeOffset.UtcNow, V2("Changed") with { RestoreArchive = true, RestoreLocked = true }, true, () => { }, default); Assert.False(recovered.NewlySent); Assert.Equal(2, transport.Uploads); Assert.Equal(2, transport.MessageWrites.Count); Assert.True((bool)transport.Thread["thread_metadata"]!["archived"]!); Assert.True((bool)transport.Thread["thread_metadata"]!["locked"]!); @@ -256,11 +278,11 @@ public async Task V2RejectedMediaWriteFallsBackAndRestoresArchivedLock() { var transport = new PublicationTransport { MessageMissing = true }; using var client = await Client(transport); var channel = (IMessageChannel)(await client.Rest.GetChannelAsync(100))!; - await DiscordRecruitmentAdapter.UpsertV2Async(channel, 999, null, false, DateTimeOffset.UtcNow, V2(), true, () => { }, default); + await V2MessageWriter.UpsertAsync(channel, 999, null, false, DateTimeOffset.UtcNow, V2(), true, () => { }, default); transport.Thread["thread_metadata"]!["archived"] = true; transport.Thread["thread_metadata"]!["locked"] = true; transport.RejectUpload = true; channel = (IMessageChannel)(await client.Rest.GetChannelAsync(100))!; - var delivery = await DiscordRecruitmentAdapter.UpsertV2Async(channel, 999, "101", false, DateTimeOffset.UtcNow, + var delivery = await V2MessageWriter.UpsertAsync(channel, 999, "101", false, DateTimeOffset.UtcNow, V2("Changed") with { RestoreArchive = true, RestoreLocked = true }, true, () => { }, default); Assert.Null(delivery.MediaAttachmentId); Assert.False(delivery.NewlySent); Assert.True((bool)transport.Thread["thread_metadata"]!["archived"]!); Assert.True((bool)transport.Thread["thread_metadata"]!["locked"]!); @@ -274,14 +296,14 @@ public async Task V2OwnershipRequiresExactRecursiveMarkerBotChannelAndFlag() var transport = new PublicationTransport { MessageMissing = true }; using var client = await Client(transport); var adapter = new DiscordRecruitmentAdapter(client, Settings()); var channel = (IMessageChannel)(await client.Rest.GetChannelAsync(100))!; - await DiscordRecruitmentAdapter.UpsertV2Async(channel, 999, null, false, DateTimeOffset.UtcNow, V2(media: false), true, () => { }, default); + await V2MessageWriter.UpsertAsync(channel, 999, null, false, DateTimeOffset.UtcNow, V2(media: false), true, () => { }, default); var owned = (IUserMessage)(await channel.GetMessageAsync(101))!; - Assert.True(DiscordRecruitmentAdapter.OwnsMessage(owned, 999, 100, "recruitment:100:public", RecruitmentMessageFormat.ComponentsV2)); - Assert.False(DiscordRecruitmentAdapter.OwnsMessage(owned, 200, 100, "recruitment:100:public", RecruitmentMessageFormat.ComponentsV2)); - Assert.False(DiscordRecruitmentAdapter.OwnsMessage(owned, 999, 200, "recruitment:100:public", RecruitmentMessageFormat.ComponentsV2)); - Assert.False(DiscordRecruitmentAdapter.OwnsMessage(owned, 999, 100, "recruitment:100:pub", RecruitmentMessageFormat.ComponentsV2)); + Assert.True(OwnedMessageLookup.OwnsMessage(owned, 999, 100, "recruitment:100:public", RecruitmentMessageFormat.ComponentsV2)); + Assert.False(OwnedMessageLookup.OwnsMessage(owned, 200, 100, "recruitment:100:public", RecruitmentMessageFormat.ComponentsV2)); + Assert.False(OwnedMessageLookup.OwnsMessage(owned, 999, 200, "recruitment:100:public", RecruitmentMessageFormat.ComponentsV2)); + Assert.False(OwnedMessageLookup.OwnsMessage(owned, 999, 100, "recruitment:100:pub", RecruitmentMessageFormat.ComponentsV2)); transport.Message["flags"] = 0; - Assert.False(DiscordRecruitmentAdapter.OwnsMessage((IUserMessage)(await channel.GetMessageAsync(101))!, 999, 100, "recruitment:100:public", RecruitmentMessageFormat.ComponentsV2)); + Assert.False(OwnedMessageLookup.OwnsMessage((IUserMessage)(await channel.GetMessageAsync(101))!, 999, 100, "recruitment:100:public", RecruitmentMessageFormat.ComponentsV2)); transport.Message["flags"] = 32768; await adapter.DeleteAdvisoryAsync("100", "101", default, "recruitment:100:public"); Assert.Equal(1, transport.Deletes); @@ -293,7 +315,7 @@ public async Task V2CancellationDoesNotWriteAndUnscopedSdkMessagesStayUntouched( var transport = new PublicationTransport { MessageMissing = true }; using var client = await Client(transport); var channel = (IMessageChannel)(await client.Rest.GetChannelAsync(100))!; using var cancellation = new CancellationTokenSource(); cancellation.Cancel(); - await Assert.ThrowsAnyAsync(() => DiscordRecruitmentAdapter.UpsertV2Async(channel, 999, null, false, + await Assert.ThrowsAnyAsync(() => V2MessageWriter.UpsertAsync(channel, 999, null, false, DateTimeOffset.UtcNow, V2(), true, () => { }, cancellation.Token)); Assert.Empty(transport.MessageWrites); await channel.SendMessageAsync("Ordinary content"); @@ -308,13 +330,13 @@ public async Task V2RecoversComponentOnlyAttachmentIdentityThroughInstalledSdk(b var transport = new PublicationTransport { MessageMissing = true, ComponentOnlyMedia = true, CdnMediaUrl = cdnUrl }; using var client = await Client(transport); var channel = (IMessageChannel)(await client.Rest.GetChannelAsync(100))!; - var delivered = await DiscordRecruitmentAdapter.UpsertV2Async(channel, 999, null, false, DateTimeOffset.UtcNow, V2(), true, () => { }, default); + var delivered = await V2MessageWriter.UpsertAsync(channel, 999, null, false, DateTimeOffset.UtcNow, V2(), true, () => { }, default); Assert.Equal("901", delivered.MediaAttachmentId); using var restarted = await Client(transport); channel = (IMessageChannel)(await restarted.Rest.GetChannelAsync(100))!; - var recovered = await DiscordRecruitmentAdapter.UpsertV2Async(channel, 999, null, true, DateTimeOffset.UtcNow, V2(), true, () => { }, default); + var recovered = await V2MessageWriter.UpsertAsync(channel, 999, null, true, DateTimeOffset.UtcNow, V2(), true, () => { }, default); Assert.Equal("901", recovered.MediaAttachmentId); Assert.Single(transport.MessageWrites); Assert.Equal(1, transport.Uploads); - await DiscordRecruitmentAdapter.UpsertV2Async(channel, 999, recovered.Id, false, DateTimeOffset.UtcNow, V2(ack: false), true, () => { }, default); + await V2MessageWriter.UpsertAsync(channel, 999, recovered.Id, false, DateTimeOffset.UtcNow, V2(ack: false), true, () => { }, default); Assert.Equal(1, transport.Uploads); Assert.Equal("901", (string?)transport.MessageWrites.Last()["attachments"]![0]!["id"]); } @@ -326,7 +348,7 @@ public async Task V2DeniedMediaPayloadOmitsAvatarThumbnailToo() var message = RecruitmentPresentation.PublicV2(rig.State, post, rig.Settings.Recruitment, rig.Clock.Now); var transport = new PublicationTransport { MessageMissing = true }; using var client = await Client(transport); var channel = (IMessageChannel)(await client.Rest.GetChannelAsync(100))!; - await DiscordRecruitmentAdapter.UpsertV2Async(channel, 999, null, false, DateTimeOffset.UtcNow, message, false, () => { }, default); + await V2MessageWriter.UpsertAsync(channel, 999, null, false, DateTimeOffset.UtcNow, message, false, () => { }, default); var payload = Assert.Single(transport.MessageWrites); Assert.DoesNotContain("cdn.discordapp.com", payload.ToString()); Assert.DoesNotContain("attachment://", payload.ToString()); Assert.DoesNotContain(payload["components"]!.SelectTokens("$..type"), t => (int)t is 11 or 12); @@ -341,10 +363,10 @@ public async Task V2AuthorNameOnlyEditRetainsUploadedImageAndCannotPing() var message = RecruitmentPresentation.PublicV2(rig.State, post, rig.Settings.Recruitment, rig.Clock.Now); var transport = new PublicationTransport { MessageMissing = true }; using var client = await Client(transport); var channel = (IMessageChannel)(await client.Rest.GetChannelAsync(100))!; - var sent = await DiscordRecruitmentAdapter.UpsertV2Async(channel, 999, null, false, DateTimeOffset.UtcNow, message, true, () => { }, default); + var sent = await V2MessageWriter.UpsertAsync(channel, 999, null, false, DateTimeOffset.UtcNow, message, true, () => { }, default); post.Facts.ServerNickname = "**<@123> New name**"; var renamed = RecruitmentPresentation.PublicV2(rig.State, post, rig.Settings.Recruitment, rig.Clock.Now); - await DiscordRecruitmentAdapter.UpsertV2Async(channel, 999, sent.Id, false, DateTimeOffset.UtcNow, renamed, true, () => { }, default); + await V2MessageWriter.UpsertAsync(channel, 999, sent.Id, false, DateTimeOffset.UtcNow, renamed, true, () => { }, default); Assert.Equal(1, transport.Uploads); Assert.Equal(2, transport.MessageWrites.Count); Assert.DoesNotContain("<@123>", transport.MessageWrites.Last().ToString()); Assert.Contains("New name", transport.MessageWrites.Last().ToString()); Assert.Equal("901", (string?)transport.MessageWrites.Last()["attachments"]![0]!["id"]); @@ -355,11 +377,11 @@ public async Task V2RepairsInterruptedLockRestorationWithoutMessageRewrite() { var transport = new PublicationTransport { MessageMissing = true }; using var client = await Client(transport); var channel = (IMessageChannel)(await client.Rest.GetChannelAsync(100))!; - await DiscordRecruitmentAdapter.UpsertV2Async(channel, 999, null, false, DateTimeOffset.UtcNow, V2(media: false), true, () => { }, default); + await V2MessageWriter.UpsertAsync(channel, 999, null, false, DateTimeOffset.UtcNow, V2(media: false), true, () => { }, default); transport.Thread["thread_metadata"]!["archived"] = true; transport.Thread["thread_metadata"]!["locked"] = false; channel = (IMessageChannel)(await client.Rest.GetChannelAsync(100))!; var saves = 0; - await DiscordRecruitmentAdapter.UpsertV2Async(channel, 999, "101", true, DateTimeOffset.UtcNow, + await V2MessageWriter.UpsertAsync(channel, 999, "101", true, DateTimeOffset.UtcNow, V2(media: false) with { RestoreArchive = true, RestoreLocked = true }, true, () => saves++, default); Assert.Equal(1, saves); Assert.Single(transport.MessageWrites); Assert.True((bool)transport.Thread["thread_metadata"]!["locked"]!); } @@ -369,7 +391,7 @@ public async Task V2ConfirmedMissingMessageIsRecreatedWithUsableControls() { var transport = new PublicationTransport { MessageMissing = true }; using var client = await Client(transport); var channel = (IMessageChannel)(await client.Rest.GetChannelAsync(100))!; - var sent = await DiscordRecruitmentAdapter.UpsertV2Async(channel, 999, "101", false, DateTimeOffset.UtcNow, V2(media: false), true, () => { }, default); + var sent = await V2MessageWriter.UpsertAsync(channel, 999, "101", false, DateTimeOffset.UtcNow, V2(media: false), true, () => { }, default); Assert.True(sent.NewlySent); Assert.Single(transport.MessageWrites); Assert.Contains("Acknowledge", transport.Message.ToString()); } @@ -378,7 +400,7 @@ public async Task InstalledSdkPinCorrectionIsScopedToExactMessageAndLeavesOrdina { var transport = new PublicationTransport { MessageMissing = true }; using var client = await Client(transport); var channel = (IThreadChannel)(await client.Rest.GetChannelAsync(100))!; - await DiscordRecruitmentAdapter.UpsertV2Async(channel, 999, "101", false, DateTimeOffset.UtcNow, V2(media: false), true, () => { }, default); + await V2MessageWriter.UpsertAsync(channel, 999, "101", false, DateTimeOffset.UtcNow, V2(media: false), true, () => { }, default); Assert.True(await DiscordRecruitmentAdapter.PinOwnedAsync(channel, 999, 101, true, false, false, (_, _) => { }, default)); Assert.Equal(new[] { "channels/100/messages/pins/101" }, transport.PinRoutes); var message = (IUserMessage)(await channel.GetMessageAsync(101))!; @@ -401,7 +423,7 @@ public async Task OptionalPinWithoutPermissionDoesNotWrite(bool? allowed) { var transport = new PublicationTransport { MessageMissing = true }; using var client = await Client(transport); var channel = (IThreadChannel)(await client.Rest.GetChannelAsync(100))!; - await DiscordRecruitmentAdapter.UpsertV2Async(channel, 999, "101", false, DateTimeOffset.UtcNow, V2(media: false), true, () => { }, default); + await V2MessageWriter.UpsertAsync(channel, 999, "101", false, DateTimeOffset.UtcNow, V2(media: false), true, () => { }, default); await Assert.ThrowsAsync(() => DiscordRecruitmentAdapter.PinOwnedAsync(channel, 999, 101, allowed, false, false, (_, _) => { }, default)); Assert.Empty(transport.PinRoutes); Assert.Empty(transport.ChannelWrites); } @@ -411,7 +433,7 @@ public async Task LostPinResponseRestoresArchivedClosedThreadWhenMessageAlreadyP { var transport = new PublicationTransport { MessageMissing = true }; using var client = await Client(transport); var channel = (IThreadChannel)(await client.Rest.GetChannelAsync(100))!; - await DiscordRecruitmentAdapter.UpsertV2Async(channel, 999, "101", false, DateTimeOffset.UtcNow, V2(media: false), true, () => { }, default); + await V2MessageWriter.UpsertAsync(channel, 999, "101", false, DateTimeOffset.UtcNow, V2(media: false), true, () => { }, default); transport.Thread["thread_metadata"]!["archived"] = true; transport.Thread["thread_metadata"]!["locked"] = true; channel = (IThreadChannel)(await client.Rest.GetChannelAsync(100))!; transport.LosePinResponse = true; transport.FailRestoreOnce = true; @@ -437,7 +459,7 @@ public async Task InstalledSdkArchivedPinFailureRestoresThreadAndKeepsFailureCos { var transport = new PublicationTransport { MessageMissing = true }; using var client = await Client(transport); var channel = (IThreadChannel)(await client.Rest.GetChannelAsync(100))!; - await DiscordRecruitmentAdapter.UpsertV2Async(channel, 999, "101", false, DateTimeOffset.UtcNow, V2(media: false), true, () => { }, default); + await V2MessageWriter.UpsertAsync(channel, 999, "101", false, DateTimeOffset.UtcNow, V2(media: false), true, () => { }, default); transport.Thread["thread_metadata"]!["archived"] = true; transport.Thread["thread_metadata"]!["locked"] = true; channel = (IThreadChannel)(await client.Rest.GetChannelAsync(100))!; transport.PinStatus = status; @@ -461,7 +483,7 @@ await Assert.ThrowsAsync(() => DiscordRecruitmentAdap Assert.True((bool)transport.Thread["thread_metadata"]!["archived"]!); Assert.True((bool)transport.Thread["thread_metadata"]!["locked"]!); Assert.False(receipt.RestoreArchive); Assert.Single(transport.PinRoutes); - var fault = RecruitmentFault.From(error!, DateTimeOffset.UtcNow); + var fault = RecruitmentFaultClassifier.From(error!, DateTimeOffset.UtcNow); Assert.Equal(status == 429, fault.RetryAt.HasValue); Assert.Equal(0, transport.Deletes); } @@ -471,7 +493,7 @@ public async Task PinRefusesCopiedMarkerOrWrongOwner() { var transport = new PublicationTransport { MessageMissing = true }; using var client = await Client(transport); var channel = (IThreadChannel)(await client.Rest.GetChannelAsync(100))!; - await DiscordRecruitmentAdapter.UpsertV2Async(channel, 999, "101", false, DateTimeOffset.UtcNow, V2(media: false), true, () => { }, default); + await V2MessageWriter.UpsertAsync(channel, 999, "101", false, DateTimeOffset.UtcNow, V2(media: false), true, () => { }, default); transport.Message["author"]!["id"] = "200"; await Assert.ThrowsAsync(() => DiscordRecruitmentAdapter.PinOwnedAsync(channel, 999, 101, true, false, false, (_, _) => { }, default)); Assert.Empty(transport.PinRoutes); @@ -483,7 +505,7 @@ public async Task PinRefusesCopiedMarkerOrWrongOwner() private static async Task Client(PublicationTransport transport) { - var client = new DiscordSocketClient(new DiscordSocketConfig { RestClientProvider = RecruitmentForumTransport.Wrap(_ => transport) }); + var client = new DiscordSocketClient(new DiscordSocketConfig { RestClientProvider = DiscordCompatibilityTransport.Wrap(_ => transport) }); await client.LoginAsync(TokenType.Bot, "offline-placeholder-token", validateToken: false); // READY normally seeds the socket REST wrapper's identity. Seed only that SDK cache from an // offline REST login so these payload tests never open a gateway or replace production I/O. diff --git a/DiscordBot.Tests/Recruitment/Discord/RecruitmentRequestScopeTests.cs b/DiscordBot.Tests/Recruitment/Discord/RecruitmentRequestScopeTests.cs new file mode 100644 index 00000000..99a571e4 --- /dev/null +++ b/DiscordBot.Tests/Recruitment/Discord/RecruitmentRequestScopeTests.cs @@ -0,0 +1,62 @@ +using System.Net; +using System.Text; +using Discord.Net.Rest; +using DiscordBot.Services.Recruitment; +using DiscordBot.Services.Recruitment.Infrastructure.Discord; + +namespace DiscordBot.Tests.Recruitment; + +[Collection("Recruitment transport")] +public class RecruitmentRequestScopeTests +{ + [Fact] + public async Task NestedScopeRestoresAfterExceptionAndOrdinaryTrafficKeepsItsBehavior() + { + Assert.False(RecruitmentRequests.IsScoped); + using (RecruitmentRequests.BeginScope(new DiscordRetryDeadlines())) + { + Assert.True(RecruitmentRequests.IsScoped); + await Assert.ThrowsAsync(async () => + { + using var nested = RecruitmentRequests.BeginScope(); + await Task.Yield(); + await RecruitmentRequests.SendAsync("GET", "channels/300", default, () => Task.FromResult(Limited())); + }); + Assert.True(RecruitmentRequests.IsScoped); + } + Assert.False(RecruitmentRequests.IsScoped); + var ordinary = await RecruitmentRequests.SendAsync("GET", "channels/301", default, () => Task.FromResult(Limited())); + Assert.Equal(HttpStatusCode.TooManyRequests, ordinary.StatusCode); + ordinary.Stream.Dispose(); + } + + [Fact] + public async Task GlobalDeadlineIsSharedAcrossRoutesAndOrdinaryWaitUsesInjectedTime() + { + var clock = new ManualClock(); + var waits = new List(); + var deadlines = new DiscordRetryDeadlines(clock, (delay, _) => { waits.Add(delay); clock.Advance(delay); return Task.CompletedTask; }); + var start = clock.GetUtcNow(); + var error = await Assert.ThrowsAsync(() => deadlines.SendAsync("GET", "channels/1", default, + () => Task.FromResult(Limited(global: true)), recruitment: true)); + Assert.Equal(start.AddSeconds(6), error.RetryAt); + var sent = false; + await Assert.ThrowsAsync(() => deadlines.SendAsync("GET", "guilds/9", default, + () => { sent = true; return Task.FromResult(Limited()); }, recruitment: true)); + Assert.False(sent); + var response = await deadlines.SendAsync("GET", "guilds/9", default, + () => Task.FromResult(new RestResponse(HttpStatusCode.OK, new Dictionary(), new MemoryStream())), recruitment: false); + Assert.Equal(TimeSpan.FromSeconds(6), Assert.Single(waits)); + response.Stream.Dispose(); + } + + [Theory] + [InlineData(1, 1, 16)] + [InlineData(4, 3, 123)] + [InlineData(10, 5, 905)] + public void BackoffIsCappedAndHasExplicitJitter(int attempts, int jitter, double seconds) => + Assert.Equal(seconds, RecruitmentBackoff.Seconds(attempts, jitter)); + + private static RestResponse Limited(bool global = false) => new(HttpStatusCode.TooManyRequests, new Dictionary(), + new MemoryStream(Encoding.UTF8.GetBytes($"{{\"retry_after\":5,\"global\":{global.ToString().ToLowerInvariant()}}}"))); +} diff --git a/DiscordBot.Tests/Recruitment/Discord/RecruitmentTransportTests.cs b/DiscordBot.Tests/Recruitment/Discord/RecruitmentTransportTests.cs index 486d67a8..9f6ad67d 100644 --- a/DiscordBot.Tests/Recruitment/Discord/RecruitmentTransportTests.cs +++ b/DiscordBot.Tests/Recruitment/Discord/RecruitmentTransportTests.cs @@ -1,3 +1,4 @@ +using DiscordBot.Services.Recruitment.Infrastructure.Discord; using System.Net; using System.Text; using Discord; @@ -18,7 +19,7 @@ public async Task InstalledSdkPreservesModerationEmojisAndFlagsThroughStagedPubl { var transport = new ForumTransport(); using var client = new DiscordRestClient(new DiscordRestConfig - { RestClientProvider = RecruitmentForumTransport.Wrap(_ => transport) }); + { RestClientProvider = DiscordCompatibilityTransport.Wrap(_ => transport) }); await client.LoginAsync(TokenType.Bot, "offline-placeholder-token", validateToken: false); var forum = Assert.IsType(await client.GetChannelAsync(11)); var tags = new[] { new LiveTag("51", "Staff", true, new Emoji("⭐")), new LiveTag("", "Programming", false) }; @@ -44,9 +45,9 @@ public async Task InstalledSdkPreservesModerationEmojisAndFlagsThroughStagedPubl public async Task TagPublicationWithoutScopeFailsBeforeTransport() { var inner = new ForumTransport(); - using var transport = new RecruitmentForumTransport(inner); + using var transport = new DiscordCompatibilityTransport(inner); await Assert.ThrowsAsync(() => transport.SendAsync("PATCH", "channels/11", "{}", - default, false, RecruitmentForumTransport.TagWriteReason, Array.Empty>>())); + default, false, DiscordCompatibilityTransport.TagWriteReason, Array.Empty>>())); Assert.Empty(inner.Writes); } @@ -55,7 +56,7 @@ public async Task InventoryReusesForumsAndFetchesGuildActiveThreadsOnce() { var transport = new ForumTransport { InventoryMode = true }; using var client = new DiscordSocketClient(new DiscordSocketConfig - { RestClientProvider = RecruitmentForumTransport.Wrap(_ => transport) }); + { RestClientProvider = DiscordCompatibilityTransport.Wrap(_ => transport) }); await client.LoginAsync(TokenType.Bot, "offline-placeholder-token", validateToken: false); var settings = new BotSettings { GuildId = 1, Recruitment = new RecruitmentSettings { Forums = new RecruitmentForums { PaidRecruitingId = 11, PaidForHireId = 12, HobbyRecruitingId = 13, HobbyForHireId = 14 } } }; @@ -81,10 +82,10 @@ public async Task InventoryReusesForumsAndFetchesGuildActiveThreadsOnce() public async Task InventoryObservationRetainsIdentitiesDiscardedBySdkParentFilter() { var transport = new ForumTransport { InventoryMode = true, IncludeOtherForumThread = true }; - using var client = new DiscordRestClient(new DiscordRestConfig { RestClientProvider = RecruitmentForumTransport.Wrap(_ => transport) }); + using var client = new DiscordRestClient(new DiscordRestConfig { RestClientProvider = DiscordCompatibilityTransport.Wrap(_ => transport) }); await client.LoginAsync(TokenType.Bot, "offline-placeholder-token", validateToken: false); var forum = Assert.IsType(await client.GetChannelAsync(11)); - using var observed = RecruitmentForumTransport.ObserveActiveInventory(1); + using var observed = DiscordCompatibilityTransport.ObserveActiveInventory(1); Assert.Empty(await forum.GetActiveThreadsAsync()); Assert.Equal((100UL, 12UL), Assert.Single(observed.Identities!)); Assert.Single(transport.Requests, r => r.Contains("/threads/active")); @@ -95,9 +96,10 @@ public async Task InventoryObservationRetainsIdentitiesDiscardedBySdkParentFilte [InlineData(537)] public async Task RateLimitReturnsDeadlineWithoutBlockingWorkerOrResending(int seconds) { + using var requestScope = RecruitmentRequests.BeginScope(new DiscordRetryDeadlines()); var transport = new ForumTransport { RetrySeconds = seconds }; using var client = new DiscordRestClient(new DiscordRestConfig - { RestClientProvider = RecruitmentForumTransport.Wrap(_ => transport) }); + { RestClientProvider = DiscordCompatibilityTransport.Wrap(_ => transport) }); await client.LoginAsync(TokenType.Bot, "offline-placeholder-token", validateToken: false); var started = DateTimeOffset.UtcNow; var error = await Assert.ThrowsAsync(async () => @@ -105,8 +107,8 @@ public async Task RateLimitReturnsDeadlineWithoutBlockingWorkerOrResending(int s Assert.True(error.RetryAt >= started.AddSeconds(seconds)); await Assert.ThrowsAsync(async () => await client.GetChannelAsync((ulong)seconds, RecruitmentRequests.Options(default)).WaitAsync(TimeSpan.FromSeconds(2))); - using var sameChannel = new RecruitmentForumTransport(transport); - _ = RecruitmentRequests.Options(default); // Mark this direct transport call as recruitment work too. + using var sameChannel = new DiscordCompatibilityTransport(transport); + _ = RecruitmentRequests.Options(default); await Assert.ThrowsAsync(() => sameChannel.SendAsync("GET", $"channels/{seconds}/messages?limit=100", default, false, "", Array.Empty>>()).WaitAsync(TimeSpan.FromSeconds(2))); Assert.Equal(1, transport.RateLimitRequests); @@ -115,24 +117,15 @@ await Assert.ThrowsAsync(() => sameChannel.SendAsy [Fact] public async Task GlobalRateLimitDefersOtherRecruitmentRoutesBeforeSdkWaiting() { + using var requestScope = RecruitmentRequests.BeginScope(new DiscordRetryDeadlines()); var transport = new ForumTransport { RetrySeconds = 1, GlobalLimit = true }; - using var client = new DiscordRestClient(new DiscordRestConfig { RestClientProvider = RecruitmentForumTransport.Wrap(_ => transport) }); + using var client = new DiscordRestClient(new DiscordRestConfig { RestClientProvider = DiscordCompatibilityTransport.Wrap(_ => transport) }); await client.LoginAsync(TokenType.Bot, "offline-placeholder-token", validateToken: false); var error = await Assert.ThrowsAsync(async () => await client.GetChannelAsync(1, RecruitmentRequests.Options(default)).WaitAsync(TimeSpan.FromSeconds(2))); - try - { - var other = Assert.Throws(() => RecruitmentRequests.Options(default)); - Assert.Equal(error.RetryAt, other.RetryAt); - Assert.Equal(1, transport.RateLimitRequests); - } - finally - { - // This process-wide limiter is shared by all SDK instances, as it is by production callers. - // Let this short synthetic global window expire before another test uses the transport. - var remaining = error.RetryAt - DateTimeOffset.UtcNow + TimeSpan.FromMilliseconds(20); - if (remaining > TimeSpan.Zero) await Task.Delay(remaining); - } + var other = Assert.Throws(() => RecruitmentRequests.Options(default)); + Assert.Equal(error.RetryAt, other.RetryAt); + Assert.Equal(1, transport.RateLimitRequests); } // Uses the real installed SDK's request queue and serialization, but never a network transport. diff --git a/DiscordBot/Program.cs b/DiscordBot/Program.cs index 5dcd6d9c..6544eddd 100644 --- a/DiscordBot/Program.cs +++ b/DiscordBot/Program.cs @@ -39,7 +39,7 @@ private async Task MainAsync() _client = new DiscordSocketClient(new DiscordSocketConfig { - RestClientProvider = RecruitmentForumTransport.Wrap(Discord.Net.Rest.DefaultRestClientProvider.Create()), + RestClientProvider = DiscordCompatibilityTransport.Wrap(Discord.Net.Rest.DefaultRestClientProvider.Create()), LogLevel = LogSeverity.Verbose, AlwaysDownloadUsers = true, MessageCacheSize = 1024, diff --git a/DiscordBot/Services/Recruitment/Application/RecruitmentAcknowledgement.cs b/DiscordBot/Services/Recruitment/Application/RecruitmentAcknowledgement.cs index d52df190..49bed15b 100644 --- a/DiscordBot/Services/Recruitment/Application/RecruitmentAcknowledgement.cs +++ b/DiscordBot/Services/Recruitment/Application/RecruitmentAcknowledgement.cs @@ -1,3 +1,4 @@ +using DiscordBot.Services.Recruitment.Infrastructure.Discord; using System.IO; namespace DiscordBot.Services.Recruitment; @@ -35,7 +36,7 @@ private async Task PublishAcknowledgementNoticeAsync(Listing post, CancellationT { // The public controls remain usable; an optional notification cannot reset the deadline // or prevent a timely acknowledgement. Retry/recover independently without a second ping. - notice.Fault = RecruitmentFault.From(ex, Now, notice.Fault); + notice.Fault = RecruitmentFaultClassifier.From(ex, Now, notice.Fault); Save(); } } diff --git a/DiscordBot/Services/Recruitment/Application/RecruitmentEngine.cs b/DiscordBot/Services/Recruitment/Application/RecruitmentEngine.cs index b66c73e4..4b35bd9b 100644 --- a/DiscordBot/Services/Recruitment/Application/RecruitmentEngine.cs +++ b/DiscordBot/Services/Recruitment/Application/RecruitmentEngine.cs @@ -1,3 +1,4 @@ +using DiscordBot.Services.Recruitment.Infrastructure.Discord; using DiscordBot.Utils; using DiscordBot.Services.Recruitment.Domain.Policies; using System.IO; @@ -133,7 +134,7 @@ public async Task SweepAsync(CancellationToken ct, bool retryHeld = true) try { MarkEvidenceGap(invalidateAccess: false); } catch (IOException) { return; } if (State is not null) { - State.RecoveryFault = RecruitmentFault.From(ex, Now, previousFault); + State.RecoveryFault = RecruitmentFaultClassifier.From(ex, Now, previousFault); try { Save(); } catch { return; } } Status = $"Recruitment held: {ex.GetType().Name}: {ex.Message}"; diff --git a/DiscordBot/Services/Recruitment/Application/RecruitmentEngineScheduling.cs b/DiscordBot/Services/Recruitment/Application/RecruitmentEngineScheduling.cs index e6d8e204..159a2ed4 100644 --- a/DiscordBot/Services/Recruitment/Application/RecruitmentEngineScheduling.cs +++ b/DiscordBot/Services/Recruitment/Application/RecruitmentEngineScheduling.cs @@ -1,3 +1,4 @@ +using DiscordBot.Services.Recruitment.Infrastructure.Discord; using DiscordBot.Services.Recruitment.Domain.Policies; namespace DiscordBot.Services.Recruitment; @@ -188,7 +189,7 @@ private async Task ProcessSafelyAsync(Listing post, CancellationToken ct, bool v catch (Exception ex) when (ex is not OperationCanceledException && ex is not System.IO.IOException) { _discord.InvalidateThread(post.Id); - post.Fault = RecruitmentFault.From(ex, Now, previousFault); + post.Fault = RecruitmentFaultClassifier.From(ex, Now, previousFault); post.Hold = post.Fault.Message; if (!post.AcceptedAt.HasValue && post.Status == ListingStatus.Open) _recoverWindows.Add(post.Id); Save(); diff --git a/DiscordBot/Services/Recruitment/Application/RecruitmentOverview.cs b/DiscordBot/Services/Recruitment/Application/RecruitmentOverview.cs index 7357f131..0d28cb16 100644 --- a/DiscordBot/Services/Recruitment/Application/RecruitmentOverview.cs +++ b/DiscordBot/Services/Recruitment/Application/RecruitmentOverview.cs @@ -1,3 +1,4 @@ +using DiscordBot.Services.Recruitment.Infrastructure.Discord; namespace DiscordBot.Services.Recruitment; public sealed partial class RecruitmentEngine @@ -77,7 +78,7 @@ public async Task RefreshOverviewAsync(CancellationToken ct) } catch (Exception ex) when (ex is not OperationCanceledException && ex is not System.IO.IOException) { - receipt.Fault = RecruitmentFault.From(ex, Now, receipt.Fault); + receipt.Fault = RecruitmentFaultClassifier.From(ex, Now, receipt.Fault); Save(); // Cosmetic failures never change listing eligibility or evidence. } } diff --git a/DiscordBot/Services/Recruitment/Application/RecruitmentPins.cs b/DiscordBot/Services/Recruitment/Application/RecruitmentPins.cs index 83a02e1c..8567c781 100644 --- a/DiscordBot/Services/Recruitment/Application/RecruitmentPins.cs +++ b/DiscordBot/Services/Recruitment/Application/RecruitmentPins.cs @@ -1,3 +1,4 @@ +using DiscordBot.Services.Recruitment.Infrastructure.Discord; namespace DiscordBot.Services.Recruitment; public sealed partial class RecruitmentEngine @@ -28,9 +29,9 @@ private async Task PinResponseAsync(Listing post, CancellationToken ct) } catch (Exception ex) when ((ex is not OperationCanceledException || !ct.IsCancellationRequested) && !persistenceFailed) { - receipt.Fault = RecruitmentFault.From(ex, Now, receipt.Fault); + receipt.Fault = RecruitmentFaultClassifier.From(ex, Now, receipt.Fault); if (receipt.RestoreArchive && receipt.Fault.RetryAt is null) - receipt.Fault = receipt.Fault with { RetryAt = Now.AddSeconds(Math.Min(900, 15 * Math.Pow(2, receipt.Fault.Attempts - 1)) + Random.Shared.Next(1, 6)) }; + receipt.Fault = receipt.Fault with { RetryAt = Now.AddSeconds(RecruitmentBackoff.Seconds(receipt.Fault.Attempts, Random.Shared.Next(1, 6))) }; Save(); return; } if (found) diff --git a/DiscordBot/Services/Recruitment/Application/RecruitmentReferences.cs b/DiscordBot/Services/Recruitment/Application/RecruitmentReferences.cs index b83d6f52..ac45b326 100644 --- a/DiscordBot/Services/Recruitment/Application/RecruitmentReferences.cs +++ b/DiscordBot/Services/Recruitment/Application/RecruitmentReferences.cs @@ -1,3 +1,4 @@ +using DiscordBot.Services.Recruitment.Infrastructure.Discord; namespace DiscordBot.Services.Recruitment; public sealed partial class RecruitmentEngine @@ -81,7 +82,7 @@ private async Task ReconcileReferencesAsync(CancellationToken ct, bool retryHeld } catch (Exception ex) when (ex is not OperationCanceledException && ex is not System.IO.IOException) { - receipt.Fault = RecruitmentFault.From(ex, Now, receipt.Fault); + receipt.Fault = RecruitmentFaultClassifier.From(ex, Now, receipt.Fault); Save(); // Reference availability does not block otherwise valid member listings. } } diff --git a/DiscordBot/Services/Recruitment/Application/RecruitmentReports.cs b/DiscordBot/Services/Recruitment/Application/RecruitmentReports.cs index 5e007d16..dc17df39 100644 --- a/DiscordBot/Services/Recruitment/Application/RecruitmentReports.cs +++ b/DiscordBot/Services/Recruitment/Application/RecruitmentReports.cs @@ -1,3 +1,4 @@ +using DiscordBot.Services.Recruitment.Infrastructure.Discord; using DiscordBot.Utils; using DiscordBot.Services.Recruitment.Domain.Policies; namespace DiscordBot.Services.Recruitment; @@ -75,7 +76,7 @@ private async Task PublishAttentionAsync(Listing post, CancellationToken ct) } catch (Exception ex) when (ex is not OperationCanceledException && ex is not System.IO.IOException) { - notice.Fault = RecruitmentFault.From(ex, Now, notice.Fault); Save(); + notice.Fault = RecruitmentFaultClassifier.From(ex, Now, notice.Fault); Save(); } } } diff --git a/DiscordBot/Services/Recruitment/Application/RecruitmentSetup.cs b/DiscordBot/Services/Recruitment/Application/RecruitmentSetup.cs index 76cb5540..fe1ff558 100644 --- a/DiscordBot/Services/Recruitment/Application/RecruitmentSetup.cs +++ b/DiscordBot/Services/Recruitment/Application/RecruitmentSetup.cs @@ -1,3 +1,4 @@ +using DiscordBot.Services.Recruitment.Infrastructure.Discord; using System.IO; using System.Security.Cryptography; using DiscordBot.Settings; @@ -38,7 +39,7 @@ public async Task ReconcileAsync(DateTimeOffset now, bool repair, CancellationTo } catch (Exception ex) when (ex is not OperationCanceledException && ex is not IOException) { - receipt.Fault = RecruitmentFault.From(ex, now, receipt.Fault); + receipt.Fault = RecruitmentFaultClassifier.From(ex, now, receipt.Fault); } _store.Save(); Progress?.Invoke(); diff --git a/DiscordBot/Services/Recruitment/Domain/Models/RecruitmentFault.cs b/DiscordBot/Services/Recruitment/Domain/Models/RecruitmentFault.cs index a808b42a..17876deb 100644 --- a/DiscordBot/Services/Recruitment/Domain/Models/RecruitmentFault.cs +++ b/DiscordBot/Services/Recruitment/Domain/Models/RecruitmentFault.cs @@ -1,29 +1,7 @@ -using DiscordBot.Utils; -using Discord.Net; -using System.Net; - namespace DiscordBot.Services.Recruitment; /// A durable hold or retry deadline. Null RetryAt requires a relevant change or explicit recovery. public sealed record RecruitmentFault(string Message, DateTimeOffset? RetryAt, int Attempts = 1) { public bool IsDue(DateTimeOffset now) => RetryAt.HasValue && RetryAt <= now; - - public static RecruitmentFault From(Exception error, DateTimeOffset now, RecruitmentFault? previous = null) - { - var attempts = Math.Min((previous?.Attempts ?? 0) + 1, 10); - var permanent = error is InvalidOperationException || error is HttpException http && - (int)http.HttpCode is >= 400 and < 500 && http.HttpCode != HttpStatusCode.TooManyRequests; - var message = error is HttpException discord - ? $"Discord {(int)discord.HttpCode}/{(int?)discord.DiscordCode}: {discord.Message}" - : error.Message; - var retry = error is RecruitmentDeferredException deferred ? deferred.RetryAt - : permanent ? (DateTimeOffset?)null : now.AddSeconds(Math.Min(900, 15 * Math.Pow(2, attempts - 1)) + Random.Shared.Next(1, 6)); - return new RecruitmentFault(DiscordDisplayText.Literal(message, 400), retry, attempts); - } -} - -internal sealed class RecruitmentDeferredException(DateTimeOffset retryAt) : Exception("Discord rate limit; retry scheduled") -{ - public DateTimeOffset RetryAt { get; } = retryAt; } diff --git a/DiscordBot/Services/Recruitment/Infrastructure/Discord/DiscordRecruitmentAdapter.cs b/DiscordBot/Services/Recruitment/Infrastructure/Discord/DiscordRecruitmentAdapter.cs index b4adaa4a..bd8dec1b 100644 --- a/DiscordBot/Services/Recruitment/Infrastructure/Discord/DiscordRecruitmentAdapter.cs +++ b/DiscordBot/Services/Recruitment/Infrastructure/Discord/DiscordRecruitmentAdapter.cs @@ -1,3 +1,4 @@ +using DiscordBot.Services.Recruitment.Infrastructure.Discord; using DiscordBot.Utils; using System.IO; using System.Collections.Concurrent; @@ -13,16 +14,17 @@ public sealed partial class DiscordRecruitmentAdapter : IRecruitmentDiscord { private readonly DiscordSocketClient _client; private readonly BotSettings _settings; + private readonly TimeProvider _time; private readonly Dictionary _forums = new(); private readonly ConcurrentDictionary _expectedForums = new(); private readonly Dictionary _threads = new(); - private readonly Dictionary _initialFacts = new(); + private readonly Dictionary _observedFacts = new(); private readonly Dictionary _messageChannels = new(); public void InvalidateThread(string threadId) { _threads.Remove(threadId); _messageChannels.Remove(threadId); } - public void InvalidateFacts(string threadId) { _initialFacts.Remove(threadId); InvalidateThread(threadId); } - public void InvalidateCache() { _threads.Clear(); _initialFacts.Clear(); _messageChannels.Clear(); _forums.Clear(); } - public DiscordRecruitmentAdapter(DiscordSocketClient client, BotSettings settings) - => (_client, _settings) = (client, settings); + public void InvalidateFacts(string threadId) { _observedFacts.Remove(threadId); InvalidateThread(threadId); } + public void InvalidateCache() { _threads.Clear(); _observedFacts.Clear(); _messageChannels.Clear(); _forums.Clear(); } + public DiscordRecruitmentAdapter(DiscordSocketClient client, BotSettings settings, TimeProvider? time = null) + => (_client, _settings, _time) = (client, settings, time ?? TimeProvider.System); private static RequestOptions Options(CancellationToken ct) => RecruitmentRequests.Options(ct); private ulong BotUserId => _client.CurrentUser?.Id ?? _client.Rest.CurrentUser?.Id ?? throw new InvalidOperationException("Authenticated bot identity is unavailable."); @@ -45,6 +47,7 @@ public Task ValidateAccessAsync(CancellationToken ct) public async Task ValidateFeedAsync(CancellationToken ct) { + using var requestScope = RecruitmentRequests.BeginScope(); var feed = await _client.Rest.GetChannelAsync(_settings.Recruitment.FeedChannelId, Options(ct)) as RestTextChannel; if (feed is null || feed.GuildId != _settings.GuildId) throw new InvalidOperationException("Recruitment feed must be a text channel in the configured guild."); var cachedFeed = Guild.GetTextChannel(feed.Id) ?? throw new InvalidOperationException("Feed permission cache unavailable."); @@ -71,6 +74,7 @@ public async Task ValidateFeedAsync(CancellationToken ct) public async Task IsMemberAsync(string guildId, string userId, CancellationToken ct) { + using var requestScope = RecruitmentRequests.BeginScope(); if (guildId != _settings.GuildId.ToString() || !ulong.TryParse(userId, out var id)) return false; return await MemberAsync(id, ct) is not null; } @@ -83,6 +87,7 @@ public async Task IsMemberAsync(string guildId, string userId, Cancellatio public async Task GetAuthorAsync(string userId, CancellationToken ct) { + using var requestScope = RecruitmentRequests.BeginScope(); var user = await MemberAsync(ulong.Parse(userId), ct); return user is null ? null : new RecruitmentAuthor(user.IsBot, user.GuildPermissions.Administrator || user.RoleIds.Contains(_settings.Roles.Moderator)); @@ -90,6 +95,7 @@ public async Task IsMemberAsync(string guildId, string userId, Cancellatio public async Task IsAdministratorAsync(string guildId, string userId, CancellationToken ct) { + using var requestScope = RecruitmentRequests.BeginScope(); if (guildId != _settings.GuildId.ToString() || !ulong.TryParse(userId, out var id)) return false; var user = await MemberAsync(id, ct); return user?.GuildPermissions.Administrator == true; @@ -97,6 +103,7 @@ public async Task IsAdministratorAsync(string guildId, string userId, Canc public async Task IsModeratorAsync(string guildId, string userId, CancellationToken ct) { + using var requestScope = RecruitmentRequests.BeginScope(); if (guildId != _settings.GuildId.ToString() || !ulong.TryParse(userId, out var id)) return false; var user = await MemberAsync(id, ct); return user is not null && (user.GuildPermissions.Administrator || user.RoleIds.Contains(_settings.Roles.Moderator)); @@ -111,6 +118,7 @@ private async Task ForumAsync(string id, CancellationToken ct) public async Task GetForumAsync(string forumId, CancellationToken ct) { + using var requestScope = RecruitmentRequests.BeginScope(); var forum = await ForumAsync(forumId, ct); var snapshot = Snapshot(forum); _forums[forumId] = (snapshot, forum); @@ -124,6 +132,7 @@ public async Task GetForumAsync(string forumId, CancellationToken public async Task PublishForumAsync(ForumSnapshot expected, string? topic, IReadOnlyList? tags, bool requireTag, CancellationToken ct) { + using var requestScope = RecruitmentRequests.BeginScope(); if (!_forums.TryGetValue(expected.Id, out var observed) || !ReferenceEquals(observed.Snapshot, expected)) throw new InvalidOperationException("Forum publication needs a current pre-write observation."); _expectedForums[expected.Id] = expected with { Topic = topic ?? expected.Topic, Tags = tags ?? expected.Tags, @@ -143,8 +152,9 @@ public bool IsExpectedForumUpdate(ForumSnapshot actual) => _expectedForums.TryGe internal static async Task ModifyForumAsync(RestForumChannel forum, string? topic, IReadOnlyList? tags, bool requireTag, RequestOptions options) { - using var scope = tags is null ? null : RecruitmentForumTransport.PreserveTags(forum.Id, tags); - if (tags is not null) options.AuditLogReason = RecruitmentForumTransport.TagWriteReason; + using var requestScope = RecruitmentRequests.BeginScope(); + using var scope = tags is null ? null : DiscordCompatibilityTransport.PreserveTags(forum.Id, tags); + if (tags is not null) options.AuditLogReason = DiscordCompatibilityTransport.TagWriteReason; await forum.ModifyAsync(p => { if (topic is not null) p.Topic = topic; @@ -163,12 +173,13 @@ await forum.ModifyAsync(p => public async Task InventoryAsync(DateTimeOffset floor, IReadOnlySet knownIds, IReadOnlyDictionary cursors, CancellationToken ct) { + using var requestScope = RecruitmentRequests.BeginScope(); var found = new Dictionary(); var progress = new Dictionary(); var complete = true; var firstId = _settings.Recruitment.Forums.Mapping().Values.First().ToString(); var firstForum = _forums.TryGetValue(firstId, out var first) ? first.Channel : await ForumAsync(firstId, ct); - using (var active = RecruitmentForumTransport.ObserveActiveInventory(_settings.GuildId)) + using (var active = DiscordCompatibilityTransport.ObserveActiveInventory(_settings.GuildId)) { foreach (var thread in await firstForum.GetActiveThreadsAsync(Options(ct))) found[thread.Id] = thread; if (active.Identities is null) throw new InvalidOperationException("Active-thread inventory response was not observed; acceptance is held."); @@ -179,7 +190,7 @@ public async Task InventoryAsync(DateTimeOffset floor, IReadOnl { var forum = _forums.TryGetValue(id.ToString(), out var observed) ? observed.Channel : await ForumAsync(id.ToString(), ct); var cursor = cursors[id.ToString()]; - var started = cursor.StartedAt ?? DateTimeOffset.UtcNow; + var started = cursor.StartedAt ?? _time.GetUtcNow(); var since = cursor.Since > floor ? cursor.Since : floor; var before = cursor.Before; var finished = false; @@ -208,6 +219,7 @@ public async Task InventoryAsync(DateTimeOffset floor, IReadOnl public async Task GetThreadAsync(string threadId, CancellationToken ct, bool refresh = false) { + using var requestScope = RecruitmentRequests.BeginScope(); if (!refresh && _threads.TryGetValue(threadId, out var cached)) return cached; try { @@ -223,7 +235,7 @@ private async Task SnapshotAsync(RestThreadChannel thread, Cance { var ownerId = ((IThreadChannel)thread).OwnerId; var id = thread.Id.ToString(); - if (!_initialFacts.TryGetValue(id, out var facts)) + if (!_observedFacts.TryGetValue(id, out var facts)) { var user = await MemberAsync(ownerId, ct); var starter = await thread.GetMessageAsync(thread.Id, Options(ct)); @@ -240,8 +252,8 @@ private async Task SnapshotAsync(RestThreadChannel thread, Cance // Missing starters remain retryable. Bound cached initial facts independently of retained history. if (starter is not null) { - if (_initialFacts.Count >= 4096) _initialFacts.Remove(_initialFacts.Keys.First()); - _initialFacts[id] = facts; + if (_observedFacts.Count >= 4096) _observedFacts.Remove(_observedFacts.Keys.First()); + _observedFacts[id] = facts; } } var snapshot = facts with { ForumId = thread.ParentChannelId.ToString(), OwnerId = ownerId.ToString(), Title = thread.Name, @@ -264,6 +276,7 @@ private async Task MessageChannelAsync(string id, CancellationT public async Task UpsertAsync(string channelId, string? messageId, bool recovering, DateTimeOffset since, RecruitmentMessage message, Action beforeWrite, CancellationToken ct) { + using var requestScope = RecruitmentRequests.BeginScope(); var channel = await MessageChannelAsync(channelId, ct); var botUserId = BotUserId; if (message.Format == RecruitmentMessageFormat.ComponentsV2) @@ -271,86 +284,18 @@ public async Task UpsertAsync(string channelId, string? messageId, boo var permissions = channel is RestThreadChannel listingThread ? _client.GetGuild(_settings.GuildId)?.CurrentUser?.GetPermissions(_client.GetGuild(_settings.GuildId)?.GetChannel(listingThread.ParentChannelId)) : null; - return await UpsertV2Async(channel, botUserId, messageId, recovering, since, message, + return await V2MessageWriter.UpsertAsync(channel, botUserId, messageId, recovering, since, message, channel is not IThreadChannel || permissions is { AttachFiles: true, EmbedLinks: true }, beforeWrite, ct); } if (message.Embed is not null && message.TextFallback is not null && channel is RestThreadChannel listing && !Guild.CurrentUser.GetPermissions(Guild.GetChannel(listing.ParentChannelId)).EmbedLinks) message = message with { Content = message.TextFallback, Embed = null }; - IUserMessage? existing = null; - if (messageId is not null) existing = await channel.GetMessageAsync(ulong.Parse(messageId), CacheMode.AllowDownload, Options(ct)) as IUserMessage; - if (existing is not null && (!OwnsMessage(existing, botUserId, channel.Id, message.Marker, message.Format))) - throw new InvalidOperationException("Stored recruitment message identity does not match."); - if (existing is null && recovering) - { - ulong? before = null; - var covered = false; - for (var page = 0; page < 20; page++) - { - var messages = (before.HasValue ? await channel.GetMessagesAsync(before.Value, Direction.Before, 100, CacheMode.AllowDownload, Options(ct)).FlattenAsync() - : await channel.GetMessagesAsync(100, CacheMode.AllowDownload, Options(ct)).FlattenAsync()).ToArray(); - existing = messages.OfType().FirstOrDefault(m => OwnsMessage(m, botUserId, channel.Id, message.Marker, message.Format)); - if (existing is not null) break; - if (messages.Length < 100 || messages.Min(m => m.Timestamp) <= since.AddMinutes(-5)) { covered = true; break; } - before = messages.Min(m => m.Id); - } - if (existing is null && !covered) throw new InvalidOperationException("Message recovery history exceeds the scan bound; staff review required before resending."); - } - var builder = new ComponentBuilder(); - if (message.AcknowledgementRequired) builder.WithButton("Acknowledge", $"recruit:ack:{channelId}", ButtonStyle.Primary); - if (message.ReaderControls) builder.WithButton("Legend", $"recruit:details:{channelId}", ButtonStyle.Secondary) - .WithButton("Report concern", $"recruit:report:{channelId}", ButtonStyle.Secondary); - if (message.OwnerControls) builder.WithButton("Your listing", $"recruit:owner:{channelId}", ButtonStyle.Secondary); - var components = builder.Build(); - if (existing is not null) - { - var unarchivedForEdit = false; - var edit = existing.Content != message.Content || RecruitmentMessage.EmbedKey(existing.Embeds.FirstOrDefault()) != RecruitmentMessage.EmbedKey(message.Embed) || - existing.Embeds.Count > (message.Embed is null ? 0 : 1) || existing.Attachments.Count > 0 || ComponentKey(existing.Components) != ComponentKey(components.Components); - if (edit) - { - beforeWrite(); - if (channel is IThreadChannel { IsArchived: true } archived) - { - if (archived.IsLocked && !message.RestoreArchive) throw new InvalidOperationException("A locked listing requires moderator review."); - await archived.ModifyAsync(p => p.Archived = false, Options(ct)); - unarchivedForEdit = true; - } - await existing.ModifyAsync(p => { p.Content = message.Content; p.Components = components; p.Attachments = Array.Empty(); p.Embeds = message.Embed is null ? Array.Empty() : new[] { message.Embed }; p.AllowedMentions = new AllowedMentions(AllowedMentionTypes.None); }, Options(ct)); - } - // The caller persists this restoration intent before publication, so a restart can finish it. - if (message.RestoreArchive && channel is IThreadChannel thread && (unarchivedForEdit || !thread.IsArchived)) - { - if (!edit) beforeWrite(); - await thread.ModifyAsync(p => p.Archived = true, Options(ct)); - } - return new Delivery(existing.Id.ToString(), false); - } - beforeWrite(); - if (channel is IThreadChannel { IsArchived: true } archivedThread) - { - if (archivedThread.IsLocked && !message.RestoreArchive) throw new InvalidOperationException("A locked thread needs moderator review before receiving a challenge."); - await archivedThread.ModifyAsync(p => p.Archived = false, Options(ct)); - } - // Only a new, explicit author notice may ping. Recovery edits always suppress mentions above. - var mentions = message.MentionUserId is { } author - ? new AllowedMentions(AllowedMentionTypes.None) { UserIds = new List { author } } : AllowedMentions.None; - var sent = await channel.SendMessageAsync(message.Content, options: Options(ct), allowedMentions: mentions, components: components, embed: message.Embed); - if (message.RestoreArchive && channel is IThreadChannel restoreThread) - await restoreThread.ModifyAsync(p => p.Archived = true, Options(ct)); - return new Delivery(sent.Id.ToString(), true); + return await StandardMessageWriter.UpsertAsync(channel, botUserId, messageId, recovering, since, message, beforeWrite, ct); } - // Compare controls, not just presence, so acknowledgement disappears after acceptance. - private static string ComponentKey(IEnumerable components) => string.Join("|", components.Select(c => c switch - { - ActionRowComponent row => "row:" + ComponentKey(row.Components), - ButtonComponent button => $"{button.CustomId}:{button.Label}:{button.Style}:{button.IsDisabled}:{button.Url}", - _ => c.Type.ToString() - })); - public async Task CloseAsync(ThreadSnapshot thread, string? closedTagId, bool protectPinned, CancellationToken ct, Func? allowWrite = null) { + using var requestScope = RecruitmentRequests.BeginScope(); var live = await ThreadAsync(thread.Id, ct); GuardWrite(live, thread, protectPinned); if (allowWrite?.Invoke() == false) throw new InvalidOperationException("New recruitment evidence requires reconciliation before this action."); @@ -363,6 +308,7 @@ public async Task CloseAsync(ThreadSnapshot thread, string? closedTagId, bool pr } public async Task ReopenAsync(ThreadSnapshot thread, string? closedTagId, CancellationToken ct) { + using var requestScope = RecruitmentRequests.BeginScope(); var live = await ThreadAsync(thread.Id, ct); GuardWrite(live, thread, false); await live.ModifyAsync(p => { p.AppliedTags = live.AppliedTags.Where(t => t.ToString() != closedTagId).ToArray(); p.Locked = false; p.Archived = false; }, Options(ct)); @@ -374,6 +320,7 @@ private async Task ThreadAsync(string id, CancellationToken c await _client.Rest.GetChannelAsync(ulong.Parse(id), Options(ct)) as RestThreadChannel ?? throw new InvalidOperationException("Thread unavailable."); public async Task DeleteAsync(ThreadSnapshot thread, string reason, bool protectPinned, CancellationToken ct, Func? allowWrite = null) { + using var requestScope = RecruitmentRequests.BeginScope(); var live = await ThreadAsync(thread.Id, ct); GuardWrite(live, thread, protectPinned); if (allowWrite?.Invoke() == false) throw new InvalidOperationException("New recruitment evidence requires reconciliation before this action."); @@ -390,8 +337,9 @@ private void GuardWrite(RestThreadChannel live, ThreadSnapshot expected, bool pr } public async Task DeleteAdvisoryAsync(string threadId, string messageId, CancellationToken ct, string? marker = null) { + using var requestScope = RecruitmentRequests.BeginScope(); var channel = await MessageChannelAsync(threadId, ct); - if (await channel.GetMessageAsync(ulong.Parse(messageId), CacheMode.AllowDownload, Options(ct)) is IUserMessage message && marker is not null && OwnsMessage(message, BotUserId, channel.Id, marker, marker.EndsWith(":public", StringComparison.Ordinal) ? RecruitmentMessageFormat.ComponentsV2 : RecruitmentMessageFormat.Standard)) + if (await channel.GetMessageAsync(ulong.Parse(messageId), CacheMode.AllowDownload, Options(ct)) is IUserMessage message && marker is not null && OwnedMessageLookup.OwnsMessage(message, BotUserId, channel.Id, marker, marker.EndsWith(":public", StringComparison.Ordinal) ? RecruitmentMessageFormat.ComponentsV2 : RecruitmentMessageFormat.Standard)) await message.DeleteAsync(Options(ct)); } } diff --git a/DiscordBot/Services/Recruitment/Infrastructure/Discord/DiscordRecruitmentPins.cs b/DiscordBot/Services/Recruitment/Infrastructure/Discord/DiscordRecruitmentPins.cs index 9390985f..ee827638 100644 --- a/DiscordBot/Services/Recruitment/Infrastructure/Discord/DiscordRecruitmentPins.cs +++ b/DiscordBot/Services/Recruitment/Infrastructure/Discord/DiscordRecruitmentPins.cs @@ -1,3 +1,4 @@ +using DiscordBot.Services.Recruitment.Infrastructure.Discord; using Discord.Rest; namespace DiscordBot.Services.Recruitment; @@ -7,6 +8,7 @@ public sealed partial class DiscordRecruitmentAdapter public async Task PinAdvisoryAsync(string threadId, string messageId, bool restoreArchive, bool restoreLocked, Action beforeWrite, CancellationToken ct) { + using var requestScope = RecruitmentRequests.BeginScope(); // A fresh channel is essential after a lost unarchive/pin response. var channel = await _client.Rest.GetChannelAsync(ulong.Parse(threadId), Options(ct)) as RestThreadChannel; if (channel is null || channel.GuildId != _settings.GuildId || @@ -21,6 +23,7 @@ public async Task PinAdvisoryAsync(string threadId, string messageId, bool internal static async Task PinOwnedAsync(IThreadChannel channel, ulong botId, ulong messageId, bool? allowed, bool restoreArchive, bool restoreLocked, Action beforeWrite, CancellationToken ct) { + using var requestScope = RecruitmentRequests.BeginScope(); using var observation = RecruitmentMessageTransport.Observe(channel.Id); var message = await channel.GetMessageAsync(messageId, CacheMode.AllowDownload, Options(ct)) as IUserMessage; var archive = restoreArchive || channel.IsArchived; @@ -37,7 +40,7 @@ async Task RestoreAsync(bool force) // Restoration from an interrupted attempt is independent of pin capability. if (restoreArchive) await RestoreAsync(false); if (message is null) return false; - if (!OwnsMessage(message, botId, channel.Id, $"recruitment:{channel.Id}:public", RecruitmentMessageFormat.ComponentsV2)) + if (!OwnedMessageLookup.OwnsMessage(message, botId, channel.Id, $"recruitment:{channel.Id}:public", RecruitmentMessageFormat.ComponentsV2)) throw new InvalidOperationException("Response pin message identity does not match."); if (message.IsPinned) return true; if (allowed != true) throw new InvalidOperationException(allowed == false ? "Pin Messages permission missing" : "Pin Messages permission evidence unavailable"); diff --git a/DiscordBot/Services/Recruitment/Infrastructure/Discord/DiscordRecruitmentReferences.cs b/DiscordBot/Services/Recruitment/Infrastructure/Discord/DiscordRecruitmentReferences.cs index 2781a809..96679183 100644 --- a/DiscordBot/Services/Recruitment/Infrastructure/Discord/DiscordRecruitmentReferences.cs +++ b/DiscordBot/Services/Recruitment/Infrastructure/Discord/DiscordRecruitmentReferences.cs @@ -8,6 +8,7 @@ public sealed partial class DiscordRecruitmentAdapter internal const string ReferenceTitle = "Read First · Recruitment Information"; public async Task CreateReferenceAsync(string forumId, string closedTagId, string marker, CancellationToken ct) { + using var requestScope = RecruitmentRequests.BeginScope(); var forum = _forums.TryGetValue(forumId, out var cached) ? cached.Channel : await ForumAsync(forumId, ct); var tags = forum.Tags.Where(t => t.Id.ToString() == closedTagId).ToArray(); if (tags.Length != 1) throw new InvalidOperationException("Reference post needs a verified Closed tag."); @@ -22,6 +23,7 @@ internal static string ReferenceIntroduction(string marker) => public async Task SecureReferenceAsync(string forumId, string threadId, bool botOwned, string marker, CancellationToken ct) { + using var requestScope = RecruitmentRequests.BeginScope(); RestThreadChannel thread; try { diff --git a/DiscordBot/Services/Recruitment/Infrastructure/Discord/OwnedMessageLookup.cs b/DiscordBot/Services/Recruitment/Infrastructure/Discord/OwnedMessageLookup.cs new file mode 100644 index 00000000..714f90ce --- /dev/null +++ b/DiscordBot/Services/Recruitment/Infrastructure/Discord/OwnedMessageLookup.cs @@ -0,0 +1,60 @@ +using static DiscordBot.Services.Recruitment.RecruitmentRequests; + +namespace DiscordBot.Services.Recruitment.Infrastructure.Discord; + +internal enum MessageLookupStatus { Found, Absent, Incomplete } +internal sealed record MessageLookup(MessageLookupStatus Status, IUserMessage? Message) +{ + public IUserMessage? RequireCovered() => Status == MessageLookupStatus.Incomplete + ? throw new InvalidOperationException("Message recovery history exceeds the scan bound; staff review required before resending.") : Message; +} + +internal static class OwnedMessageLookup +{ + // One predicate covers stored IDs, interrupted-send discovery and advisory deletion. + internal static bool OwnsMessage(IUserMessage message, ulong botId, ulong channelId, string marker, RecruitmentMessageFormat format) => + message.Author.Id == botId && message.Channel.Id == channelId && + (format == RecruitmentMessageFormat.ComponentsV2 + ? (message.Flags & MessageFlags.ComponentsV2) != 0 && TextDisplays(message.Components).Any(t => t == $"||{marker}||") + : message.Content.Split('\n').Any(t => t == $"||{marker}||" || t == $"-# {marker}")); + + private static IEnumerable TextDisplays(IEnumerable components) + { + foreach (var component in components) + { + if (component is TextDisplayComponent text) yield return text.Content; + if (component is ContainerComponent container) + foreach (var value in TextDisplays(container.Components)) yield return value; + if (component is SectionComponent section) + foreach (var value in TextDisplays(section.Components)) yield return value; + } + } + + internal static async Task FindAsync(IMessageChannel channel, ulong botId, string? messageId, + bool recovering, DateTimeOffset since, string marker, RecruitmentMessageFormat format, CancellationToken ct) + { + ct.ThrowIfCancellationRequested(); + var existing = messageId is null ? null : await channel.GetMessageAsync(ulong.Parse(messageId), CacheMode.AllowDownload, Options(ct)) as IUserMessage; + if (existing is not null) + { + if (!OwnsMessage(existing, botId, channel.Id, marker, format)) + throw new InvalidOperationException("Stored recruitment message identity does not match."); + return new(MessageLookupStatus.Found, existing); + } + if (!recovering) return new(MessageLookupStatus.Absent, null); + ulong? before = null; + for (var page = 0; page < 20; page++) + { + ct.ThrowIfCancellationRequested(); + var messages = (before.HasValue + ? await channel.GetMessagesAsync(before.Value, Direction.Before, 100, CacheMode.AllowDownload, Options(ct)).FlattenAsync() + : await channel.GetMessagesAsync(100, CacheMode.AllowDownload, Options(ct)).FlattenAsync()).ToArray(); + existing = messages.OfType().FirstOrDefault(m => OwnsMessage(m, botId, channel.Id, marker, format)); + if (existing is not null) return new(MessageLookupStatus.Found, existing); + if (messages.Length < 100 || messages.Min(m => m.Timestamp) <= since.AddMinutes(-5)) + return new(MessageLookupStatus.Absent, null); + before = messages.Min(m => m.Id); + } + return new(MessageLookupStatus.Incomplete, null); + } +} diff --git a/DiscordBot/Services/Recruitment/Infrastructure/Discord/RecruitmentFaultClassifier.cs b/DiscordBot/Services/Recruitment/Infrastructure/Discord/RecruitmentFaultClassifier.cs new file mode 100644 index 00000000..74e2b15a --- /dev/null +++ b/DiscordBot/Services/Recruitment/Infrastructure/Discord/RecruitmentFaultClassifier.cs @@ -0,0 +1,31 @@ +using Discord.Net; +using System.Net; +using DiscordBot.Utils; + +namespace DiscordBot.Services.Recruitment.Infrastructure.Discord; + +public static class RecruitmentFaultClassifier +{ + public static RecruitmentFault From(Exception error, DateTimeOffset now, RecruitmentFault? previous = null, Func? jitterSeconds = null) + { + var attempts = Math.Min((previous?.Attempts ?? 0) + 1, 10); + var permanent = error is InvalidOperationException || error is HttpException http && + (int)http.HttpCode is >= 400 and < 500 && http.HttpCode != HttpStatusCode.TooManyRequests; + var message = error is HttpException discord + ? $"Discord {(int)discord.HttpCode}/{(int?)discord.DiscordCode}: {discord.Message}" + : error.Message; + var retry = error is RecruitmentDeferredException deferred ? deferred.RetryAt + : permanent ? (DateTimeOffset?)null : now.AddSeconds(RecruitmentBackoff.Seconds(attempts, (jitterSeconds ?? (() => Random.Shared.Next(1, 6)))())); + return new RecruitmentFault(DiscordDisplayText.Literal(message, 400), retry, attempts); + } +} + +internal static class RecruitmentBackoff +{ + public static double Seconds(int attempts, int jitterSeconds) => Math.Min(900, 15 * Math.Pow(2, attempts - 1)) + jitterSeconds; +} + +internal sealed class RecruitmentDeferredException(DateTimeOffset retryAt) : Exception("Discord rate limit; retry scheduled") +{ + public DateTimeOffset RetryAt { get; } = retryAt; +} diff --git a/DiscordBot/Services/Recruitment/Infrastructure/Discord/StandardMessageWriter.cs b/DiscordBot/Services/Recruitment/Infrastructure/Discord/StandardMessageWriter.cs new file mode 100644 index 00000000..4e9c3052 --- /dev/null +++ b/DiscordBot/Services/Recruitment/Infrastructure/Discord/StandardMessageWriter.cs @@ -0,0 +1,67 @@ +using static DiscordBot.Services.Recruitment.RecruitmentRequests; + +namespace DiscordBot.Services.Recruitment.Infrastructure.Discord; + +internal static class StandardMessageWriter +{ + internal static async Task UpsertAsync(IMessageChannel channel, ulong botUserId, string? messageId, + bool recovering, DateTimeOffset since, RecruitmentMessage message, Action beforeWrite, CancellationToken ct) + { + using var requestScope = RecruitmentRequests.BeginScope(); + var channelId = channel.Id.ToString(); + var existing = (await OwnedMessageLookup.FindAsync(channel, botUserId, messageId, recovering, since, + message.Marker, message.Format, ct)).RequireCovered(); + var builder = new ComponentBuilder(); + if (message.AcknowledgementRequired) builder.WithButton("Acknowledge", $"recruit:ack:{channelId}", ButtonStyle.Primary); + if (message.ReaderControls) builder.WithButton("Legend", $"recruit:details:{channelId}", ButtonStyle.Secondary) + .WithButton("Report concern", $"recruit:report:{channelId}", ButtonStyle.Secondary); + if (message.OwnerControls) builder.WithButton("Your listing", $"recruit:owner:{channelId}", ButtonStyle.Secondary); + var components = builder.Build(); + if (existing is not null) + { + var unarchivedForEdit = false; + var edit = existing.Content != message.Content || RecruitmentMessage.EmbedKey(existing.Embeds.FirstOrDefault()) != RecruitmentMessage.EmbedKey(message.Embed) || + existing.Embeds.Count > (message.Embed is null ? 0 : 1) || existing.Attachments.Count > 0 || ComponentKey(existing.Components) != ComponentKey(components.Components); + if (edit) + { + beforeWrite(); + if (channel is IThreadChannel { IsArchived: true } archived) + { + if (archived.IsLocked && !message.RestoreArchive) throw new InvalidOperationException("A locked listing requires moderator review."); + await archived.ModifyAsync(p => p.Archived = false, Options(ct)); + unarchivedForEdit = true; + } + await existing.ModifyAsync(p => { p.Content = message.Content; p.Components = components; p.Attachments = Array.Empty(); p.Embeds = message.Embed is null ? Array.Empty() : new[] { message.Embed }; p.AllowedMentions = new AllowedMentions(AllowedMentionTypes.None); }, Options(ct)); + } + // The caller persists this restoration intent before publication, so a restart can finish it. + if (message.RestoreArchive && channel is IThreadChannel thread && (unarchivedForEdit || !thread.IsArchived)) + { + if (!edit) beforeWrite(); + await thread.ModifyAsync(p => p.Archived = true, Options(ct)); + } + return new Delivery(existing.Id.ToString(), false); + } + beforeWrite(); + if (channel is IThreadChannel { IsArchived: true } archivedThread) + { + if (archivedThread.IsLocked && !message.RestoreArchive) throw new InvalidOperationException("A locked thread needs moderator review before receiving a challenge."); + await archivedThread.ModifyAsync(p => p.Archived = false, Options(ct)); + } + // Only a new, explicit author notice may ping. Recovery edits always suppress mentions above. + var mentions = message.MentionUserId is { } author + ? new AllowedMentions(AllowedMentionTypes.None) { UserIds = new List { author } } : AllowedMentions.None; + var sent = await channel.SendMessageAsync(message.Content, options: Options(ct), allowedMentions: mentions, components: components, embed: message.Embed); + if (message.RestoreArchive && channel is IThreadChannel restoreThread) + await restoreThread.ModifyAsync(p => p.Archived = true, Options(ct)); + return new Delivery(sent.Id.ToString(), true); + } + + // Compare controls, not just presence, so acknowledgement disappears after acceptance. + private static string ComponentKey(IEnumerable components) => string.Join("|", components.Select(c => c switch + { + ActionRowComponent row => "row:" + ComponentKey(row.Components), + ButtonComponent button => $"{button.CustomId}:{button.Label}:{button.Style}:{button.IsDisabled}:{button.Url}", + _ => c.Type.ToString() + })); + +} diff --git a/DiscordBot/Services/Recruitment/Infrastructure/Discord/Transport/RecruitmentForumTransport.cs b/DiscordBot/Services/Recruitment/Infrastructure/Discord/Transport/DiscordCompatibilityTransport.cs similarity index 95% rename from DiscordBot/Services/Recruitment/Infrastructure/Discord/Transport/RecruitmentForumTransport.cs rename to DiscordBot/Services/Recruitment/Infrastructure/Discord/Transport/DiscordCompatibilityTransport.cs index 68b3dd7e..bae14f99 100644 --- a/DiscordBot/Services/Recruitment/Infrastructure/Discord/Transport/RecruitmentForumTransport.cs +++ b/DiscordBot/Services/Recruitment/Infrastructure/Discord/Transport/DiscordCompatibilityTransport.cs @@ -7,8 +7,8 @@ namespace DiscordBot.Services.Recruitment; -/// Corrects recruitment tag serialization without bypassing Discord.Net's request queue. -internal sealed class RecruitmentForumTransport(IRestClient inner) : IRestClient +/// Shared Discord.Net 3.20.1 compatibility facade. Corrections are scoped; rate-limit deadlines are bot-wide. +internal sealed class DiscordCompatibilityTransport(IRestClient inner) : IRestClient { internal const string TagWriteReason = "Recruitment: publish forum tags"; private static readonly AsyncLocal Current = new(); @@ -27,7 +27,7 @@ internal static ActiveInventory ObserveActiveInventory(ulong guildId) } private sealed record TagScope(string Endpoint, IReadOnlyList Tags); - public static RestClientProvider Wrap(RestClientProvider provider) => url => new RecruitmentForumTransport(provider(url)); + public static RestClientProvider Wrap(RestClientProvider provider) => url => new DiscordCompatibilityTransport(provider(url)); internal static IDisposable PreserveTags(ulong forumId, IReadOnlyList tags) { diff --git a/DiscordBot/Services/Recruitment/Infrastructure/Discord/Transport/DiscordRetryDeadlines.cs b/DiscordBot/Services/Recruitment/Infrastructure/Discord/Transport/DiscordRetryDeadlines.cs new file mode 100644 index 00000000..4d59b723 --- /dev/null +++ b/DiscordBot/Services/Recruitment/Infrastructure/Discord/Transport/DiscordRetryDeadlines.cs @@ -0,0 +1,66 @@ +using System.Collections.Concurrent; +using System.Globalization; +using System.IO; +using System.Net; +using Discord.Net.Rest; +using Newtonsoft.Json.Linq; +using DiscordBot.Services.Recruitment.Infrastructure.Discord; + +namespace DiscordBot.Services.Recruitment; + +/// One production instance tracks Discord's bot-wide and major-route retry deadlines. +internal sealed class DiscordRetryDeadlines(TimeProvider? time = null, Func? delay = null) +{ + private readonly TimeProvider _time = time ?? TimeProvider.System; + private readonly Func _delay = delay ?? ((duration, ct) => Task.Delay(duration, time ?? TimeProvider.System, ct)); + private readonly ConcurrentDictionary DeferredRoutes = new(); + private long _globalUntilTicks; + + internal void CheckGlobal() + { + var global = new DateTimeOffset(Interlocked.Read(ref _globalUntilTicks), TimeSpan.Zero); + if (global > _time.GetUtcNow()) throw new RecruitmentDeferredException(global); + } + + public async Task SendAsync(string method, string endpoint, CancellationToken ct, Func> send, bool recruitment) + { + // Discord buckets include a major channel/guild identity. Conservatively defer that identity + // across methods, message IDs and query strings so a different URL cannot evade a known limit. + var path = endpoint.Split('?')[0].Split('/'); + var key = path.Length >= 2 && path[0] is "channels" or "guilds" or "webhooks" + ? path[0] + "/" + path[1] : method + " " + endpoint.Split('?')[0]; + var now = _time.GetUtcNow(); + var until = DeferredRoutes.GetValueOrDefault(key); + var global = new DateTimeOffset(Interlocked.Read(ref _globalUntilTicks), TimeSpan.Zero); + if (global > until) until = global; + if (until > now) + { + if (recruitment) throw new RecruitmentDeferredException(until); + await _delay(until - now, ct); // Other bot features retain their usual waiting behavior. + } + ct.ThrowIfCancellationRequested(); + var response = await send(); + if (response.StatusCode != HttpStatusCode.TooManyRequests) return response; + using var original = response.Stream; + using var reader = new StreamReader(original); + var body = await reader.ReadToEndAsync(ct); + var payload = JObject.Parse(body); + var seconds = (double?)payload["retry_after"]; + if (!seconds.HasValue && response.Headers.TryGetValue("Retry-After", out var header) && + double.TryParse(header, NumberStyles.Float, CultureInfo.InvariantCulture, out var value)) seconds = value; + // Include a small cushion; never shorten the server's retry window. + now = _time.GetUtcNow(); + until = now.AddSeconds(Math.Max(1, seconds ?? 60) + 1); + DeferredRoutes.AddOrUpdate(key, until, (_, prior) => prior > until ? prior : until); + if ((bool?)payload["global"] == true) + { + long previous; + do { previous = Interlocked.Read(ref _globalUntilTicks); } + while (previous < until.UtcTicks && Interlocked.CompareExchange(ref _globalUntilTicks, until.UtcTicks, previous) != previous); + } + if (DeferredRoutes.Count > 1024) + foreach (var expired in DeferredRoutes.Where(x => x.Value <= now)) DeferredRoutes.TryRemove(expired.Key, out _); + if (recruitment) throw new RecruitmentDeferredException(until); + return new RestResponse(response.StatusCode, response.Headers, new MemoryStream(System.Text.Encoding.UTF8.GetBytes(body))); + } +} diff --git a/DiscordBot/Services/Recruitment/Infrastructure/Discord/Transport/RecruitmentRequests.cs b/DiscordBot/Services/Recruitment/Infrastructure/Discord/Transport/RecruitmentRequests.cs index 61cf2745..f149ee41 100644 --- a/DiscordBot/Services/Recruitment/Infrastructure/Discord/Transport/RecruitmentRequests.cs +++ b/DiscordBot/Services/Recruitment/Infrastructure/Discord/Transport/RecruitmentRequests.cs @@ -1,67 +1,34 @@ -using System.Collections.Concurrent; -using System.Globalization; -using System.IO; -using System.Net; using Discord.Net.Rest; -using Newtonsoft.Json.Linq; namespace DiscordBot.Services.Recruitment; -/// Hands Discord retry deadlines back to the worker instead of sleeping inside the SDK queue. +/// Opt-in retry behavior lives only for the enclosing Discord operation, including SDK queue dispatch. internal static class RecruitmentRequests { - private static readonly AsyncLocal RecruitmentCall = new(); - private static readonly ConcurrentDictionary DeferredRoutes = new(); - private static long _globalUntilTicks; + private static readonly DiscordRetryDeadlines SharedDeadlines = new(); + private static readonly AsyncLocal Current = new(); + internal static bool IsScoped => Current.Value is not null; - public static RequestOptions Options(CancellationToken ct) + internal static IDisposable BeginScope(DiscordRetryDeadlines? deadlines = null) { - ct.ThrowIfCancellationRequested(); - RecruitmentCall.Value = true; - var global = new DateTimeOffset(Interlocked.Read(ref _globalUntilTicks), TimeSpan.Zero); - if (global > DateTimeOffset.UtcNow) throw new RecruitmentDeferredException(global); - return new RequestOptions { CancelToken = ct, Timeout = 15000, RetryMode = RetryMode.AlwaysFail }; + var scope = new RequestScope(deadlines ?? Current.Value?.Deadlines ?? SharedDeadlines, Current.Value); + Current.Value = scope; + return scope; } - public static async Task SendAsync(string method, string endpoint, CancellationToken ct, Func> send) + private sealed class RequestScope(DiscordRetryDeadlines deadlines, RequestScope? previous) : IDisposable + { + public DiscordRetryDeadlines Deadlines { get; } = deadlines; + public void Dispose() => Current.Value = previous; + } + + public static RequestOptions Options(CancellationToken ct) { - // Discord buckets include a major channel/guild identity. Conservatively defer that identity - // across methods, message IDs and query strings so a different URL cannot evade a known limit. - var path = endpoint.Split('?')[0].Split('/'); - var key = path.Length >= 2 && path[0] is "channels" or "guilds" or "webhooks" - ? path[0] + "/" + path[1] : method + " " + endpoint.Split('?')[0]; - var now = DateTimeOffset.UtcNow; - var until = DeferredRoutes.GetValueOrDefault(key); - var global = new DateTimeOffset(Interlocked.Read(ref _globalUntilTicks), TimeSpan.Zero); - if (global > until) until = global; - if (until > now) - { - if (RecruitmentCall.Value) throw new RecruitmentDeferredException(until); - await Task.Delay(until - now, ct); // Other bot features retain their usual waiting behavior. - } ct.ThrowIfCancellationRequested(); - var response = await send(); - if (response.StatusCode != HttpStatusCode.TooManyRequests) return response; - using var original = response.Stream; - using var reader = new StreamReader(original); - var body = await reader.ReadToEndAsync(ct); - var payload = JObject.Parse(body); - var seconds = (double?)payload["retry_after"]; - if (!seconds.HasValue && response.Headers.TryGetValue("Retry-After", out var header) && - double.TryParse(header, NumberStyles.Float, CultureInfo.InvariantCulture, out var value)) seconds = value; - // Include a small cushion; never shorten the server's retry window. - now = DateTimeOffset.UtcNow; - until = now.AddSeconds(Math.Max(1, seconds ?? 60) + 1); - DeferredRoutes.AddOrUpdate(key, until, (_, prior) => prior > until ? prior : until); - if ((bool?)payload["global"] == true) - { - long previous; - do { previous = Interlocked.Read(ref _globalUntilTicks); } - while (previous < until.UtcTicks && Interlocked.CompareExchange(ref _globalUntilTicks, until.UtcTicks, previous) != previous); - } - if (DeferredRoutes.Count > 1024) - foreach (var expired in DeferredRoutes.Where(x => x.Value <= now)) DeferredRoutes.TryRemove(expired.Key, out _); - if (RecruitmentCall.Value) throw new RecruitmentDeferredException(until); - return new RestResponse(response.StatusCode, response.Headers, new MemoryStream(System.Text.Encoding.UTF8.GetBytes(body))); + (Current.Value?.Deadlines ?? SharedDeadlines).CheckGlobal(); + return new RequestOptions { CancelToken = ct, Timeout = 15000, RetryMode = RetryMode.AlwaysFail }; } + + public static Task SendAsync(string method, string endpoint, CancellationToken ct, Func> send) => + (Current.Value?.Deadlines ?? SharedDeadlines).SendAsync(method, endpoint, ct, send, IsScoped); } diff --git a/DiscordBot/Services/Recruitment/Infrastructure/Discord/DiscordRecruitmentDelivery.cs b/DiscordBot/Services/Recruitment/Infrastructure/Discord/V2MessageWriter.cs similarity index 66% rename from DiscordBot/Services/Recruitment/Infrastructure/Discord/DiscordRecruitmentDelivery.cs rename to DiscordBot/Services/Recruitment/Infrastructure/Discord/V2MessageWriter.cs index 9cb85b74..695f6192 100644 --- a/DiscordBot/Services/Recruitment/Infrastructure/Discord/DiscordRecruitmentDelivery.cs +++ b/DiscordBot/Services/Recruitment/Infrastructure/Discord/V2MessageWriter.cs @@ -1,29 +1,11 @@ +using static DiscordBot.Services.Recruitment.RecruitmentRequests; using Discord.Net; using Newtonsoft.Json.Linq; -namespace DiscordBot.Services.Recruitment; +namespace DiscordBot.Services.Recruitment.Infrastructure.Discord; -public sealed partial class DiscordRecruitmentAdapter +internal static class V2MessageWriter { - // One predicate covers stored IDs, interrupted-send discovery and advisory deletion. - internal static bool OwnsMessage(IUserMessage message, ulong botId, ulong channelId, string marker, RecruitmentMessageFormat format) => - message.Author.Id == botId && message.Channel.Id == channelId && - (format == RecruitmentMessageFormat.ComponentsV2 - ? (message.Flags & MessageFlags.ComponentsV2) != 0 && TextDisplays(message.Components).Any(t => t == $"||{marker}||") - : message.Content.Split('\n').Any(t => t == $"||{marker}||" || t == $"-# {marker}")); - - private static IEnumerable TextDisplays(IEnumerable components) - { - foreach (var component in components) - { - if (component is TextDisplayComponent text) yield return text.Content; - if (component is ContainerComponent container) - foreach (var value in TextDisplays(container.Components)) yield return value; - if (component is SectionComponent section) - foreach (var value in TextDisplays(section.Components)) yield return value; - } - } - // Serialize the complete recursive builder model, dropping only Discord-assigned IDs and // resolved CDN metadata. Attachment URLs and avatar CDN signatures have a stable identity. internal static string V2ComponentKey(IEnumerable components) @@ -55,29 +37,14 @@ private static void Normalize(JToken token) else if (token is JArray array) foreach (var item in array) Normalize(item); } - internal static async Task UpsertV2Async(IMessageChannel channel, ulong botId, string? messageId, + internal static async Task UpsertAsync(IMessageChannel channel, ulong botId, string? messageId, bool recovering, DateTimeOffset since, RecruitmentMessage message, bool mediaAvailable, Action beforeWrite, CancellationToken ct) { + using var requestScope = RecruitmentRequests.BeginScope(); ct.ThrowIfCancellationRequested(); using var observation = RecruitmentMessageTransport.Observe(channel.Id); - IUserMessage? existing = messageId is null ? null : await channel.GetMessageAsync(ulong.Parse(messageId), CacheMode.AllowDownload, Options(ct)) as IUserMessage; - if (existing is not null && !OwnsMessage(existing, botId, channel.Id, message.Marker, RecruitmentMessageFormat.ComponentsV2)) - throw new InvalidOperationException("Stored recruitment message identity does not match."); - if (existing is null && recovering) - { - ulong? before = null; - var covered = false; - for (var page = 0; page < 20; page++) - { - var messages = (before.HasValue ? await channel.GetMessagesAsync(before.Value, Direction.Before, 100, CacheMode.AllowDownload, Options(ct)).FlattenAsync() - : await channel.GetMessagesAsync(100, CacheMode.AllowDownload, Options(ct)).FlattenAsync()).ToArray(); - existing = messages.OfType().FirstOrDefault(m => OwnsMessage(m, botId, channel.Id, message.Marker, RecruitmentMessageFormat.ComponentsV2)); - if (existing is not null) break; - if (messages.Length < 100 || messages.Min(m => m.Timestamp) <= since.AddMinutes(-5)) { covered = true; break; } - before = messages.Min(m => m.Id); - } - if (existing is null && !covered) throw new InvalidOperationException("Message recovery history exceeds the scan bound; staff review required before resending."); - } + var existing = (await OwnedMessageLookup.FindAsync(channel, botId, messageId, recovering, since, + message.Marker, RecruitmentMessageFormat.ComponentsV2, ct)).RequireCovered(); var media = mediaAvailable ? message.Media : null; var components = media is null ? message.FallbackComponents ?? message.Components! : message.Components!; var retained = media is null ? null : existing?.Attachments.SingleOrDefault(a => a.Filename == media.FileName); @@ -121,7 +88,7 @@ await existing.ModifyAsync(p => { // A definite rejected media write is safe to retry as text. Ambiguous responses and // cancellation propagate with the saved publication/restoration intent untouched. - return await UpsertV2Async(channel, botId, existing?.Id.ToString(), recovering, since, + return await UpsertAsync(channel, botId, existing?.Id.ToString(), recovering, since, message with { Media = null, Components = message.FallbackComponents }, false, beforeWrite, ct); } if (message.RestoreArchive && (needsRestore || edit) && channel is IThreadChannel restore) From 7e797b9c9ee8f4510de1f4a1bba54241c8ca114a Mon Sep 17 00:00:00 2001 From: James Kellie Date: Mon, 19 Oct 2026 21:12:40 +1000 Subject: [PATCH 28/31] Extract listing lifecycle and durable action execution --- .../Workflows/AutomaticActionPolicyTests.cs | 23 ++ .../Application/ListingActionExecutor.cs | 140 +++++++ .../Application/ListingEligibilityText.cs | 20 + .../Application/ListingEvidenceCapture.cs | 59 +++ .../Recruitment/Application/ListingForums.cs | 24 ++ .../Application/ListingPublisher.cs | 176 +++++++++ .../Recruitment/Application/ListingReader.cs | 61 +++ .../Application/ListingWorkflow.cs | 213 ++++++++++ .../Application/RecruitmentAcknowledgement.cs | 43 --- .../Application/RecruitmentAttempts.cs | 22 +- .../Application/RecruitmentEngine.cs | 365 ++---------------- .../RecruitmentEngineScheduling.cs | 49 +-- .../Application/RecruitmentEvidence.cs | 41 -- .../Application/RecruitmentPins.cs | 43 --- .../Application/RecruitmentReports.cs | 36 +- .../Policies/AcknowledgementRecovery.cs | 9 + .../Domain/Policies/AutomaticActionPolicy.cs | 18 + 17 files changed, 788 insertions(+), 554 deletions(-) create mode 100644 DiscordBot.Tests/Recruitment/Workflows/AutomaticActionPolicyTests.cs create mode 100644 DiscordBot/Services/Recruitment/Application/ListingActionExecutor.cs create mode 100644 DiscordBot/Services/Recruitment/Application/ListingEligibilityText.cs create mode 100644 DiscordBot/Services/Recruitment/Application/ListingEvidenceCapture.cs create mode 100644 DiscordBot/Services/Recruitment/Application/ListingForums.cs create mode 100644 DiscordBot/Services/Recruitment/Application/ListingPublisher.cs create mode 100644 DiscordBot/Services/Recruitment/Application/ListingReader.cs create mode 100644 DiscordBot/Services/Recruitment/Application/ListingWorkflow.cs delete mode 100644 DiscordBot/Services/Recruitment/Application/RecruitmentAcknowledgement.cs delete mode 100644 DiscordBot/Services/Recruitment/Application/RecruitmentPins.cs create mode 100644 DiscordBot/Services/Recruitment/Domain/Policies/AcknowledgementRecovery.cs create mode 100644 DiscordBot/Services/Recruitment/Domain/Policies/AutomaticActionPolicy.cs diff --git a/DiscordBot.Tests/Recruitment/Workflows/AutomaticActionPolicyTests.cs b/DiscordBot.Tests/Recruitment/Workflows/AutomaticActionPolicyTests.cs new file mode 100644 index 00000000..faf7939a --- /dev/null +++ b/DiscordBot.Tests/Recruitment/Workflows/AutomaticActionPolicyTests.cs @@ -0,0 +1,23 @@ +using DiscordBot.Services.Recruitment; +using DiscordBot.Services.Recruitment.Domain.Policies; + +namespace DiscordBot.Tests.Recruitment; + +public class AutomaticActionPolicyTests +{ + [Theory] + [InlineData(false, false, true)] + [InlineData(true, true, true)] + [InlineData(true, false, false)] + public void TimeoutRequiresCurrentEvidenceAndRecoveredWindow(bool current, bool recovering, bool cancelled) + { + var now = DateTimeOffset.UnixEpoch.AddDays(1); + var post = new Listing { Deadline = now.AddMinutes(-1) }; + var action = new RecruitmentAction { Automatic = true, Condition = RecruitmentConditions.Timeout }; + Assert.Equal(cancelled, AutomaticActionPolicy.ShouldCancel(new(), post, action, now, 30, current, recovering, false)); + post.AcknowledgedAt = now.AddMinutes(-2); + Assert.True(AutomaticActionPolicy.ShouldCancel(new(), post, action, now, 30, current, recovering, false)); + action.Automatic = false; + Assert.False(AutomaticActionPolicy.ShouldCancel(new(), post, action, now, 30, false, true, false)); + } +} diff --git a/DiscordBot/Services/Recruitment/Application/ListingActionExecutor.cs b/DiscordBot/Services/Recruitment/Application/ListingActionExecutor.cs new file mode 100644 index 00000000..dc6623f2 --- /dev/null +++ b/DiscordBot/Services/Recruitment/Application/ListingActionExecutor.cs @@ -0,0 +1,140 @@ +using System.IO; +using DiscordBot.Settings; +using DiscordBot.Utils; +using DiscordBot.Services.Recruitment.Domain.Analysis; +using DiscordBot.Services.Recruitment.Domain.Policies; +using DiscordBot.Services.Recruitment.Infrastructure.Discord; + +namespace DiscordBot.Services.Recruitment.Application; + +internal sealed class ListingActionExecutor(RecruitmentState state, RecruitmentSettings settings, IRecruitmentDiscord discord, TimeProvider time, Action save, AcknowledgementRecovery windows, ListingReader reader, ListingPublisher publisher, ListingForums forums, Func evidenceCurrent, Func pendingThreadWork) +{ + private readonly RecruitmentState State = state; + private readonly RecruitmentSettings _settings = settings; + private readonly IRecruitmentDiscord _discord = discord; + private DateTimeOffset Now => time.GetUtcNow(); + private void Save() => save(); + private readonly AcknowledgementRecovery _recoverWindows = windows; + private readonly ListingReader _reader = reader; + private readonly ListingPublisher _publisher = publisher; + private readonly ListingForums _forums = forums; + private bool EvidenceCurrent => evidenceCurrent(); + private readonly Func _pendingThreadWork = pendingThreadWork; + + public async Task BeginActionAsync(Listing post, RecruitmentActionKind kind, string actor, string reason, string condition, bool automatic, CancellationToken ct, bool requiresModerator = false) + { + if (post.PendingAction is not null) throw new InvalidOperationException("An earlier action still needs reconciliation."); + post.PendingAction = new RecruitmentAction { Kind = kind, ActorId = actor, Reason = reason, Condition = condition, + Automatic = automatic, RequiresModerator = requiresModerator, GuidelineTimeout = condition == RecruitmentConditions.Timeout, CreatedAt = Now }; + if (!automatic) Audit(post, actor, reason); + Save(); + await ExecuteActionAsync(post, ct); + } + + public async Task ExecuteActionAsync(Listing post, CancellationToken ct) + { + var action = post.PendingAction!; + if (action.Automatic && action.Condition == RecruitmentConditions.LegacyUnanswered) + { + await ReconcileLegacyExpiryAsync(post, action, ct); + return; + } + if (action.RequiresModerator && !await _discord.IsModeratorAsync(State!.GuildId, action.ActorId, ct)) + { + post.PendingAction = null; post.LastOutcome = "Staff action cancelled: moderator access no longer present"; + post.PublicationPending = true; Save(); await _publisher.PublishStaffAsync(post, ct); return; + } + // Intent and staff evidence must exist before removing or changing member content. + await _publisher.PublishStaffAsync(post, ct); + var live = await _reader.ReadAsync(post, ct, refresh: true); + if (live is null) + { + _reader.RecordAbsence(post); + await _publisher.PublishStaffAsync(post, ct); post.PublicationPending = false; Save(); return; + } + if (action.Kind == RecruitmentActionKind.RemoveAdvisory) + { + await RemoveAdvisoryAsync(post, ct); + return; + } + + if (action.CallStarted && action.Kind == RecruitmentActionKind.Close && live.Archived && live.Locked) + { + post.Status = ListingStatus.Closed; post.PendingAction = null; + post.LastOutcome = "Closure confirmed after interrupted action"; post.UpdatedAt = Now; post.Version++; + post.PublicationPending = true; Save(); await _publisher.PublishAsync(post, ct); return; + } + if (action.Automatic && AutomaticActionPolicy.ShouldCancel(State, post, action, Now, _settings.CooldownDays, + EvidenceCurrent, _recoverWindows.Contains(post.Id), + action.Condition == RecruitmentConditions.ClosedTag && live.Tags.Contains(_forums.ClosingTag(post)))) + { post.PendingAction = null; Save(); return; } + if (action.Kind == RecruitmentActionKind.Reopen && State!.Listings.Values.Any(p => p.OwnerId == post.OwnerId && p.Id != post.Id && p.Status == ListingStatus.Open)) + throw new InvalidOperationException("Another listing occupies this author's slot."); + // Gateway facts and timely owner interactions already queued must be applied before an automatic action. + if (action.Automatic && (!EvidenceCurrent || _pendingThreadWork(post.Id))) + { post.PendingAction = null; Save(); return; } + // The pending action is already visible to staff. Do not append an automatic-action audit + // for a request cancelled by newer evidence before it reaches the write boundary. + if (action.Automatic && !action.CallStarted) Audit(post, action.ActorId, action.Reason); + action.CallStarted = true; Save(); + switch (action.Kind) + { + case RecruitmentActionKind.Delete: + await _discord.DeleteAsync(live, action.Reason, action.Automatic, ct, () => !action.Automatic || EvidenceCurrent && !_pendingThreadWork(post.Id)); + post.Status = ListingStatus.Deleted; + if (action.GuidelineTimeout) + { + var author = ListingTransitions.Author(State!, post.OwnerId, Now); + author.ConsecutiveTimeouts++; + if (author.ConsecutiveTimeouts >= 3 && !author.TimeoutAlerted) + { post.TimeoutAlertCount = author.ConsecutiveTimeouts; author.TimeoutAlerted = true; _publisher.QueueAttention(post, "Three consecutive confirmed Guidelines timeouts"); } + } + break; + case RecruitmentActionKind.Close: + await _discord.CloseAsync(live, _forums.ClosingTag(post), action.Automatic, ct, () => !action.Automatic || EvidenceCurrent && !_pendingThreadWork(post.Id)); + post.Status = ListingStatus.Closed; + break; + case RecruitmentActionKind.Reopen: + await _discord.ReopenAsync(live, _forums.ClosingTag(post), ct); + post.Status = ListingStatus.Open; + if (!post.AcceptedAt.HasValue) _recoverWindows.Add(post.Id); + break; + } + ListingTransitions.Author(State!, post.OwnerId, Now); + post.LastOutcome = action.Reason; post.PendingAction = null; post.UpdatedAt = Now; post.Version++; post.PublicationPending = true; Save(); + await _publisher.PublishAsync(post, ct); + } + + private async Task ReconcileLegacyExpiryAsync(Listing post, RecruitmentAction action, CancellationToken ct) + { + // Cancel unsent legacy expiry; preserve a closure already applied before the upgrade. + var previous = action.CallStarted ? await _reader.ReadAsync(post, ct, refresh: true) : null; + if (action.CallStarted && previous is null) { _reader.RecordAbsence(post); return; } + var alreadyClosed = action.CallStarted && previous is { Locked: true, Archived: true }; + if (alreadyClosed) post.Status = ListingStatus.Closed; + post.PendingAction = null; post.PublicationPending = true; + post.LastOutcome = alreadyClosed ? "Closure applied before upgrade confirmed" : "Automatic expiry cancelled; listing remains open"; + Save(); + } + + private async Task RemoveAdvisoryAsync(Listing post, CancellationToken ct) + { + if (post.PublicMessageId is null && post.PublicSendPending) + { + // Resolve a possibly delivered provisional advisory before removing it. + var recovered = await _discord.UpsertAsync(post.Id, null, true, post.PublicSendStartedAt ?? post.CreatedAt, + RecruitmentPresentation.PublicV2(State!, post, _settings, Now), Save, ct); + post.PublicMessageId = recovered.Id; post.PublicSendPending = false; Save(); + } + if (post.PublicMessageId is not null) await _discord.DeleteAdvisoryAsync(post.Id, post.PublicMessageId, ct, $"recruitment:{post.Id}:public"); + post.PublicMessageId = null; post.PublicMediaAttachmentId = null; post.PublicMediaFileName = null; post.PendingAction = null; Save(); + } + + public void Audit(Listing post, string actor, string reason) + { + post.LastActor = actor; post.LastReason = reason; + post.Audit.Add(new RecruitmentAudit(Now, actor, reason)); + if (post.Audit.Count > 30) post.Audit.RemoveRange(0, post.Audit.Count - 30); + } + +} diff --git a/DiscordBot/Services/Recruitment/Application/ListingEligibilityText.cs b/DiscordBot/Services/Recruitment/Application/ListingEligibilityText.cs new file mode 100644 index 00000000..8867d716 --- /dev/null +++ b/DiscordBot/Services/Recruitment/Application/ListingEligibilityText.cs @@ -0,0 +1,20 @@ +using System.IO; +using DiscordBot.Settings; +using DiscordBot.Utils; +using DiscordBot.Services.Recruitment.Domain.Analysis; +using DiscordBot.Services.Recruitment.Domain.Policies; +using DiscordBot.Services.Recruitment.Infrastructure.Discord; + +namespace DiscordBot.Services.Recruitment.Application; + +internal sealed class ListingEligibilityText() +{ + + public static string EligibilityReason(Eligibility eligibility) + { + if (eligibility.AttemptPausedUntil is { } pause) return $"Posting attempts paused until {pause:yyyy-MM-dd HH:mm} UTC. Check /recruitment eligibility."; + if (eligibility.BlockingListingId is { } id) return $"Existing listing {id} occupies your slot. Update or bump that thread; check /recruitment eligibility."; + return $"Accepted-listing cooldown ends {eligibility.NextAllowedAt:yyyy-MM-dd HH:mm} UTC. Check /recruitment eligibility."; + } + +} diff --git a/DiscordBot/Services/Recruitment/Application/ListingEvidenceCapture.cs b/DiscordBot/Services/Recruitment/Application/ListingEvidenceCapture.cs new file mode 100644 index 00000000..27014b4b --- /dev/null +++ b/DiscordBot/Services/Recruitment/Application/ListingEvidenceCapture.cs @@ -0,0 +1,59 @@ +using System.Security.Cryptography; +using System.Text; +using System.IO; +using DiscordBot.Settings; +using DiscordBot.Utils; +using DiscordBot.Services.Recruitment.Domain.Analysis; +using DiscordBot.Services.Recruitment.Domain.Policies; +using DiscordBot.Services.Recruitment.Infrastructure.Discord; + +namespace DiscordBot.Services.Recruitment.Application; + +internal sealed class ListingEvidenceCapture(RecruitmentState state, TimeProvider time, Action queueAttention) +{ + private readonly RecruitmentState State = state; + private DateTimeOffset Now => time.GetUtcNow(); + private void QueueAttention(Listing post, string reason) => queueAttention(post, reason); + + public void RefreshFacts(Listing post, ThreadSnapshot live) + { + var purpose = State!.Forums.First(f => f.Value.ForumId == post.ForumId).Key; + var analysis = ListingAnalysis.Read(live, purpose, State.PortfolioSites.EffectiveHosts()); + var payment = analysis.Payment; + post.Facts.Checks = RecruitmentSignals.Evaluate(live, purpose, analysis); + post.PortfolioRegistryVersion = State.PortfolioSites.Version; + post.Facts.PortfolioCandidates = analysis.Portfolio; + post.Facts.ServerNickname = RecruitmentSignals.SafeIdentity(live.ServerNickname); + post.Facts.GlobalName = RecruitmentSignals.SafeIdentity(live.GlobalName); + post.Facts.Username = RecruitmentSignals.SafeIdentity(live.Username); + post.Facts.AvatarUrl = RecruitmentSignals.SafeAvatarUrl(live.AvatarUrl); + post.Facts.AvatarIdentity = RecruitmentSignals.SafeIdentity(live.AvatarIdentity, 200); + post.Facts.AccountCreatedAt = live.AccountCreatedAt; post.Facts.JoinedAt = live.JoinedAt; + post.Facts.MemberRoleCount = live.MemberRoleCount; post.Facts.HasCustomAvatar = live.HasCustomAvatar; + post.Facts.Payment = payment.Summary; post.Facts.PaymentEvidence = payment.Evidence; + post.Facts.Portfolio = analysis.PortfolioSummary; + post.Facts.Observations = analysis.Observations; + if (live.Starter is null) return; // Missing data must not overwrite previously observed evidence. + var attachments = live.Attachments?.Take(10).ToList() ?? new(); + var normalized = string.Join(' ', live.Starter.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries)); + var fingerprint = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(normalized + "\n" + string.Join('|', attachments.Select(a => a.Id))))); + if (post.LatestStarter?.Fingerprint == fingerprint) return; + var version = new StarterVersion(Now, live.Starter[..Math.Min(2000, live.Starter.Length)], live.Starter.Length > 2000, + fingerprint, payment.Summary, payment.Evidence, analysis.ContactLinks, attachments, + Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(analysis.CompensationTerms)))); + if (post.LatestStarter is { } previous && post.AcceptedAt.HasValue && + (previous.Payment != version.Payment || previous.PaymentEvidence != version.PaymentEvidence || + previous.TermsFingerprint is not null && previous.TermsFingerprint != version.TermsFingerprint || + !previous.Links.SequenceEqual(version.Links) || !previous.Attachments.SequenceEqual(version.Attachments))) + { + if (post.MaterialChanges == post.ReviewedMaterialChanges) QueueAttention(post, "Payment terms, links or attachments changed after acceptance"); + post.MaterialChanges++; + post.EarlierMaterialVersions.Add(previous); + if (post.EarlierMaterialVersions.Count > 3) post.EarlierMaterialVersions.RemoveAt(0); + } + post.OriginalStarter ??= version; + post.LatestStarter = version; + post.UpdatedAt = Now; + } + +} diff --git a/DiscordBot/Services/Recruitment/Application/ListingForums.cs b/DiscordBot/Services/Recruitment/Application/ListingForums.cs new file mode 100644 index 00000000..5d4c7953 --- /dev/null +++ b/DiscordBot/Services/Recruitment/Application/ListingForums.cs @@ -0,0 +1,24 @@ +using System.IO; +using DiscordBot.Settings; +using DiscordBot.Utils; +using DiscordBot.Services.Recruitment.Domain.Analysis; +using DiscordBot.Services.Recruitment.Domain.Policies; +using DiscordBot.Services.Recruitment.Infrastructure.Discord; + +namespace DiscordBot.Services.Recruitment.Application; + +internal sealed class ListingForums(RecruitmentState state, RecruitmentAssets assets) +{ + private readonly RecruitmentState State = state; + private readonly RecruitmentAssets _assets = assets; + public string TagAdvice(ThreadSnapshot live) => _assets.TagAdvice(Purpose(live.ForumId), live.Tags, State.Forums[Purpose(live.ForumId)]); + + public string Purpose(string forum) => State!.Forums.Single(x => x.Value.ForumId == forum).Key; + + public string? ClosingTag(Listing post) + { + var purpose = Purpose(post.ForumId); + return State!.Forums[purpose].Tags.GetValueOrDefault(_assets!.Tags[purpose].Single(t => t.Closing).Key)?.Id; + } + +} diff --git a/DiscordBot/Services/Recruitment/Application/ListingPublisher.cs b/DiscordBot/Services/Recruitment/Application/ListingPublisher.cs new file mode 100644 index 00000000..031e0632 --- /dev/null +++ b/DiscordBot/Services/Recruitment/Application/ListingPublisher.cs @@ -0,0 +1,176 @@ +using System.IO; +using DiscordBot.Settings; +using DiscordBot.Utils; +using DiscordBot.Services.Recruitment.Domain.Analysis; +using DiscordBot.Services.Recruitment.Domain.Policies; +using DiscordBot.Services.Recruitment.Infrastructure.Discord; + +namespace DiscordBot.Services.Recruitment.Application; + +internal sealed class ListingPublisher(RecruitmentState state, RecruitmentSettings settings, IRecruitmentDiscord discord, TimeProvider time, Action save, Action guardFeedWrite) +{ + private readonly RecruitmentState State = state; + private readonly RecruitmentSettings _settings = settings; + private readonly IRecruitmentDiscord _discord = discord; + private DateTimeOffset Now => time.GetUtcNow(); + private void Save() => save(); + private void GuardFeedWrite() => guardFeedWrite(); + private readonly Dictionary _publicRenders = new(); + private readonly Dictionary _staffRenders = new(); + public void ClearRenders() { _publicRenders.Clear(); _staffRenders.Clear(); } + public void InvalidatePublic(string id) => _publicRenders.Remove(id); + public void InvalidateStaff(string id) => _staffRenders.Remove(id); + + public async Task PublishStaffAsync(Listing post, CancellationToken ct) + { + await PublishAttentionAsync(post, ct); + var message = RecruitmentPresentation.Staff(State!, post, _settings); + if (!post.StaffSendPending && post.StaffMessageId is not null && _staffRenders.GetValueOrDefault(post.Id) == message.Content) return; + var recovering = post.StaffSendPending; + var delivery = await _discord.UpsertAsync(_settings.FeedChannelId.ToString(), post.StaffMessageId, recovering, + post.StaffSendStartedAt ?? post.CreatedAt, message, () => + { + GuardFeedWrite(); + if (!recovering) post.StaffSendStartedAt = Now; + post.StaffSendPending = true; Save(); + }, ct); + var changed = post.StaffMessageId != delivery.Id || post.StaffSendPending; + post.StaffMessageId = delivery.Id; post.StaffSendPending = false; + _staffRenders[post.Id] = message.Content; + if (changed) Save(); + } + + public async Task PublishAsync(Listing post, CancellationToken ct) + { + await PublishStaffAsync(post, ct); + if (post.Status is ListingStatus.Deleted or ListingStatus.Reference) + { post.PublicationPending = false; post.PublicSendPending = false; Save(); return; } + var message = RecruitmentPresentation.PublicV2(State!, post, _settings, Now); + var renderKey = message.RenderKey + "\nPortfolio registry " + State!.PortfolioSites.Version; + if (!post.PublicSendPending && !post.PublicationPending && post.PublicMessageId is not null && _publicRenders.GetValueOrDefault(post.Id) == renderKey) return; + var recovering = post.PublicSendPending; + var delivery = await _discord.UpsertAsync(post.Id, post.PublicMessageId, recovering, post.PublicSendStartedAt ?? post.CreatedAt, + message with + { RestoreArchive = post.PublicRestoreArchive || post.Status == ListingStatus.Closed, RestoreLocked = post.PublicRestoreLocked || post.Status == ListingStatus.Closed }, () => + { + if (!recovering) post.PublicSendStartedAt = Now; + post.PublicSendPending = true; Save(); + }, ct); + var changed = post.PublicMessageId != delivery.Id || post.PublicSendPending || post.PublicationPending || + post.PublicMediaAttachmentId != delivery.MediaAttachmentId || post.PublicMediaFileName != delivery.MediaFileName; + post.PublicMediaAttachmentId = delivery.MediaAttachmentId; post.PublicMediaFileName = delivery.MediaFileName; + post.PublicMessageId = delivery.Id; post.PublicSendPending = false; post.PublicationPending = false; + _publicRenders[post.Id] = renderKey; + if (delivery.NewlySent && post.Deadline.HasValue && !post.AcceptedAt.HasValue) + { post.PromptDeliveredAt = null; post.Deadline = null; post.Version++; changed = true; } + if (changed) Save(); + } + + public void QueueAttention(Listing post, string reason) + { + // Bound retained receipts. During prolonged feed failure, coalesce rather than grow without limit. + if (post.Attention.Count >= 8) + { + var completed = post.Attention.FirstOrDefault(n => n.MessageId is not null && !n.SendPending); + if (completed is null) return; // Existing pending notices and the overview still link this listing. + post.Attention.Remove(completed); + } + post.Attention.Add(new AttentionNotice { At = Now, Reason = reason }); + } + + public bool AttentionDue(Listing post) => post.Attention.Any(n => (n.MessageId is null || n.SendPending) && (n.Fault is null || n.Fault.IsDue(Now))); + + public async Task PublishAttentionAsync(Listing post, CancellationToken ct) + { + foreach (var notice in post.Attention.Where(n => (n.MessageId is null || n.SendPending) && (n.Fault is null || n.Fault.IsDue(Now))).Take(3)) + { + try + { + var marker = $"recruitment:{post.Id}:attention:{notice.Id}"; + var message = new RecruitmentMessage($"**Recruitment review** · {notice.Reason}\n<#{post.Id}> · author `{post.OwnerId}`\nUse `/recruitment lookup {post.Id}` for evidence and review actions.\n||{marker}||", marker, false); + var delivery = await _discord.UpsertAsync(_settings.FeedChannelId.ToString(), notice.MessageId, notice.SendPending, + notice.At, message, () => { GuardFeedWrite(); notice.SendPending = true; Save(); }, ct); + notice.MessageId = delivery.Id; notice.SendPending = false; notice.Fault = null; Save(); + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not System.IO.IOException) + { + notice.Fault = RecruitmentFaultClassifier.From(ex, Now, notice.Fault); Save(); + } + } + } + + public DateTimeOffset? AcknowledgementNoticeDue(Listing post) => + post.Status == ListingStatus.Open && !post.AcceptedAt.HasValue && !post.AcknowledgedAt.HasValue && + post.PublicMessageId is not null && post.Deadline > Now && + (post.AcknowledgementNotice.MessageId is null || post.AcknowledgementNotice.SendPending) + ? post.AcknowledgementNotice.Fault is { } fault ? fault.RetryAt : Now : null; + + public async Task PublishAcknowledgementNoticeAsync(Listing post, CancellationToken ct) + { + if (AcknowledgementNoticeDue(post) is not { } due || due > Now) return; + var notice = post.AcknowledgementNotice; + var marker = $"recruitment:{post.Id}:acknowledgement"; + // The card owns the current deadline and may be restored with a new message ID/window. + var message = new RecruitmentMessage($"<@{post.OwnerId}>, please read this forum's **Guidelines**, then use **Acknowledge** on the " + + $"observations card in this thread to enter its code. Check the card for your deadline.\n||{marker}||", + marker, false, MentionUserId: ulong.Parse(post.OwnerId)); + var recovering = notice.SendPending; + try + { + var delivery = await _discord.UpsertAsync(post.Id, notice.MessageId, recovering, notice.StartedAt ?? Now, + message, () => + { + if (!recovering) notice.StartedAt = Now; + notice.SendPending = true; Save(); + }, ct); + notice.MessageId = delivery.Id; notice.SendPending = false; notice.Fault = null; + Save(); + } + catch (Exception ex) when (ex is not IOException && ex is not OperationCanceledException) + { + // The public controls remain usable; an optional notification cannot reset the deadline + // or prevent a timely acknowledgement. Retry/recover independently without a second ping. + notice.Fault = RecruitmentFaultClassifier.From(ex, Now, notice.Fault); + Save(); + } + } + + public bool PinDue(Listing post) => post.Status is ListingStatus.Open or ListingStatus.Closed && + post.PublicMessageId is not null && + (post.PublicPin.MessageId != post.PublicMessageId || + (!post.PublicPin.Confirmed || post.PublicPin.RestoreArchive) && (post.PublicPin.Fault is null || post.PublicPin.Fault.IsDue(Now))); + + public async Task PinResponseAsync(Listing post, CancellationToken ct) + { + if (!PinDue(post)) return; + if (post.PublicPin.MessageId != post.PublicMessageId) + { post.PublicPin = new MessagePinReceipt { MessageId = post.PublicMessageId }; Save(); } + var receipt = post.PublicPin; + bool found; + var persistenceFailed = false; + try + { + found = await _discord.PinAdvisoryAsync(post.Id, receipt.MessageId!, receipt.RestoreArchive, receipt.RestoreLocked, + (archive, locked) => + { + receipt.RestoreArchive = archive; receipt.RestoreLocked = locked; + try { Save(); } + catch (Exception ex) + { persistenceFailed = true; throw new System.IO.IOException("Could not persist response pin intent.", ex); } + }, ct); + } + catch (Exception ex) when ((ex is not OperationCanceledException || !ct.IsCancellationRequested) && !persistenceFailed) + { + receipt.Fault = RecruitmentFaultClassifier.From(ex, Now, receipt.Fault); + if (receipt.RestoreArchive && receipt.Fault.RetryAt is null) + receipt.Fault = receipt.Fault with { RetryAt = Now.AddSeconds(RecruitmentBackoff.Seconds(receipt.Fault.Attempts, Random.Shared.Next(1, 6))) }; + Save(); return; + } + if (found) + { receipt.Confirmed = true; receipt.RestoreArchive = receipt.RestoreLocked = false; receipt.Fault = null; } + else + { post.PublicMessageId = null; post.PublicationPending = true; _publicRenders.Remove(post.Id); post.PublicPin = new(); } + Save(); + } + +} diff --git a/DiscordBot/Services/Recruitment/Application/ListingReader.cs b/DiscordBot/Services/Recruitment/Application/ListingReader.cs new file mode 100644 index 00000000..f5bd86d1 --- /dev/null +++ b/DiscordBot/Services/Recruitment/Application/ListingReader.cs @@ -0,0 +1,61 @@ +using System.IO; +using DiscordBot.Settings; +using DiscordBot.Utils; +using DiscordBot.Services.Recruitment.Domain.Analysis; +using DiscordBot.Services.Recruitment.Domain.Policies; +using DiscordBot.Services.Recruitment.Infrastructure.Discord; + +namespace DiscordBot.Services.Recruitment.Application; + +internal sealed class ListingReader(RecruitmentState state, RecruitmentSettings settings, IRecruitmentDiscord discord, TimeProvider time, Action save, AcknowledgementRecovery windows) +{ + private readonly RecruitmentState State = state; + private readonly RecruitmentSettings _settings = settings; + private readonly IRecruitmentDiscord _discord = discord; + private DateTimeOffset Now => time.GetUtcNow(); + private void Save() => save(); + private readonly AcknowledgementRecovery _recoverWindows = windows; + + public async Task ReadAsync(Listing post, CancellationToken ct, bool refresh = false) + { + try + { + var live = await _discord.GetThreadAsync(post.Id, ct, refresh); + if (live is not null && (live.OwnerId != post.OwnerId || live.ForumId != post.ForumId)) + throw new InvalidOperationException("Thread identity changed; staff review required."); + post.AvailabilityUnknown = false; + if (live is not null) post.Pinned = live.Pinned; + return live; + } + catch { post.AvailabilityUnknown = true; Save(); throw; } + } + + public void RecordAbsence(Listing post) + { + post.Status = ListingStatus.Deleted; post.PendingAction = null; post.UpdatedAt = Now; + post.PublicationPending = true; post.PublicSendPending = false; post.Hold = null; post.Fault = null; + post.AvailabilityUnknown = false; post.Deadline = null; _recoverWindows.Remove(post.Id); + post.LastOutcome = "Thread absent; deletion actor unknown"; post.Version++; + ListingTransitions.Author(State!, post.OwnerId, Now); + Save(); + } + + public async Task RefreshBlockersAsync(Listing candidate, CancellationToken ct) + { + // Eligibility cannot depend on which half of a bounded sweep happened to run first. + for (var count = 0; count < 10; count++) + { + var id = ListingEligibilityPolicy.Evaluate(State!, candidate, Now, _settings.CooldownDays).BlockingListingId; + if (id is null) return; + var blocker = State!.Listings[id]; + try + { + if (await ReadAsync(blocker, ct, refresh: true) is not null) return; + RecordAbsence(blocker); + } + catch (Exception ex) when (ex is not IOException && ex is not OperationCanceledException) + { blocker.Hold = "Author capacity could not be verified; existing slot retained"; Save(); return; } + } + } + +} diff --git a/DiscordBot/Services/Recruitment/Application/ListingWorkflow.cs b/DiscordBot/Services/Recruitment/Application/ListingWorkflow.cs new file mode 100644 index 00000000..cbadff18 --- /dev/null +++ b/DiscordBot/Services/Recruitment/Application/ListingWorkflow.cs @@ -0,0 +1,213 @@ +using System.IO; +using DiscordBot.Settings; +using DiscordBot.Utils; +using DiscordBot.Services.Recruitment.Domain.Analysis; +using DiscordBot.Services.Recruitment.Domain.Policies; +using DiscordBot.Services.Recruitment.Infrastructure.Discord; + +namespace DiscordBot.Services.Recruitment.Application; + +internal sealed class ListingWorkflow(RecruitmentState state, RecruitmentSettings settings, IRecruitmentDiscord discord, TimeProvider time, Action save, AcknowledgementRecovery windows, ListingReader reader, ListingPublisher publisher, ListingActionExecutor actions, ListingEvidenceCapture facts, ListingForums forums, Func evidenceCurrent, Action observeReferenceCandidate) +{ + private readonly RecruitmentState State = state; + private readonly RecruitmentSettings _settings = settings; + private readonly IRecruitmentDiscord _discord = discord; + private DateTimeOffset Now => time.GetUtcNow(); + private void Save() => save(); + private readonly AcknowledgementRecovery _recoverWindows = windows; + private readonly ListingReader _reader = reader; + private readonly ListingPublisher _publisher = publisher; + private readonly ListingActionExecutor _actions = actions; + private readonly ListingEvidenceCapture _facts = facts; + private readonly ListingForums _forums = forums; + private bool EvidenceCurrent => evidenceCurrent(); + + public Task ObserveAsync(ThreadSnapshot live, bool observedFromCreation, CancellationToken ct) + { + if (live.OwnerIsBot || live.OwnerIsStaff || !State.Forums.Values.Any(f => f.ForumId == live.ForumId)) return Task.CompletedTask; + if (State.Listings.ContainsKey(live.Id) || live.CreatedAt < State.DiscoveryFloor) return Task.CompletedTask; + var post = new Listing + { + Id = live.Id, ForumId = live.ForumId, OwnerId = live.OwnerId, Title = DiscordDisplayText.Literal(live.Title), + CreatedAt = live.CreatedAt.ToUniversalTime(), UpdatedAt = Now, Pinned = live.Pinned, + ResponseCoverageComplete = observedFromCreation, + Facts = new InitialFacts + { + AccountCreatedAt = live.AccountCreatedAt, JoinedAt = live.JoinedAt, + MemberRoleCount = live.MemberRoleCount, + TagAdvice = _forums.TagAdvice(live), + PriorAcceptedListing = State.Authors.GetValueOrDefault(live.OwnerId)?.LastAcceptedCreation is not null + } + }; + State.Listings[post.Id] = post; + _facts.RefreshFacts(post, live); + RecordCreation(post); + Save(); + return Task.CompletedTask; + } + + public async Task ProcessAsync(Listing post, CancellationToken ct) + { + if (post.Status == ListingStatus.Deleted) + { + if (post.PublicationPending || post.StaffSendPending || _publisher.AttentionDue(post)) await _publisher.PublishAsync(post, ct); + return; + } + if (post.Status == ListingStatus.Reference) + { + if (post.PendingAction is not null) await _actions.ExecuteActionAsync(post, ct); + return; + } + var live = await _reader.ReadAsync(post, ct); + if (live is null) + { + // Unknown Channel proves absence, not who deleted it or when. Never invent a timeout or cooldown. + _reader.RecordAbsence(post); + await _publisher.PublishStaffAsync(post, ct); post.PublicationPending = false; Save(); + return; + } + if (live.OwnerIsBot || live.OwnerIsStaff) + { + observeReferenceCandidate(live); + // Retire records made by older versions without editing staff content or erasing accepted history. + post.Status = ListingStatus.Reference; post.PendingAction = null; post.Deadline = null; + post.Hold = null; post.Fault = null; post.ValidCodes.Clear(); _recoverWindows.Remove(post.Id); + post.LastOutcome = "Staff or bot post; excluded from member recruitment"; post.Version++; Save(); + await _actions.BeginActionAsync(post, RecruitmentActionKind.RemoveAdvisory, "system", post.LastOutcome, "", false, ct); + return; + } + RefreshSnapshot(post, live); + if (live.Starter is null && !post.PromptDeliveredAt.HasValue && !post.AcceptedAt.HasValue) + { + post.Hold = "Starter message is not available yet"; + post.Fault = new RecruitmentFault(post.Hold, Now.AddSeconds(15)); + _discord.InvalidateThread(post.Id); Save(); return; + } + if (post.PendingAction is not null) { await _actions.ExecuteActionAsync(post, ct); return; } + if (post.Status != ListingStatus.Open) + { + if (post.StaffSendPending || post.PublicSendPending || post.PublicationPending || _publisher.AttentionDue(post)) await _publisher.PublishAsync(post, ct); + return; + } + if (await CloseIneligibleAsync(post, ct)) return; + await EnsureAcknowledgementWindowAsync(post, ct); + await AcceptAcknowledgedAsync(post, ct); + await EnforceAutomaticActionAsync(post, live, ct); + } + + private async Task EnsureAcknowledgementWindowAsync(Listing post, CancellationToken ct) + { + if (_recoverWindows.Remove(post.Id)) + { + post.PromptDeliveredAt = post.Deadline = null; + // A timeout that was never sent must not run immediately after an outage. + post.Version++; + } + var receipt = State!.Forums[_forums.Purpose(post.ForumId)]; + if (!post.AcceptedAt.HasValue && !post.ValidCodes.Contains(receipt.VisibleCode)) post.ValidCodes.Add(receipt.VisibleCode); + Save(); + await _publisher.PublishAsync(post, ct); + if (!post.PromptDeliveredAt.HasValue && !post.AcceptedAt.HasValue) + { + post.PromptDeliveredAt = Now; + post.Deadline = Now.AddMinutes(_settings.AcknowledgementMinutes); + post.Version++; Save(); + await _publisher.PublishAsync(post, ct); + } + await _publisher.PublishAcknowledgementNoticeAsync(post, ct); + } + + private async Task AcceptAcknowledgedAsync(Listing post, CancellationToken ct) + { + if (EvidenceCurrent && post.AcknowledgedAt.HasValue && !post.AcceptedAt.HasValue) + await _reader.RefreshBlockersAsync(post, ct); + if (EvidenceCurrent && ListingTransitions.TryAccept(State, post, Now, _settings.CooldownDays)) + { Save(); await _publisher.PublishAsync(post, ct); } + } + + private async Task EnforceAutomaticActionAsync(Listing post, ThreadSnapshot live, CancellationToken ct) + { + if (!EvidenceCurrent || post.Exempt || post.Pinned) return; + if (live.Tags.Contains(_forums.ClosingTag(post))) + await _actions.BeginActionAsync(post, RecruitmentActionKind.Close, "system", "Closed tag applied", RecruitmentConditions.ClosedTag, true, ct); + else if (!post.AcceptedAt.HasValue && post.Deadline <= Now) + { + if (!post.AcknowledgedAt.HasValue) + await _actions.BeginActionAsync(post, RecruitmentActionKind.Delete, "system", "Guidelines acknowledgement timed out", RecruitmentConditions.Timeout, true, ct); + else + await _actions.BeginActionAsync(post, RecruitmentActionKind.Close, "system", "Acknowledged but still ineligible at deadline", RecruitmentConditions.Ineligible, true, ct); + } + } + + private void RefreshSnapshot(Listing post, ThreadSnapshot live) + { + post.Title = DiscordDisplayText.Literal(live.Title); + _facts.RefreshFacts(post, live); + post.Facts.TagAdvice = _forums.TagAdvice(live); + if (!post.PublicSendPending) + { + post.PublicRestoreArchive = post.PublicPin.RestoreArchive || live.Archived && post.AcceptedAt.HasValue; + post.PublicRestoreLocked = post.PublicRestoreArchive && (post.PublicPin.RestoreArchive ? post.PublicPin.RestoreLocked : live.Locked); + } + post.Hold = null; + } + + private async Task CloseIneligibleAsync(Listing post, CancellationToken ct) + { + if (EvidenceCurrent && !post.AcceptedAt.HasValue && !post.Exempt && !post.Pinned) + { + await _reader.RefreshBlockersAsync(post, ct); + var eligibility = ListingEligibilityPolicy.Evaluate(State!, post, Now, _settings.CooldownDays); + if (!eligibility.Allowed) + { + await _actions.BeginActionAsync(post, RecruitmentActionKind.Close, "system", ListingEligibilityText.EligibilityReason(eligibility), RecruitmentConditions.Ineligible, true, ct); + return true; + } + } + return false; + } + + public async Task ProcessSafelyAsync(Listing post, CancellationToken ct, bool verifyHeld = false) + { + if (post.Fault is { } fault && !fault.IsDue(Now)) + { + if (!verifyHeld || fault.RetryAt is not null) return; + // A permanent write hold must not hide a deletion during downtime. Probe once per bounded audit. + try + { + var live = await _reader.ReadAsync(post, ct, refresh: true); + if (live is null) _reader.RecordAbsence(post); + else if (!live.OwnerIsBot && !live.OwnerIsStaff) return; + } + catch (Exception ex) when (ex is not System.IO.IOException && ex is not OperationCanceledException) { return; } + } + var previousFault = post.Fault; + post.Fault = null; + try + { + await ProcessAsync(post, ct); + await _publisher.PinResponseAsync(post, ct); + if (previousFault is not null) Save(); + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not System.IO.IOException) + { + _discord.InvalidateThread(post.Id); + post.Fault = RecruitmentFaultClassifier.From(ex, Now, previousFault); + post.Hold = post.Fault.Message; + if (!post.AcceptedAt.HasValue && post.Status == ListingStatus.Open) _recoverWindows.Add(post.Id); + Save(); + } + } + + public void RecordCreation(Listing post) + { + var history = ListingTransitions.Author(State!, post.OwnerId, Now); + if (post.Pinned || post.CreatedAt <= Now.AddMinutes(-_settings.AttemptWindowMinutes)) return; + var decision = AttemptPolicy.Record(history.RecentCreations, history.AttemptPausedUntil, post.CreatedAt, Now, + _settings.AttemptWindowMinutes, _settings.AttemptLimit, _settings.AttemptPauseMinutes); + history.RecentCreations = decision.Creations.ToList(); + post.AttemptPausedUntil = history.AttemptPausedUntil = decision.PausedUntil; + if (decision.PauseStarted) _publisher.QueueAttention(post, "Repeated creations triggered a posting pause"); + } + +} diff --git a/DiscordBot/Services/Recruitment/Application/RecruitmentAcknowledgement.cs b/DiscordBot/Services/Recruitment/Application/RecruitmentAcknowledgement.cs deleted file mode 100644 index 49bed15b..00000000 --- a/DiscordBot/Services/Recruitment/Application/RecruitmentAcknowledgement.cs +++ /dev/null @@ -1,43 +0,0 @@ -using DiscordBot.Services.Recruitment.Infrastructure.Discord; -using System.IO; - -namespace DiscordBot.Services.Recruitment; - -public sealed partial class RecruitmentEngine -{ - private DateTimeOffset? AcknowledgementNoticeDue(Listing post) => - post.Status == ListingStatus.Open && !post.AcceptedAt.HasValue && !post.AcknowledgedAt.HasValue && - post.PublicMessageId is not null && post.Deadline > Now && - (post.AcknowledgementNotice.MessageId is null || post.AcknowledgementNotice.SendPending) - ? post.AcknowledgementNotice.Fault is { } fault ? fault.RetryAt : Now : null; - - private async Task PublishAcknowledgementNoticeAsync(Listing post, CancellationToken ct) - { - if (AcknowledgementNoticeDue(post) is not { } due || due > Now) return; - var notice = post.AcknowledgementNotice; - var marker = $"recruitment:{post.Id}:acknowledgement"; - // The card owns the current deadline and may be restored with a new message ID/window. - var message = new RecruitmentMessage($"<@{post.OwnerId}>, please read this forum's **Guidelines**, then use **Acknowledge** on the " + - $"observations card in this thread to enter its code. Check the card for your deadline.\n||{marker}||", - marker, false, MentionUserId: ulong.Parse(post.OwnerId)); - var recovering = notice.SendPending; - try - { - var delivery = await _discord.UpsertAsync(post.Id, notice.MessageId, recovering, notice.StartedAt ?? Now, - message, () => - { - if (!recovering) notice.StartedAt = Now; - notice.SendPending = true; Save(); - }, ct); - notice.MessageId = delivery.Id; notice.SendPending = false; notice.Fault = null; - Save(); - } - catch (Exception ex) when (ex is not IOException && ex is not OperationCanceledException) - { - // The public controls remain usable; an optional notification cannot reset the deadline - // or prevent a timely acknowledgement. Retry/recover independently without a second ping. - notice.Fault = RecruitmentFaultClassifier.From(ex, Now, notice.Fault); - Save(); - } - } -} diff --git a/DiscordBot/Services/Recruitment/Application/RecruitmentAttempts.cs b/DiscordBot/Services/Recruitment/Application/RecruitmentAttempts.cs index 1c179429..dc2bf097 100644 --- a/DiscordBot/Services/Recruitment/Application/RecruitmentAttempts.cs +++ b/DiscordBot/Services/Recruitment/Application/RecruitmentAttempts.cs @@ -22,29 +22,11 @@ public async Task ThreadCreatedAsync(ThreadCreation creation, CancellationToken PublicationPending = true }; State.Listings[post.Id] = post; - RecordCreation(post); Save(); - await ProcessSafelyAsync(post, ct); + _workflow!.RecordCreation(post); Save(); + await _workflow!.ProcessSafelyAsync(post, ct); } // Called once per newly retained thread identity, regardless of later deletion or acknowledgement. - private void RecordCreation(Listing post) - { - var history = ListingTransitions.Author(State!, post.OwnerId, Now); - if (post.Pinned || post.CreatedAt <= Now.AddMinutes(-_settings.AttemptWindowMinutes)) return; - var decision = AttemptPolicy.Record(history.RecentCreations, history.AttemptPausedUntil, post.CreatedAt, Now, - _settings.AttemptWindowMinutes, _settings.AttemptLimit, _settings.AttemptPauseMinutes); - history.RecentCreations = decision.Creations.ToList(); - post.AttemptPausedUntil = history.AttemptPausedUntil = decision.PausedUntil; - if (decision.PauseStarted) QueueAttention(post, "Repeated creations triggered a posting pause"); - } - - private static string EligibilityReason(Eligibility eligibility) - { - if (eligibility.AttemptPausedUntil is { } pause) return $"Posting attempts paused until {pause:yyyy-MM-dd HH:mm} UTC. Check /recruitment eligibility."; - if (eligibility.BlockingListingId is { } id) return $"Existing listing {id} occupies your slot. Update or bump that thread; check /recruitment eligibility."; - return $"Accepted-listing cooldown ends {eligibility.NextAllowedAt:yyyy-MM-dd HH:mm} UTC. Check /recruitment eligibility."; - } - public async Task EligibilityAsync(string guild, string owner, CancellationToken ct) { if (State is null || guild != State.GuildId || !await _discord.IsMemberAsync(guild, owner, ct)) diff --git a/DiscordBot/Services/Recruitment/Application/RecruitmentEngine.cs b/DiscordBot/Services/Recruitment/Application/RecruitmentEngine.cs index 4b35bd9b..9a75f5c3 100644 --- a/DiscordBot/Services/Recruitment/Application/RecruitmentEngine.cs +++ b/DiscordBot/Services/Recruitment/Application/RecruitmentEngine.cs @@ -1,3 +1,4 @@ +using DiscordBot.Services.Recruitment.Application; using DiscordBot.Services.Recruitment.Infrastructure.Discord; using DiscordBot.Utils; using DiscordBot.Services.Recruitment.Domain.Policies; @@ -19,8 +20,13 @@ public sealed partial class RecruitmentEngine : IDisposable private RecruitmentSetup? _setup; private bool _ready; private bool _inventoryComplete; - private readonly HashSet _recoverWindows = new(); + private readonly AcknowledgementRecovery _recoverWindows = new(); private readonly Dictionary _confirmations = new(); + private ListingWorkflow? _workflow; + private ListingActionExecutor? _actions; + private ListingPublisher? _publisher; + private ListingReader? _reader; + private ListingForums? _listingForums; public string Status { get; private set; } = "Not started"; public RecruitmentState? State => _store?.State; private DateTimeOffset Now => _time.GetUtcNow(); @@ -46,6 +52,14 @@ public async Task StartAsync(CancellationToken ct) _assets = new RecruitmentAssets(_bot.AssetsRootPath, _settings); _store = new RecruitmentStateStore(_bot.ServerRootPath, _bot.GuildId.ToString(), _settings.Forums.Mapping().ToDictionary(x => x.Key, x => x.Value.ToString()), Now); + _listingForums = new ListingForums(State!, _assets); + _publisher = new ListingPublisher(State!, _settings, _discord, _time, Save, GuardFeedWrite); + _reader = new ListingReader(State!, _settings, _discord, _time, Save, _recoverWindows); + _actions = new ListingActionExecutor(State!, _settings, _discord, _time, Save, _recoverWindows, _reader, _publisher, _listingForums, + () => EvidenceCurrent, _pendingThreadWork); + var facts = new ListingEvidenceCapture(State!, _time, _publisher.QueueAttention); + _workflow = new ListingWorkflow(State!, _settings, _discord, _time, Save, _recoverWindows, _reader, _publisher, _actions, + facts, _listingForums, () => EvidenceCurrent, ObserveReferenceCandidate); _setup = new RecruitmentSetup(_store, _assets, _settings, _discord); _setup.Progress += () => Changed?.Invoke(); MarkEvidenceGap(); @@ -78,7 +92,7 @@ public async Task SweepAsync(CancellationToken ct, bool retryHeld = true) var auditDue = _nextAudit <= Now; var held = !retryHeld && State.RecoveryFault is { } recovery && !recovery.IsDue(Now); _nextAudit = Now.AddMinutes(15 + Random.Shared.NextDouble() * 3); - _publicRenders.Clear(); _staffRenders.Clear(); + _publisher?.ClearRenders(); _discord.InvalidateCache(); try { @@ -111,14 +125,14 @@ public async Task SweepAsync(CancellationToken ct, bool retryHeld = true) foreach (var notice in State.Listings.Values.SelectMany(p => p.Attention).Where(n => n.MessageId is null || n.SendPending)) notice.Fault = null; var cursor = ulong.TryParse(State.SweepAfterId, out var savedCursor) ? savedCursor : 0; var batch = State.Listings.Values - .Where(p => p.Status == ListingStatus.Open || p.PendingAction is not null || p.StaffSendPending || p.PublicSendPending || p.PublicationPending || AttentionDue(p) || PinDue(p)) + .Where(p => p.Status == ListingStatus.Open || p.PendingAction is not null || p.StaffSendPending || p.PublicSendPending || p.PublicationPending || _publisher!.AttentionDue(p) || _publisher!.PinDue(p)) .OrderBy(p => ulong.Parse(p.Id) <= cursor).ThenBy(p => ulong.Parse(p.Id)).Take(10).ToArray(); foreach (var post in batch) { ct.ThrowIfCancellationRequested(); if (retryHeld) { post.Fault = null; post.PublicPin.Fault = null; } _discord.InvalidateFacts(post.Id); - await ProcessSafelyAsync(post, ct, verifyHeld: true); + await _workflow!.ProcessSafelyAsync(post, ct, verifyHeld: true); } if (batch.Length > 0) State.SweepAfterId = batch[^1].Id; await ReconcileReferencesAsync(ct, retryHeld); @@ -173,218 +187,14 @@ private async Task DiscoverAsync(CancellationToken ct) public async Task ReconcileThreadAsync(string thread, CancellationToken ct) { if (!_ready || State?.Listings.GetValueOrDefault(thread) is not { } post) return; - await ProcessSafelyAsync(post, ct); + await _workflow!.ProcessSafelyAsync(post, ct); foreach (var waiting in State.Listings.Values.Where(p => p.Id != post.Id && p.OwnerId == post.OwnerId && p.Status == ListingStatus.Open && !p.AcceptedAt.HasValue).OrderBy(p => p.CreatedAt).ToArray()) - await ProcessSafelyAsync(waiting, ct); + await _workflow!.ProcessSafelyAsync(waiting, ct); } - public Task ObserveAsync(ThreadSnapshot live, bool observedFromCreation, CancellationToken ct) - { - if (State is null || !_ready || live.OwnerIsBot || live.OwnerIsStaff || !State.Forums.Values.Any(f => f.ForumId == live.ForumId)) return Task.CompletedTask; - if (State.Listings.ContainsKey(live.Id) || live.CreatedAt < State.DiscoveryFloor) return Task.CompletedTask; - var purpose = Purpose(live.ForumId); - var post = new Listing - { - Id = live.Id, ForumId = live.ForumId, OwnerId = live.OwnerId, Title = DiscordDisplayText.Literal(live.Title), - CreatedAt = live.CreatedAt.ToUniversalTime(), UpdatedAt = Now, Pinned = live.Pinned, - ResponseCoverageComplete = observedFromCreation, - Facts = new InitialFacts - { - AccountCreatedAt = live.AccountCreatedAt, JoinedAt = live.JoinedAt, - MemberRoleCount = live.MemberRoleCount, - TagAdvice = _assets!.TagAdvice(purpose, live.Tags, State.Forums[purpose]), - PriorAcceptedListing = State.Authors.GetValueOrDefault(live.OwnerId)?.LastAcceptedCreation is not null - } - }; - State.Listings[post.Id] = post; - RefreshFacts(post, live); - RecordCreation(post); - Save(); - return Task.CompletedTask; - } - - private async Task ProcessAsync(Listing post, CancellationToken ct) - { - if (post.Status == ListingStatus.Deleted) - { - if (post.PublicationPending || post.StaffSendPending || AttentionDue(post)) await PublishAsync(post, ct); - return; - } - if (post.Status == ListingStatus.Reference) - { - if (post.PendingAction is not null) await ExecuteActionAsync(post, ct); - return; - } - var live = await ReadAsync(post, ct); - if (live is null) - { - // Unknown Channel proves absence, not who deleted it or when. Never invent a timeout or cooldown. - RecordAbsence(post); - await PublishStaffAsync(post, ct); post.PublicationPending = false; Save(); - return; - } - if (live.OwnerIsBot || live.OwnerIsStaff) - { - ObserveReferenceCandidate(live); - // Retire records made by older versions without editing staff content or erasing accepted history. - post.Status = ListingStatus.Reference; post.PendingAction = null; post.Deadline = null; - post.Hold = null; post.Fault = null; post.ValidCodes.Clear(); _recoverWindows.Remove(post.Id); - post.LastOutcome = "Staff or bot post; excluded from member recruitment"; post.Version++; Save(); - await BeginActionAsync(post, RecruitmentActionKind.RemoveAdvisory, "system", post.LastOutcome, "", false, ct); - return; - } - post.Title = DiscordDisplayText.Literal(live.Title); - RefreshFacts(post, live); - post.Facts.TagAdvice = _assets!.TagAdvice(Purpose(post.ForumId), live.Tags, State!.Forums[Purpose(post.ForumId)]); - if (!post.PublicSendPending) - { - post.PublicRestoreArchive = post.PublicPin.RestoreArchive || live.Archived && post.AcceptedAt.HasValue; - post.PublicRestoreLocked = post.PublicRestoreArchive && (post.PublicPin.RestoreArchive ? post.PublicPin.RestoreLocked : live.Locked); - } - post.Hold = null; - if (live.Starter is null && !post.PromptDeliveredAt.HasValue && !post.AcceptedAt.HasValue) - { - post.Hold = "Starter message is not available yet"; - post.Fault = new RecruitmentFault(post.Hold, Now.AddSeconds(15)); - _discord.InvalidateThread(post.Id); Save(); return; - } - if (post.PendingAction is not null) { await ExecuteActionAsync(post, ct); return; } - if (post.Status != ListingStatus.Open) - { - if (post.StaffSendPending || post.PublicSendPending || post.PublicationPending || AttentionDue(post)) await PublishAsync(post, ct); - return; - } - if (EvidenceCurrent && !post.AcceptedAt.HasValue && !post.Exempt && !post.Pinned) - { - await RefreshBlockersAsync(post, ct); - var eligibility = ListingEligibilityPolicy.Evaluate(State!, post, Now, _settings.CooldownDays); - if (!eligibility.Allowed) - { - await BeginActionAsync(post, RecruitmentActionKind.Close, "system", EligibilityReason(eligibility), RecruitmentConditions.Ineligible, true, ct); - return; - } - } - if (_recoverWindows.Remove(post.Id)) - { - post.PromptDeliveredAt = post.Deadline = null; - // A timeout that was never sent must not run immediately after an outage. - post.Version++; - } - var receipt = State!.Forums[Purpose(post.ForumId)]; - if (!post.AcceptedAt.HasValue && !post.ValidCodes.Contains(receipt.VisibleCode)) post.ValidCodes.Add(receipt.VisibleCode); - Save(); - await PublishAsync(post, ct); - if (!post.PromptDeliveredAt.HasValue && !post.AcceptedAt.HasValue) - { - post.PromptDeliveredAt = Now; - post.Deadline = Now.AddMinutes(_settings.AcknowledgementMinutes); - post.Version++; Save(); - await PublishAsync(post, ct); - } - await PublishAcknowledgementNoticeAsync(post, ct); - if (EvidenceCurrent && post.AcknowledgedAt.HasValue && !post.AcceptedAt.HasValue) - await RefreshBlockersAsync(post, ct); - if (EvidenceCurrent && ListingTransitions.TryAccept(State, post, Now, _settings.CooldownDays)) - { Save(); await PublishAsync(post, ct); } - if (!EvidenceCurrent || post.Exempt || post.Pinned) return; - if (live.Tags.Contains(ClosingTag(post))) - await BeginActionAsync(post, RecruitmentActionKind.Close, "system", "Closed tag applied", RecruitmentConditions.ClosedTag, true, ct); - else if (!post.AcceptedAt.HasValue && post.Deadline <= Now) - { - if (!post.AcknowledgedAt.HasValue) - await BeginActionAsync(post, RecruitmentActionKind.Delete, "system", "Guidelines acknowledgement timed out", RecruitmentConditions.Timeout, true, ct); - else - await BeginActionAsync(post, RecruitmentActionKind.Close, "system", "Acknowledged but still ineligible at deadline", RecruitmentConditions.Ineligible, true, ct); - } - } - - private void RecordAbsence(Listing post) - { - post.Status = ListingStatus.Deleted; post.PendingAction = null; post.UpdatedAt = Now; - post.PublicationPending = true; post.PublicSendPending = false; post.Hold = null; post.Fault = null; - post.AvailabilityUnknown = false; post.Deadline = null; _recoverWindows.Remove(post.Id); - post.LastOutcome = "Thread absent; deletion actor unknown"; post.Version++; - ListingTransitions.Author(State!, post.OwnerId, Now); - Save(); - } - - private async Task RefreshBlockersAsync(Listing candidate, CancellationToken ct) - { - // Eligibility cannot depend on which half of a bounded sweep happened to run first. - for (var count = 0; count < 10; count++) - { - var id = ListingEligibilityPolicy.Evaluate(State!, candidate, Now, _settings.CooldownDays).BlockingListingId; - if (id is null) return; - var blocker = State!.Listings[id]; - try - { - if (await ReadAsync(blocker, ct, refresh: true) is not null) return; - RecordAbsence(blocker); - } - catch (Exception ex) when (ex is not IOException && ex is not OperationCanceledException) - { blocker.Hold = "Author capacity could not be verified; existing slot retained"; Save(); return; } - } - } - - private async Task ReadAsync(Listing post, CancellationToken ct, bool refresh = false) - { - try - { - var live = await _discord.GetThreadAsync(post.Id, ct, refresh); - if (live is not null && (live.OwnerId != post.OwnerId || live.ForumId != post.ForumId)) - throw new InvalidOperationException("Thread identity changed; staff review required."); - post.AvailabilityUnknown = false; - if (live is not null) post.Pinned = live.Pinned; - return live; - } - catch { post.AvailabilityUnknown = true; Save(); throw; } - } - - private async Task PublishStaffAsync(Listing post, CancellationToken ct) - { - await PublishAttentionAsync(post, ct); - var message = RecruitmentPresentation.Staff(State!, post, _settings); - if (!post.StaffSendPending && post.StaffMessageId is not null && _staffRenders.GetValueOrDefault(post.Id) == message.Content) return; - var recovering = post.StaffSendPending; - var delivery = await _discord.UpsertAsync(_settings.FeedChannelId.ToString(), post.StaffMessageId, recovering, - post.StaffSendStartedAt ?? post.CreatedAt, message, () => - { - GuardFeedWrite(); - if (!recovering) post.StaffSendStartedAt = Now; - post.StaffSendPending = true; Save(); - }, ct); - var changed = post.StaffMessageId != delivery.Id || post.StaffSendPending; - post.StaffMessageId = delivery.Id; post.StaffSendPending = false; - _staffRenders[post.Id] = message.Content; - if (changed) Save(); - } - - private async Task PublishAsync(Listing post, CancellationToken ct) - { - await PublishStaffAsync(post, ct); - if (post.Status is ListingStatus.Deleted or ListingStatus.Reference) - { post.PublicationPending = false; post.PublicSendPending = false; Save(); return; } - var message = RecruitmentPresentation.PublicV2(State!, post, _settings, Now); - var renderKey = message.RenderKey + "\nPortfolio registry " + State!.PortfolioSites.Version; - if (!post.PublicSendPending && !post.PublicationPending && post.PublicMessageId is not null && _publicRenders.GetValueOrDefault(post.Id) == renderKey) return; - var recovering = post.PublicSendPending; - var delivery = await _discord.UpsertAsync(post.Id, post.PublicMessageId, recovering, post.PublicSendStartedAt ?? post.CreatedAt, - message with - { RestoreArchive = post.PublicRestoreArchive || post.Status == ListingStatus.Closed, RestoreLocked = post.PublicRestoreLocked || post.Status == ListingStatus.Closed }, () => - { - if (!recovering) post.PublicSendStartedAt = Now; - post.PublicSendPending = true; Save(); - }, ct); - var changed = post.PublicMessageId != delivery.Id || post.PublicSendPending || post.PublicationPending || - post.PublicMediaAttachmentId != delivery.MediaAttachmentId || post.PublicMediaFileName != delivery.MediaFileName; - post.PublicMediaAttachmentId = delivery.MediaAttachmentId; post.PublicMediaFileName = delivery.MediaFileName; - post.PublicMessageId = delivery.Id; post.PublicSendPending = false; post.PublicationPending = false; - _publicRenders[post.Id] = renderKey; - if (delivery.NewlySent && post.Deadline.HasValue && !post.AcceptedAt.HasValue) - { post.PromptDeliveredAt = null; post.Deadline = null; post.Version++; changed = true; } - if (changed) Save(); - } + public Task ObserveAsync(ThreadSnapshot live, bool observedFromCreation, CancellationToken ct) => + !_ready || _workflow is null ? Task.CompletedTask : _workflow.ObserveAsync(live, observedFromCreation, ct); public async Task AcknowledgeAsync(string guild, string thread, string owner, string code, CancellationToken ct, DateTimeOffset? receivedAt = null) { @@ -408,9 +218,9 @@ public async Task AcknowledgeAsync(string guild, string thread, string o Save(); _discord.InvalidateThread(post.Id); post.Fault = null; - await ProcessAsync(post, ct); + await _workflow!.ProcessAsync(post, ct); if (post.PendingAction is null && post.Status == ListingStatus.Open && !post.AcceptedAt.HasValue) - await ProcessAsync(post, ct); // A recovered timeout intent may have just been cancelled. + await _workflow!.ProcessAsync(post, ct); // A recovered timeout intent may have just been cancelled. if (post.Status != ListingStatus.Open) return "This listing is no longer open. Use your listing controls to check its status."; return post.AcceptedAt.HasValue ? "Guidelines acknowledged. Your listing is accepted." : !EvidenceCurrent ? "Guidelines acknowledged. Acceptance is waiting for inventory recovery." @@ -421,7 +231,7 @@ public async Task CloseOwnerAsync(string guild, string thread, string ow { var post = Owner(guild, thread, owner); if (post is null || post.Status != ListingStatus.Open) return "This open listing does not belong to you or recruitment is unavailable."; - await BeginActionAsync(post, RecruitmentActionKind.Close, owner, "Owner closed listing", "", false, ct); + await _actions!.BeginActionAsync(post, RecruitmentActionKind.Close, owner, "Owner closed listing", "", false, ct); return "Listing closed. Its accepted creation cooldown is unchanged."; } @@ -446,110 +256,13 @@ public async Task ConfirmRemovalAsync(string guild, string thread, strin var post = Owner(guild, thread, owner); if (post is null || confirm.Expires <= Now || confirm.Version != post.Version || post.Status is ListingStatus.Deleted or ListingStatus.Reference) return "This confirmation expired or the listing changed. Request removal again."; - await BeginActionAsync(post, RecruitmentActionKind.Delete, owner, "Owner confirmed removal", "", false, ct); + await _actions!.BeginActionAsync(post, RecruitmentActionKind.Delete, owner, "Owner confirmed removal", "", false, ct); return "Post removed. Any accepted creation cooldown is unchanged."; } private Listing? Owner(string guild, string thread, string owner) => _ready && State?.GuildId == guild && State.Listings.TryGetValue(thread, out var post) && post.OwnerId == owner ? post : null; - private async Task BeginActionAsync(Listing post, RecruitmentActionKind kind, string actor, string reason, string condition, bool automatic, CancellationToken ct, bool requiresModerator = false) - { - if (post.PendingAction is not null) throw new InvalidOperationException("An earlier action still needs reconciliation."); - post.PendingAction = new RecruitmentAction { Kind = kind, ActorId = actor, Reason = reason, Condition = condition, - Automatic = automatic, RequiresModerator = requiresModerator, GuidelineTimeout = condition == RecruitmentConditions.Timeout, CreatedAt = Now }; - if (!automatic) Audit(post, actor, reason); - Save(); - await ExecuteActionAsync(post, ct); - } - - private async Task ExecuteActionAsync(Listing post, CancellationToken ct) - { - var action = post.PendingAction!; - if (action.Automatic && action.Condition == RecruitmentConditions.LegacyUnanswered) - { - // Cancel unsent legacy expiry; preserve a closure already applied before the upgrade. - var previous = action.CallStarted ? await ReadAsync(post, ct, refresh: true) : null; - if (action.CallStarted && previous is null) { RecordAbsence(post); return; } - var alreadyClosed = action.CallStarted && previous is { Locked: true, Archived: true }; - if (alreadyClosed) post.Status = ListingStatus.Closed; - post.PendingAction = null; post.PublicationPending = true; - post.LastOutcome = alreadyClosed ? "Closure applied before upgrade confirmed" : "Automatic expiry cancelled; listing remains open"; - Save(); return; - } - if (action.RequiresModerator && !await _discord.IsModeratorAsync(State!.GuildId, action.ActorId, ct)) - { - post.PendingAction = null; post.LastOutcome = "Staff action cancelled: moderator access no longer present"; - post.PublicationPending = true; Save(); await PublishStaffAsync(post, ct); return; - } - // Intent and staff evidence must exist before removing or changing member content. - await PublishStaffAsync(post, ct); - var live = await ReadAsync(post, ct, refresh: true); - if (live is null) - { - RecordAbsence(post); - await PublishStaffAsync(post, ct); post.PublicationPending = false; Save(); return; - } - if (action.Kind == RecruitmentActionKind.RemoveAdvisory) - { - if (post.PublicMessageId is null && post.PublicSendPending) - { - // Resolve a possibly delivered provisional advisory before removing it. - var recovered = await _discord.UpsertAsync(post.Id, null, true, post.PublicSendStartedAt ?? post.CreatedAt, - RecruitmentPresentation.PublicV2(State!, post, _settings, Now), Save, ct); - post.PublicMessageId = recovered.Id; post.PublicSendPending = false; Save(); - } - if (post.PublicMessageId is not null) await _discord.DeleteAdvisoryAsync(post.Id, post.PublicMessageId, ct, $"recruitment:{post.Id}:public"); - post.PublicMessageId = null; post.PublicMediaAttachmentId = null; post.PublicMediaFileName = null; post.PendingAction = null; Save(); return; - } - if (action.CallStarted && action.Kind == RecruitmentActionKind.Close && live.Archived && live.Locked) - { - post.Status = ListingStatus.Closed; post.PendingAction = null; - post.LastOutcome = "Closure confirmed after interrupted action"; post.UpdatedAt = Now; post.Version++; - post.PublicationPending = true; Save(); await PublishAsync(post, ct); return; - } - if (action.Automatic && (!EvidenceCurrent || post.Exempt || post.Pinned || post.Status != ListingStatus.Open || - (action.Condition == RecruitmentConditions.Timeout && (post.AcknowledgedAt.HasValue || post.AcceptedAt.HasValue || post.Deadline > Now || _recoverWindows.Contains(post.Id))) || - (action.Condition == RecruitmentConditions.Ineligible && (post.AcceptedAt.HasValue || ListingEligibilityPolicy.Evaluate(State!, post, Now, _settings.CooldownDays).Allowed)) || - (action.Condition == RecruitmentConditions.ClosedTag && !live.Tags.Contains(ClosingTag(post))))) - { post.PendingAction = null; Save(); return; } - if (action.Kind == RecruitmentActionKind.Reopen && State!.Listings.Values.Any(p => p.OwnerId == post.OwnerId && p.Id != post.Id && p.Status == ListingStatus.Open)) - throw new InvalidOperationException("Another listing occupies this author's slot."); - // Gateway facts and timely owner interactions already queued must be applied before an automatic action. - if (action.Automatic && (!EvidenceCurrent || _pendingThreadWork(post.Id))) - { post.PendingAction = null; Save(); return; } - // The pending action is already visible to staff. Do not append an automatic-action audit - // for a request cancelled by newer evidence before it reaches the write boundary. - if (action.Automatic && !action.CallStarted) Audit(post, action.ActorId, action.Reason); - action.CallStarted = true; Save(); - switch (action.Kind) - { - case RecruitmentActionKind.Delete: - await _discord.DeleteAsync(live, action.Reason, action.Automatic, ct, () => !action.Automatic || EvidenceCurrent && !_pendingThreadWork(post.Id)); - post.Status = ListingStatus.Deleted; - if (action.GuidelineTimeout) - { - var author = ListingTransitions.Author(State!, post.OwnerId, Now); - author.ConsecutiveTimeouts++; - if (author.ConsecutiveTimeouts >= 3 && !author.TimeoutAlerted) - { post.TimeoutAlertCount = author.ConsecutiveTimeouts; author.TimeoutAlerted = true; QueueAttention(post, "Three consecutive confirmed Guidelines timeouts"); } - } - break; - case RecruitmentActionKind.Close: - await _discord.CloseAsync(live, ClosingTag(post), action.Automatic, ct, () => !action.Automatic || EvidenceCurrent && !_pendingThreadWork(post.Id)); - post.Status = ListingStatus.Closed; - break; - case RecruitmentActionKind.Reopen: - await _discord.ReopenAsync(live, ClosingTag(post), ct); - post.Status = ListingStatus.Open; - if (!post.AcceptedAt.HasValue) _recoverWindows.Add(post.Id); - break; - } - ListingTransitions.Author(State!, post.OwnerId, Now); - post.LastOutcome = action.Reason; post.PendingAction = null; post.UpdatedAt = Now; post.Version++; post.PublicationPending = true; Save(); - await PublishAsync(post, ct); - } - public async Task StaffAsync(string guild, string actor, string operation, string target, string reason, CancellationToken ct) { if (guild != _bot.GuildId.ToString() || !await _discord.IsModeratorAsync(guild, actor, ct)) return "Moderator access is required in the configured server."; @@ -599,12 +312,12 @@ public async Task StaffAsync(string guild, string actor, string operatio // Audit on every retained listing for this author; waiver never changes occupied capacity. var posts = State.Listings.Values.Where(p => p.OwnerId == target).ToArray(); if (posts.Length == 0) return "No listing audit record remains; no active cooldown should need a waiver."; - foreach (var p in posts) { Audit(p, actor, "Waive cooldown: " + reason); Save(); await PublishStaffAsync(p, ct); } + foreach (var p in posts) { _actions!.Audit(p, actor, "Waive cooldown: " + reason); Save(); await _publisher!.PublishStaffAsync(p, ct); } author.LastAcceptedCreation = null; author.UpdatedAt = Now; Save(); return "Creation cooldown waived. Existing open listings still occupy the slot."; } if (!State.Listings.TryGetValue(target, out var post)) return "Managed thread was not found. Reconcile first."; - Audit(post, actor, operation + ": " + reason); Save(); await PublishStaffAsync(post, ct); + _actions!.Audit(post, actor, operation + ": " + reason); Save(); await _publisher!.PublishStaffAsync(post, ct); switch (operation) { case "exempt": post.Exempt = true; break; @@ -630,18 +343,18 @@ public async Task StaffAsync(string guild, string actor, string operatio if (post.Status == ListingStatus.Deleted) return "A deleted thread cannot become a reference post."; post.Status = ListingStatus.Reference; post.PendingAction = null; post.Deadline = null; post.ValidCodes.Clear(); post.LastOutcome = "Moderator-designated reference post"; post.Version++; Save(); - await BeginActionAsync(post, RecruitmentActionKind.RemoveAdvisory, actor, reason, "", false, ct, requiresModerator: true); + await _actions!.BeginActionAsync(post, RecruitmentActionKind.RemoveAdvisory, actor, reason, "", false, ct, requiresModerator: true); return "Reference post designated; bot advisory removed. Any accepted cooldown is preserved."; case "close": if (post.Status != ListingStatus.Open) return "Only an open listing can be closed."; - await BeginActionAsync(post, RecruitmentActionKind.Close, actor, reason, "", false, ct, requiresModerator: true); return post.Status == ListingStatus.Closed ? "Listing closed." : "Closure was cancelled or is awaiting reconciliation."; + await _actions!.BeginActionAsync(post, RecruitmentActionKind.Close, actor, reason, "", false, ct, requiresModerator: true); return post.Status == ListingStatus.Closed ? "Listing closed." : "Closure was cancelled or is awaiting reconciliation."; case "reopen": if (post.Status != ListingStatus.Closed) return "Only a closed listing can be reopened."; await DiscoverAsync(ct); if (!_inventoryComplete) return "Inventory is incomplete; reopening is held."; if (State.Listings.Values.Any(p => p.OwnerId == post.OwnerId && p.Id != post.Id && p.Status == ListingStatus.Open)) return "Another listing occupies this author's slot."; - await BeginActionAsync(post, RecruitmentActionKind.Reopen, actor, reason, "", false, ct, requiresModerator: true); return post.Status == ListingStatus.Open + await _actions!.BeginActionAsync(post, RecruitmentActionKind.Reopen, actor, reason, "", false, ct, requiresModerator: true); return post.Status == ListingStatus.Open ? "Listing reopened. Update this thread to preserve its conversation history." : "Reopening was cancelled or is awaiting reconciliation."; case "review-absent": if (!post.AvailabilityUnknown) return "Availability is known; use close or reconcile instead."; @@ -652,23 +365,11 @@ public async Task StaffAsync(string guild, string actor, string operatio default: return "Unknown recruitment operation."; } post.Version++; post.UpdatedAt = Now; Save(); - if (operation == "review-absent") await PublishStaffAsync(post, ct); - else await PublishAsync(post, ct); + if (operation == "review-absent") await _publisher!.PublishStaffAsync(post, ct); + else await _publisher!.PublishAsync(post, ct); return "Recruitment record updated."; } - private void Audit(Listing post, string actor, string reason) - { - post.LastActor = actor; post.LastReason = reason; - post.Audit.Add(new RecruitmentAudit(Now, actor, reason)); - if (post.Audit.Count > 30) post.Audit.RemoveRange(0, post.Audit.Count - 30); - } - private string Purpose(string forum) => State!.Forums.Single(x => x.Value.ForumId == forum).Key; - private string? ClosingTag(Listing post) - { - var purpose = Purpose(post.ForumId); - return State!.Forums[purpose].Tags.GetValueOrDefault(_assets!.Tags[purpose].Single(t => t.Closing).Key)?.Id; - } private void Save() { try { _store!.Save(); Changed?.Invoke(); } diff --git a/DiscordBot/Services/Recruitment/Application/RecruitmentEngineScheduling.cs b/DiscordBot/Services/Recruitment/Application/RecruitmentEngineScheduling.cs index 159a2ed4..6025484a 100644 --- a/DiscordBot/Services/Recruitment/Application/RecruitmentEngineScheduling.cs +++ b/DiscordBot/Services/Recruitment/Application/RecruitmentEngineScheduling.cs @@ -10,8 +10,6 @@ public sealed partial class RecruitmentEngine private long _inventoryGeneration = -1; private DateTimeOffset _nextAudit = DateTimeOffset.MaxValue; private bool _accessInvalid = true; - private readonly Dictionary _publicRenders = new(); - private readonly Dictionary _staffRenders = new(); private bool EvidenceCurrent => _gatewayReady() && !_pendingIdentities() && _inventoryComplete && _inventoryGeneration == _evidenceGeneration(); private bool RoutinePendingIdentity => _ready && _gatewayReady() && _pendingIdentities() && _inventoryComplete && _inventoryGeneration == _evidenceGeneration(); public event Action? Changed; @@ -51,7 +49,7 @@ private DateTimeOffset? NextWorkAt private DateTimeOffset? ListingDue(Listing post) { if (post.Fault is { } fault) return fault.RetryAt; - var pinDue = PinDue(post) ? (DateTimeOffset?)Now : + var pinDue = _publisher!.PinDue(post) ? (DateTimeOffset?)Now : post.Status is ListingStatus.Open or ListingStatus.Closed && post.PublicMessageId is not null ? post.PublicPin.Fault?.RetryAt : null; if (post.PendingAction is not null || post.PublicSendPending || post.StaffSendPending || post.PublicationPending) return Now; @@ -62,7 +60,7 @@ post.Status is ListingStatus.Open or ListingStatus.Closed && post.PublicMessageI if (_recoverWindows.Contains(post.Id)) return Now; if (!post.PromptDeliveredAt.HasValue && !post.AcceptedAt.HasValue) return Now; var dates = new List(); - if (AcknowledgementNoticeDue(post) is { } noticeDue) dates.Add(noticeDue); + if (_publisher!.AcknowledgementNoticeDue(post) is { } noticeDue) dates.Add(noticeDue); if (attention.HasValue) dates.Add(attention.Value); if (pinDue.HasValue) dates.Add(pinDue.Value); if (!post.Exempt && !post.Pinned) @@ -89,7 +87,7 @@ public async Task RunDueAsync(CancellationToken ct) } if (!_ready || !EvidenceCurrent) return; foreach (var post in State.Listings.Values.Where(p => ListingDue(p) <= Now).OrderBy(p => p.CreatedAt).Take(20).ToArray()) - await ProcessSafelyAsync(post, ct); + await _workflow!.ProcessSafelyAsync(post, ct); } public void RequestRecovery() => _nextAudit = Now; @@ -100,7 +98,7 @@ public void InvalidateAccess(string? forum = null) _feedReady = false; _ready = false; _discord.InvalidateCache(); - _publicRenders.Clear(); _staffRenders.Clear(); + _publisher?.ClearRenders(); if (State is null) return; State.RecoveryFault = null; State.Overview.Fault = null; @@ -138,7 +136,7 @@ public async Task ThreadDeletedAsync(string id, CancellationToken ct) RequestRecovery(); Save(); } if (State?.Listings.GetValueOrDefault(id) is not { } post) return; - RecordAbsence(post); + _reader!.RecordAbsence(post); if (_ready) await ReconcileThreadAsync(id, ct); } @@ -157,42 +155,11 @@ public async Task MessageDeletedAsync(string channel, IReadOnlySet ids, channel == _settings.FeedChannelId.ToString() && p.StaffMessageId is not null && ids.Contains(p.StaffMessageId) || channel == p.Id && p.PublicMessageId is not null && ids.Contains(p.PublicMessageId)).ToArray()) { - if (channel == post.Id) { post.PublicMessageId = null; _publicRenders.Remove(post.Id); } - else { post.StaffMessageId = null; _staffRenders.Remove(post.Id); } + if (channel == post.Id) { post.PublicMessageId = null; _publisher!.InvalidatePublic(post.Id); } + else { post.StaffMessageId = null; _publisher!.InvalidateStaff(post.Id); } post.PublicationPending = true; post.Fault = null; Save(); - if (_ready) await ProcessSafelyAsync(post, ct); + if (_ready) await _workflow!.ProcessSafelyAsync(post, ct); } } - private async Task ProcessSafelyAsync(Listing post, CancellationToken ct, bool verifyHeld = false) - { - if (post.Fault is { } fault && !fault.IsDue(Now)) - { - if (!verifyHeld || fault.RetryAt is not null) return; - // A permanent write hold must not hide a deletion during downtime. Probe once per bounded audit. - try - { - var live = await ReadAsync(post, ct, refresh: true); - if (live is null) RecordAbsence(post); - else if (!live.OwnerIsBot && !live.OwnerIsStaff) return; - } - catch (Exception ex) when (ex is not System.IO.IOException && ex is not OperationCanceledException) { return; } - } - var previousFault = post.Fault; - post.Fault = null; - try - { - await ProcessAsync(post, ct); - await PinResponseAsync(post, ct); - if (previousFault is not null) Save(); - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not System.IO.IOException) - { - _discord.InvalidateThread(post.Id); - post.Fault = RecruitmentFaultClassifier.From(ex, Now, previousFault); - post.Hold = post.Fault.Message; - if (!post.AcceptedAt.HasValue && post.Status == ListingStatus.Open) _recoverWindows.Add(post.Id); - Save(); - } - } } diff --git a/DiscordBot/Services/Recruitment/Application/RecruitmentEvidence.cs b/DiscordBot/Services/Recruitment/Application/RecruitmentEvidence.cs index e3322767..08f0e59c 100644 --- a/DiscordBot/Services/Recruitment/Application/RecruitmentEvidence.cs +++ b/DiscordBot/Services/Recruitment/Application/RecruitmentEvidence.cs @@ -7,47 +7,6 @@ namespace DiscordBot.Services.Recruitment; public sealed partial class RecruitmentEngine { - private void RefreshFacts(Listing post, ThreadSnapshot live) - { - var purpose = State!.Forums.First(f => f.Value.ForumId == post.ForumId).Key; - var analysis = ListingAnalysis.Read(live, purpose, State.PortfolioSites.EffectiveHosts()); - var payment = analysis.Payment; - post.Facts.Checks = RecruitmentSignals.Evaluate(live, purpose, analysis); - post.PortfolioRegistryVersion = State.PortfolioSites.Version; - post.Facts.PortfolioCandidates = analysis.Portfolio; - post.Facts.ServerNickname = RecruitmentSignals.SafeIdentity(live.ServerNickname); - post.Facts.GlobalName = RecruitmentSignals.SafeIdentity(live.GlobalName); - post.Facts.Username = RecruitmentSignals.SafeIdentity(live.Username); - post.Facts.AvatarUrl = RecruitmentSignals.SafeAvatarUrl(live.AvatarUrl); - post.Facts.AvatarIdentity = RecruitmentSignals.SafeIdentity(live.AvatarIdentity, 200); - post.Facts.AccountCreatedAt = live.AccountCreatedAt; post.Facts.JoinedAt = live.JoinedAt; - post.Facts.MemberRoleCount = live.MemberRoleCount; post.Facts.HasCustomAvatar = live.HasCustomAvatar; - post.Facts.Payment = payment.Summary; post.Facts.PaymentEvidence = payment.Evidence; - post.Facts.Portfolio = analysis.PortfolioSummary; - post.Facts.Observations = analysis.Observations; - if (live.Starter is null) return; // Missing data must not overwrite previously observed evidence. - var attachments = live.Attachments?.Take(10).ToList() ?? new(); - var normalized = string.Join(' ', live.Starter.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries)); - var fingerprint = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(normalized + "\n" + string.Join('|', attachments.Select(a => a.Id))))); - if (post.LatestStarter?.Fingerprint == fingerprint) return; - var version = new StarterVersion(Now, live.Starter[..Math.Min(2000, live.Starter.Length)], live.Starter.Length > 2000, - fingerprint, payment.Summary, payment.Evidence, analysis.ContactLinks, attachments, - Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(analysis.CompensationTerms)))); - if (post.LatestStarter is { } previous && post.AcceptedAt.HasValue && - (previous.Payment != version.Payment || previous.PaymentEvidence != version.PaymentEvidence || - previous.TermsFingerprint is not null && previous.TermsFingerprint != version.TermsFingerprint || - !previous.Links.SequenceEqual(version.Links) || !previous.Attachments.SequenceEqual(version.Attachments))) - { - if (post.MaterialChanges == post.ReviewedMaterialChanges) QueueAttention(post, "Payment terms, links or attachments changed after acceptance"); - post.MaterialChanges++; - post.EarlierMaterialVersions.Add(previous); - if (post.EarlierMaterialVersions.Count > 3) post.EarlierMaterialVersions.RemoveAt(0); - } - post.OriginalStarter ??= version; - post.LatestStarter = version; - post.UpdatedAt = Now; - } - private string EvidenceDetails(Listing post) { var text = RecruitmentPresentation.Staff(State!, post, _settings).Content + "\n" + diff --git a/DiscordBot/Services/Recruitment/Application/RecruitmentPins.cs b/DiscordBot/Services/Recruitment/Application/RecruitmentPins.cs deleted file mode 100644 index 8567c781..00000000 --- a/DiscordBot/Services/Recruitment/Application/RecruitmentPins.cs +++ /dev/null @@ -1,43 +0,0 @@ -using DiscordBot.Services.Recruitment.Infrastructure.Discord; -namespace DiscordBot.Services.Recruitment; - -public sealed partial class RecruitmentEngine -{ - private bool PinDue(Listing post) => post.Status is ListingStatus.Open or ListingStatus.Closed && - post.PublicMessageId is not null && - (post.PublicPin.MessageId != post.PublicMessageId || - (!post.PublicPin.Confirmed || post.PublicPin.RestoreArchive) && (post.PublicPin.Fault is null || post.PublicPin.Fault.IsDue(Now))); - - private async Task PinResponseAsync(Listing post, CancellationToken ct) - { - if (!PinDue(post)) return; - if (post.PublicPin.MessageId != post.PublicMessageId) - { post.PublicPin = new MessagePinReceipt { MessageId = post.PublicMessageId }; Save(); } - var receipt = post.PublicPin; - bool found; - var persistenceFailed = false; - try - { - found = await _discord.PinAdvisoryAsync(post.Id, receipt.MessageId!, receipt.RestoreArchive, receipt.RestoreLocked, - (archive, locked) => - { - receipt.RestoreArchive = archive; receipt.RestoreLocked = locked; - try { Save(); } - catch (Exception ex) - { persistenceFailed = true; throw new System.IO.IOException("Could not persist response pin intent.", ex); } - }, ct); - } - catch (Exception ex) when ((ex is not OperationCanceledException || !ct.IsCancellationRequested) && !persistenceFailed) - { - receipt.Fault = RecruitmentFaultClassifier.From(ex, Now, receipt.Fault); - if (receipt.RestoreArchive && receipt.Fault.RetryAt is null) - receipt.Fault = receipt.Fault with { RetryAt = Now.AddSeconds(RecruitmentBackoff.Seconds(receipt.Fault.Attempts, Random.Shared.Next(1, 6))) }; - Save(); return; - } - if (found) - { receipt.Confirmed = true; receipt.RestoreArchive = receipt.RestoreLocked = false; receipt.Fault = null; } - else - { post.PublicMessageId = null; post.PublicationPending = true; _publicRenders.Remove(post.Id); post.PublicPin = new(); } - Save(); - } -} diff --git a/DiscordBot/Services/Recruitment/Application/RecruitmentReports.cs b/DiscordBot/Services/Recruitment/Application/RecruitmentReports.cs index dc17df39..5b8dc377 100644 --- a/DiscordBot/Services/Recruitment/Application/RecruitmentReports.cs +++ b/DiscordBot/Services/Recruitment/Application/RecruitmentReports.cs @@ -41,43 +41,11 @@ public async Task ReportAsync(string guild, string thread, string report var firstOpen = post.Reports.All(r => r.ResolvedAt.HasValue); post.Reports.Add(new RecruitmentReport { ReporterId = reporter, At = Now, Concern = DiscordDisplayText.Literal(concern, 1000) }); history.RecentReports.Add(Now); - if (firstOpen) QueueAttention(post, "A private reader report needs review"); + if (firstOpen) _publisher!.QueueAttention(post, "A private reader report needs review"); post.UpdatedAt = Now; post.PublicationPending = true; Save(); // The durable report is accepted even if notification delivery needs a retry. - await ProcessSafelyAsync(post, ct); + await _workflow!.ProcessSafelyAsync(post, ct); return "Your report has been saved for moderators. It is not shown to the listing author or other readers. Keep relevant messages and links; the bot cannot read other people's DMs."; } - private void QueueAttention(Listing post, string reason) - { - // Bound retained receipts. During prolonged feed failure, coalesce rather than grow without limit. - if (post.Attention.Count >= 8) - { - var completed = post.Attention.FirstOrDefault(n => n.MessageId is not null && !n.SendPending); - if (completed is null) return; // Existing pending notices and the overview still link this listing. - post.Attention.Remove(completed); - } - post.Attention.Add(new AttentionNotice { At = Now, Reason = reason }); - } - - private bool AttentionDue(Listing post) => post.Attention.Any(n => (n.MessageId is null || n.SendPending) && (n.Fault is null || n.Fault.IsDue(Now))); - - private async Task PublishAttentionAsync(Listing post, CancellationToken ct) - { - foreach (var notice in post.Attention.Where(n => (n.MessageId is null || n.SendPending) && (n.Fault is null || n.Fault.IsDue(Now))).Take(3)) - { - try - { - var marker = $"recruitment:{post.Id}:attention:{notice.Id}"; - var message = new RecruitmentMessage($"**Recruitment review** · {notice.Reason}\n<#{post.Id}> · author `{post.OwnerId}`\nUse `/recruitment lookup {post.Id}` for evidence and review actions.\n||{marker}||", marker, false); - var delivery = await _discord.UpsertAsync(_settings.FeedChannelId.ToString(), notice.MessageId, notice.SendPending, - notice.At, message, () => { GuardFeedWrite(); notice.SendPending = true; Save(); }, ct); - notice.MessageId = delivery.Id; notice.SendPending = false; notice.Fault = null; Save(); - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not System.IO.IOException) - { - notice.Fault = RecruitmentFaultClassifier.From(ex, Now, notice.Fault); Save(); - } - } - } } diff --git a/DiscordBot/Services/Recruitment/Domain/Policies/AcknowledgementRecovery.cs b/DiscordBot/Services/Recruitment/Domain/Policies/AcknowledgementRecovery.cs new file mode 100644 index 00000000..d4693df7 --- /dev/null +++ b/DiscordBot/Services/Recruitment/Domain/Policies/AcknowledgementRecovery.cs @@ -0,0 +1,9 @@ +namespace DiscordBot.Services.Recruitment.Domain.Policies; + +internal sealed class AcknowledgementRecovery +{ + private readonly HashSet _listings = new(); + public void Add(string id) => _listings.Add(id); + public bool Remove(string id) => _listings.Remove(id); + public bool Contains(string id) => _listings.Contains(id); +} diff --git a/DiscordBot/Services/Recruitment/Domain/Policies/AutomaticActionPolicy.cs b/DiscordBot/Services/Recruitment/Domain/Policies/AutomaticActionPolicy.cs new file mode 100644 index 00000000..f80b497c --- /dev/null +++ b/DiscordBot/Services/Recruitment/Domain/Policies/AutomaticActionPolicy.cs @@ -0,0 +1,18 @@ +namespace DiscordBot.Services.Recruitment.Domain.Policies; + +internal static class AutomaticActionPolicy +{ + public static bool ShouldCancel(RecruitmentState state, Listing post, RecruitmentAction action, + DateTimeOffset now, int cooldownDays, bool evidenceCurrent, bool recoveringWindow, bool closingTagApplied) + { + if (!action.Automatic) return false; + if (!evidenceCurrent || post.Exempt || post.Pinned || post.Status != ListingStatus.Open) return true; + return action.Condition switch + { + RecruitmentConditions.Timeout => post.AcknowledgedAt.HasValue || post.AcceptedAt.HasValue || post.Deadline > now || recoveringWindow, + RecruitmentConditions.Ineligible => post.AcceptedAt.HasValue || ListingEligibilityPolicy.Evaluate(state, post, now, cooldownDays).Allowed, + RecruitmentConditions.ClosedTag => !closingTagApplied, + _ => false // Preserve the behavior of older/unknown persisted conditions. + }; + } +} From cc865c9862d5676d70a0316ae2690a9a5f2f480b Mon Sep 17 00:00:00 2001 From: James Kellie Date: Mon, 19 Oct 2026 21:15:52 +1000 Subject: [PATCH 29/31] Separate recruitment commands and query formatting --- .../Workflows/RecruitmentCommandTests.cs | 37 ++++ .../Modules/Recruitment/RecruitmentModule.cs | 36 ++-- .../Application/ListingOwnerCommands.cs | 87 ++++++++ .../Recruitment/Application/ListingQueries.cs | 67 +++++++ ...entReports.cs => ListingReportCommands.cs} | 35 ++-- .../Application/ListingStaffCommands.cs | 82 ++++++++ .../Application/PortfolioCommands.cs | 41 ++++ .../Application/RecruitmentAttempts.cs | 25 --- .../Application/RecruitmentCommands.cs | 36 ++++ .../Application/RecruitmentEngine.cs | 188 +++++------------- .../Application/RecruitmentEvidence.cs | 42 ---- .../RecruitmentPortfolioCommands.cs | 37 ---- .../Presentation/RecruitmentQueryFormatter.cs | 60 ++++++ .../Recruitment/RecruitmentService.cs | 3 +- DiscordBot/Utils/DiscordReplyChunks.cs | 17 ++ 15 files changed, 514 insertions(+), 279 deletions(-) create mode 100644 DiscordBot.Tests/Recruitment/Workflows/RecruitmentCommandTests.cs create mode 100644 DiscordBot/Services/Recruitment/Application/ListingOwnerCommands.cs create mode 100644 DiscordBot/Services/Recruitment/Application/ListingQueries.cs rename DiscordBot/Services/Recruitment/Application/{RecruitmentReports.cs => ListingReportCommands.cs} (56%) create mode 100644 DiscordBot/Services/Recruitment/Application/ListingStaffCommands.cs create mode 100644 DiscordBot/Services/Recruitment/Application/PortfolioCommands.cs create mode 100644 DiscordBot/Services/Recruitment/Application/RecruitmentCommands.cs delete mode 100644 DiscordBot/Services/Recruitment/Application/RecruitmentEvidence.cs delete mode 100644 DiscordBot/Services/Recruitment/Application/RecruitmentPortfolioCommands.cs create mode 100644 DiscordBot/Services/Recruitment/Presentation/RecruitmentQueryFormatter.cs create mode 100644 DiscordBot/Utils/DiscordReplyChunks.cs diff --git a/DiscordBot.Tests/Recruitment/Workflows/RecruitmentCommandTests.cs b/DiscordBot.Tests/Recruitment/Workflows/RecruitmentCommandTests.cs new file mode 100644 index 00000000..35b9c155 --- /dev/null +++ b/DiscordBot.Tests/Recruitment/Workflows/RecruitmentCommandTests.cs @@ -0,0 +1,37 @@ +using DiscordBot.Utils; + +namespace DiscordBot.Tests.Recruitment; + +public class RecruitmentCommandTests +{ + [Theory] + [InlineData("reset", "Only an open unaccepted attempt")] + [InlineData("unknown-operation", "Unknown recruitment operation")] + public async Task AttemptedStaffCommandsStillAuditBeforeRejectingPreconditions(string operation, string response) + { + using var rig = new RecruitmentRig(); await rig.Start(); var post = await rig.Post(); await rig.Ack(); + var count = post.Audit.Count; + Assert.Contains(response, await rig.Staff(operation)); + Assert.Equal(count + 1, post.Audit.Count); + Assert.StartsWith(operation + ": ", post.Audit[^1].Reason); + Assert.NotNull(post.AcceptedAt); + } + + [Theory] + [InlineData(0)] + [InlineData(1900)] + [InlineData(6200)] + public void ReplyChunksKeepCompleteTextAndUnicodeWithinDiscordLimit(int length) + { + var text = new string('x', length) + "\n```\n" + new string('a', 1898) + "🦆\n```"; + var chunks = DiscordReplyChunks.Split(text).ToArray(); + Assert.Equal(text, string.Concat(chunks)); + Assert.All(chunks, chunk => + { + Assert.InRange(chunk.Length, 1, 1900); + Assert.False(char.IsHighSurrogate(chunk[^1])); + Assert.False(char.IsLowSurrogate(chunk[0])); + }); + Assert.Empty(DiscordReplyChunks.Split("")); + } +} diff --git a/DiscordBot/Modules/Recruitment/RecruitmentModule.cs b/DiscordBot/Modules/Recruitment/RecruitmentModule.cs index 2a002df9..2a3dee92 100644 --- a/DiscordBot/Modules/Recruitment/RecruitmentModule.cs +++ b/DiscordBot/Modules/Recruitment/RecruitmentModule.cs @@ -1,3 +1,5 @@ +using DiscordBot.Services.Recruitment.Application; +using DiscordBot.Utils; using Discord.Interactions; using DiscordBot.Services.Recruitment; @@ -53,22 +55,23 @@ public async Task PortfolioSites([Choice("List", "list"), Choice("Add", "add"), string domain = "", string reason = "") { await DeferAsync(ephemeral: true); - await Reply(() => Recruitment.RunAsync((engine, ct) => engine.PortfolioSitesAsync(Guild, Actor, operation, domain, reason, ct))); + await Reply(() => Recruitment.RunAsync((engine, ct) => engine.PortfolioSitesAsync(Guild, Actor, RecruitmentCommands.Portfolio(operation), domain, reason, ct))); } private async Task Staff(string operation, string target = "", string reason = "") { await DeferAsync(ephemeral: true); - if (operation is "repair" or "reconcile") - { await FollowupAsync(Recruitment.QueueRecovery(Guild, Actor, operation, target, reason), ephemeral: true, allowedMentions: AllowedMentions.None); return; } - await Reply(() => Recruitment.RunAsync((engine, ct) => engine.StaffAsync(Guild, Actor, operation, target, reason, ct))); + var command = RecruitmentCommands.Staff(operation); + if (command.Operation is StaffOperation.Repair or StaffOperation.Reconcile) + { await FollowupAsync(Recruitment.QueueRecovery(Guild, Actor, command, target, reason), ephemeral: true, allowedMentions: AllowedMentions.None); return; } + await Reply(() => Recruitment.RunAsync((engine, ct) => engine.StaffAsync(Guild, Actor, command, target, reason, ct))); } [ComponentInteraction("recruit:details:*", true)] public async Task Details(string thread) { await DeferAsync(ephemeral: true); - if (thread != Thread) { await FollowupAsync("This control belongs to another thread.", ephemeral: true); return; } + if (!await MatchesThreadAsync(thread)) return; await Reply(() => Recruitment.RunAsync((engine, ct) => engine.DetailsAsync(Guild, thread, Actor, ct))); } @@ -76,7 +79,7 @@ public async Task Details(string thread) public async Task OwnerControls(string thread) { await DeferAsync(ephemeral: true); - if (thread != Thread) { await FollowupAsync("This control belongs to another thread.", ephemeral: true); return; } + if (!await MatchesThreadAsync(thread)) return; try { var result = await Recruitment.RunAsync((engine, ct) => engine.OwnerDetailsAsync(Guild, thread, Actor, ct)); @@ -105,7 +108,7 @@ public sealed class ConcernModal : IModal public async Task SubmitConcern(string thread, ConcernModal modal) { await DeferAsync(ephemeral: true); - if (thread != Thread) { await FollowupAsync("This control belongs to another thread.", ephemeral: true); return; } + if (!await MatchesThreadAsync(thread)) return; await Reply(() => Recruitment.RunAsync((engine, ct) => engine.ReportAsync(Guild, thread, Actor, modal.Concern, ct))); } @@ -128,21 +131,21 @@ public sealed class GuidelinesModal : IModal public async Task SubmitCode(string thread, GuidelinesModal modal) { await DeferAsync(ephemeral: true); - if (thread != Thread) { await FollowupAsync("This control belongs to another thread.", ephemeral: true); return; } + if (!await MatchesThreadAsync(thread)) return; await Reply(() => Recruitment.RunAsync((engine, ct) => engine.AcknowledgeAsync(Guild, thread, Actor, modal.Code, ct, Context.Interaction.CreatedAt), thread)); } [ComponentInteraction("recruit:close:*", true)] public async Task Close(string thread) { await DeferAsync(ephemeral: true); - if (thread != Thread) { await FollowupAsync("This control belongs to another thread.", ephemeral: true); return; } + if (!await MatchesThreadAsync(thread)) return; await Reply(() => Recruitment.RunAsync((engine, ct) => engine.CloseOwnerAsync(Guild, thread, Actor, ct), thread)); } [ComponentInteraction("recruit:remove:*", true)] public async Task Remove(string thread) { await DeferAsync(ephemeral: true); - if (thread != Thread) { await FollowupAsync("This control belongs to another thread.", ephemeral: true); return; } + if (!await MatchesThreadAsync(thread)) return; try { var result = await Recruitment.RunAsync((engine, _) => Task.FromResult(engine.PrepareRemoval(Guild, thread, Actor)), thread); @@ -156,16 +159,23 @@ public async Task Remove(string thread) public async Task Confirm(string thread, string token) { await DeferAsync(ephemeral: true); - if (thread != Thread) { await FollowupAsync("This control belongs to another thread.", ephemeral: true); return; } + if (!await MatchesThreadAsync(thread)) return; await Reply(() => Recruitment.RunAsync((engine, ct) => engine.ConfirmRemovalAsync(Guild, thread, Actor, token, ct), thread)); } + private async Task MatchesThreadAsync(string thread) + { + if (thread == Thread) return true; + await FollowupAsync("This control belongs to another thread.", ephemeral: true); + return false; + } + private async Task Reply(Func> action) { try { var text = await action(); - for (var offset = 0; offset < text.Length; offset += 1900) - await FollowupAsync(text.Substring(offset, Math.Min(1900, text.Length - offset)), ephemeral: true, allowedMentions: AllowedMentions.None); + foreach (var chunk in DiscordReplyChunks.Split(text)) + await FollowupAsync(chunk, ephemeral: true, allowedMentions: AllowedMentions.None); } catch { await FollowupAsync("Recruitment could not complete this request. Try again or ask a moderator to check status.", ephemeral: true); } } diff --git a/DiscordBot/Services/Recruitment/Application/ListingOwnerCommands.cs b/DiscordBot/Services/Recruitment/Application/ListingOwnerCommands.cs new file mode 100644 index 00000000..cd431f20 --- /dev/null +++ b/DiscordBot/Services/Recruitment/Application/ListingOwnerCommands.cs @@ -0,0 +1,87 @@ +using DiscordBot.Settings; +using DiscordBot.Utils; +using DiscordBot.Services.Recruitment.Domain.Policies; +using DiscordBot.Services.Recruitment.Presentation; + +namespace DiscordBot.Services.Recruitment.Application; + +internal sealed class ListingOwnerCommands(RecruitmentState state, RecruitmentSettings settings, IRecruitmentDiscord discord, TimeProvider time, Action save, ListingWorkflow workflow, ListingActionExecutor actions, Func ready, Func acknowledgementReady, Func evidenceCurrent) +{ + private readonly RecruitmentState State = state; + private readonly RecruitmentSettings _settings = settings; + private readonly IRecruitmentDiscord _discord = discord; + private DateTimeOffset Now => time.GetUtcNow(); + private readonly ListingWorkflow _workflow = workflow; + private readonly ListingActionExecutor _actions = actions; + private void Save() => save(); + private readonly Dictionary _confirmations = new(); + private sealed record RemovalConfirmation(string Guild, string Thread, string Owner, long Version, DateTimeOffset Expires); + + public async Task AcknowledgeAsync(string guild, string thread, string owner, string code, CancellationToken ct, DateTimeOffset? receivedAt = null) + { + var post = Owner(guild, thread, owner); + if (post is null) return "This control is unavailable or does not belong to you."; + // Startup/recovery establishes cross-forum identity coverage; ordinary acknowledgements only + // refresh the candidate and its blocking author records. + if (!acknowledgementReady()) return "Recruitment is waiting for recovery. Please try again shortly."; + if (post.Status != ListingStatus.Open || post.AcceptedAt.HasValue) return "This listing no longer needs acknowledgement."; + var submittedAt = receivedAt ?? Now; + if (!post.Deadline.HasValue || post.Deadline < submittedAt || post.PromptDeliveredAt > submittedAt) + return "The acknowledgement window is unavailable or has expired."; + if (post.LastCodeAttemptAt?.AddSeconds(5) > Now) return "Please wait a few seconds before trying again."; + post.LastCodeAttemptAt = Now; Save(); + if (!post.ValidCodes.Contains(code.Trim().ToUpperInvariant())) return "That code does not match. Read the native Guidelines for this forum and try again."; + // Persist a timely valid submission before any processing can execute an overdue timeout. + // Invalid/late submissions never extend the window or fence the deadline worker. + post.AcknowledgedAt = submittedAt; post.Version++; post.UpdatedAt = Now; + var author = ListingTransitions.Author(State!, owner, Now); + author.ConsecutiveTimeouts = 0; author.TimeoutAlerted = false; + Save(); + _discord.InvalidateThread(post.Id); + post.Fault = null; + await _workflow!.ProcessAsync(post, ct); + if (post.PendingAction is null && post.Status == ListingStatus.Open && !post.AcceptedAt.HasValue) + await _workflow!.ProcessAsync(post, ct); // A recovered timeout intent may have just been cancelled. + if (post.Status != ListingStatus.Open) return "This listing is no longer open. Use your listing controls to check its status."; + return post.AcceptedAt.HasValue ? "Guidelines acknowledged. Your listing is accepted." : !evidenceCurrent() + ? "Guidelines acknowledged. Acceptance is waiting for inventory recovery." + : "Guidelines acknowledged. The existing listing or creation cooldown still blocks acceptance; see the advisory."; + } + + public async Task CloseOwnerAsync(string guild, string thread, string owner, CancellationToken ct) + { + var post = Owner(guild, thread, owner); + if (post is null || post.Status != ListingStatus.Open) return "This open listing does not belong to you or recruitment is unavailable."; + await _actions!.BeginActionAsync(post, RecruitmentActionKind.Close, owner, "Owner closed listing", "", false, ct); + return "Listing closed. Its accepted creation cooldown is unchanged."; + } + + public (string Message, string? Token) PrepareRemoval(string guild, string thread, string owner) + { + var post = Owner(guild, thread, owner); + if (post is null || post.Status is ListingStatus.Deleted or ListingStatus.Reference) return ("This listing is unavailable or does not belong to you.", null); + foreach (var key in _confirmations.Where(x => x.Value.Expires <= Now || x.Value.Owner == owner).Select(x => x.Key).ToArray()) _confirmations.Remove(key); + var token = Guid.NewGuid().ToString("N"); + _confirmations[token] = new RemovalConfirmation(guild, thread, owner, post.Version, Now.AddMinutes(2)); + var next = State!.Authors.GetValueOrDefault(owner)?.LastAcceptedCreation?.AddDays(_settings.CooldownDays); + var timing = !post.AcceptedAt.HasValue ? "Removing this unaccepted attempt adds no accepted-listing cooldown; its creation still counts toward the attempt limit." : next.HasValue + ? "Its creation cooldown remains " + DiscordDisplayText.Timestamp(next.Value) + "." : "The accepted creation cooldown has been waived."; + return ($"Permanently remove this forum post? {timing} This confirmation expires in two minutes.", token); + } + + public async Task ConfirmRemovalAsync(string guild, string thread, string owner, string token, CancellationToken ct) + { + if (!_confirmations.TryGetValue(token, out var confirm) || confirm.Guild != guild || confirm.Thread != thread || confirm.Owner != owner) + return "This confirmation does not belong to this listing and owner."; + _confirmations.Remove(token); + var post = Owner(guild, thread, owner); + if (post is null || confirm.Expires <= Now || confirm.Version != post.Version || post.Status is ListingStatus.Deleted or ListingStatus.Reference) + return "This confirmation expired or the listing changed. Request removal again."; + await _actions!.BeginActionAsync(post, RecruitmentActionKind.Delete, owner, "Owner confirmed removal", "", false, ct); + return "Post removed. Any accepted creation cooldown is unchanged."; + } + + public Listing? Owner(string guild, string thread, string owner) => + ready() && State?.GuildId == guild && State.Listings.TryGetValue(thread, out var post) && post.OwnerId == owner ? post : null; + +} diff --git a/DiscordBot/Services/Recruitment/Application/ListingQueries.cs b/DiscordBot/Services/Recruitment/Application/ListingQueries.cs new file mode 100644 index 00000000..f012d24b --- /dev/null +++ b/DiscordBot/Services/Recruitment/Application/ListingQueries.cs @@ -0,0 +1,67 @@ +using DiscordBot.Settings; +using DiscordBot.Utils; +using DiscordBot.Services.Recruitment.Domain.Policies; +using DiscordBot.Services.Recruitment.Presentation; + +namespace DiscordBot.Services.Recruitment.Application; + +internal sealed class ListingQueries(RecruitmentState state, RecruitmentSettings settings, IRecruitmentDiscord discord, TimeProvider time, ListingOwnerCommands owners, Func evidenceCurrent) +{ + private readonly RecruitmentState State = state; + private readonly RecruitmentSettings _settings = settings; + private readonly IRecruitmentDiscord _discord = discord; + private DateTimeOffset Now => time.GetUtcNow(); + + public async Task ReaderListingAsync(string guild, string thread, string reader, CancellationToken ct) => + State is not null && guild == State.GuildId && State.Listings.TryGetValue(thread, out var post) && + post.Status is not ListingStatus.Reference && await _discord.IsMemberAsync(guild, reader, ct) ? post : null; + + public async Task EligibilityAsync(string guild, string owner, CancellationToken ct) + { + if (State is null || guild != State.GuildId || !await _discord.IsMemberAsync(guild, owner, ct)) + return "This control is only available to current members of the configured server."; + State.Authors.TryGetValue(owner, out var author); + var open = State.Listings.Values.Where(p => p.OwnerId == owner && p.Status == ListingStatus.Open).Take(10) + .Select(p => new EligibilityListing(p.Id, p.AcceptedAt.HasValue)).ToArray(); + var used = author is null || author.AttemptPausedUntil.HasValue && author.AttemptPausedUntil <= Now + ? 0 : author.RecentCreations.Count(d => d > Now.AddMinutes(-_settings.AttemptWindowMinutes)); + var view = new EligibilityView(open, author is not null, author?.LastAcceptedCreation?.AddDays(_settings.CooldownDays), + author?.AttemptPausedUntil, Now, evidenceCurrent(), used, _settings.CooldownDays, _settings.AttemptLimit, + _settings.AttemptWindowMinutes, _settings.AttemptPauseMinutes); + return RecruitmentQueryFormatter.Eligibility(view); + } + + public async Task DetailsAsync(string guild, string thread, string reader, CancellationToken ct) + { + var post = await ReaderListingAsync(guild, thread, reader, ct); + return post is null ? "Listing details are unavailable in this server." : RecruitmentPresentation.Details(post); + } + + public async Task<(string Message, bool Open, bool Removable)> OwnerDetailsAsync(string guild, string thread, string owner, CancellationToken ct) + { + var post = owners.Owner(guild, thread, owner); + if (post is null) return ("This listing does not belong to you or recruitment is unavailable.", false, false); + return ($"**Your listing · {post.Status}**\n" + await EligibilityAsync(guild, owner, ct) + + "\nClosing preserves the discussion. Removal permanently deletes it.", post.Status == ListingStatus.Open, + post.Status is ListingStatus.Open or ListingStatus.Closed); + } + + public string EvidenceDetails(Listing post) + { + var versions = new[] { post.OriginalStarter }.Concat(post.EarlierMaterialVersions).Append(post.LatestStarter) + .OfType().DistinctBy(v => (v.ObservedAt, v.Fingerprint)).ToArray(); + RelatedListing[] related = Array.Empty(); + if (post.LatestStarter is { } latest) + { + static IEnumerable Retained(Listing p) => new[] { p.OriginalStarter }.Concat(p.EarlierMaterialVersions) + .Append(p.LatestStarter).OfType(); + var links = Retained(post).SelectMany(v => v.Links).ToHashSet(StringComparer.Ordinal); + related = State!.Listings.Values.Where(p => p.Id != post.Id && p.OwnerId != post.OwnerId && + Retained(p).Any(other => latest.Content.Length >= 120 && latest.Fingerprint == other.Fingerprint || links.Overlaps(other.Links))) + .OrderByDescending(p => p.CreatedAt).Take(5).Select(p => new RelatedListing(p.Id, p.OwnerId)).ToArray(); + } + return RecruitmentQueryFormatter.Evidence(new PrivateEvidenceView(RecruitmentPresentation.Staff(State, post, _settings).Content, + post.CreatedAt, post.AcceptedAt, post.PublicPin, versions, related, post.Reports.ToArray(), post.Audit.TakeLast(10).ToArray())); + } + +} diff --git a/DiscordBot/Services/Recruitment/Application/RecruitmentReports.cs b/DiscordBot/Services/Recruitment/Application/ListingReportCommands.cs similarity index 56% rename from DiscordBot/Services/Recruitment/Application/RecruitmentReports.cs rename to DiscordBot/Services/Recruitment/Application/ListingReportCommands.cs index 5b8dc377..39521062 100644 --- a/DiscordBot/Services/Recruitment/Application/RecruitmentReports.cs +++ b/DiscordBot/Services/Recruitment/Application/ListingReportCommands.cs @@ -1,35 +1,24 @@ -using DiscordBot.Services.Recruitment.Infrastructure.Discord; +using DiscordBot.Settings; using DiscordBot.Utils; using DiscordBot.Services.Recruitment.Domain.Policies; -namespace DiscordBot.Services.Recruitment; +using DiscordBot.Services.Recruitment.Presentation; -public sealed partial class RecruitmentEngine -{ - private async Task ReaderListingAsync(string guild, string thread, string reader, CancellationToken ct) => - State is not null && guild == State.GuildId && State.Listings.TryGetValue(thread, out var post) && - post.Status is not ListingStatus.Reference && await _discord.IsMemberAsync(guild, reader, ct) ? post : null; - - public async Task DetailsAsync(string guild, string thread, string reader, CancellationToken ct) - { - var post = await ReaderListingAsync(guild, thread, reader, ct); - return post is null ? "Listing details are unavailable in this server." : RecruitmentPresentation.Details(post); - } +namespace DiscordBot.Services.Recruitment.Application; - public async Task<(string Message, bool Open, bool Removable)> OwnerDetailsAsync(string guild, string thread, string owner, CancellationToken ct) - { - var post = Owner(guild, thread, owner); - if (post is null) return ("This listing does not belong to you or recruitment is unavailable.", false, false); - return ($"**Your listing · {post.Status}**\n" + await EligibilityAsync(guild, owner, ct) + - "\nClosing preserves the discussion. Removal permanently deletes it.", post.Status == ListingStatus.Open, - post.Status is ListingStatus.Open or ListingStatus.Closed); - } +internal sealed class ListingReportCommands(RecruitmentState state, TimeProvider time, Action save, ListingQueries queries, ListingPublisher publisher, ListingWorkflow workflow, Func reportingReady) +{ + private readonly RecruitmentState State = state; + private DateTimeOffset Now => time.GetUtcNow(); + private readonly ListingPublisher _publisher = publisher; + private readonly ListingWorkflow _workflow = workflow; + private void Save() => save(); public async Task ReportAsync(string guild, string thread, string reporter, string concern, CancellationToken ct) { - var post = await ReaderListingAsync(guild, thread, reporter, ct); + var post = await queries.ReaderListingAsync(guild, thread, reporter, ct); if (post is null) return "This listing is unavailable or you are not a current server member."; if (post.OwnerId == reporter) return "Use your listing controls or contact a moderator about your own listing."; - if (!_ready || !_feedReady || !_gatewayReady() || _feedGeneration != _evidenceGeneration()) + if (!reportingReady()) return "Private reporting is temporarily unavailable. Please contact a moderator directly."; concern = concern.Trim(); if (concern.Length is < 10 or > 1000) return "Describe the concern in 10–1,000 characters, including relevant context."; diff --git a/DiscordBot/Services/Recruitment/Application/ListingStaffCommands.cs b/DiscordBot/Services/Recruitment/Application/ListingStaffCommands.cs new file mode 100644 index 00000000..c18533ea --- /dev/null +++ b/DiscordBot/Services/Recruitment/Application/ListingStaffCommands.cs @@ -0,0 +1,82 @@ +using DiscordBot.Settings; +using DiscordBot.Utils; +using DiscordBot.Services.Recruitment.Domain.Policies; +using DiscordBot.Services.Recruitment.Presentation; + +namespace DiscordBot.Services.Recruitment.Application; + +internal sealed class ListingStaffCommands(RecruitmentState state, TimeProvider time, Action save, ListingActionExecutor actions, ListingPublisher publisher, AcknowledgementRecovery windows, Func> discover) +{ + private readonly RecruitmentState State = state; + private readonly ListingActionExecutor _actions = actions; + private readonly ListingPublisher _publisher = publisher; + private readonly AcknowledgementRecovery _recoverWindows = windows; + private DateTimeOffset Now => time.GetUtcNow(); + private void Save() => save(); + + public async Task ExecuteAsync(string actor, StaffCommand command, string target, string reason, CancellationToken ct) + { + var operation = command.Operation; + if (operation == StaffOperation.WaiveCooldown) + { + if (!State.Authors.TryGetValue(target, out var author)) return "Author history was not found."; + // Audit on every retained listing for this author; waiver never changes occupied capacity. + var posts = State.Listings.Values.Where(p => p.OwnerId == target).ToArray(); + if (posts.Length == 0) return "No listing audit record remains; no active cooldown should need a waiver."; + foreach (var p in posts) { _actions!.Audit(p, actor, "Waive cooldown: " + reason); Save(); await _publisher!.PublishStaffAsync(p, ct); } + author.LastAcceptedCreation = null; author.UpdatedAt = Now; Save(); + return "Creation cooldown waived. Existing open listings still occupy the slot."; + } + if (!State.Listings.TryGetValue(target, out var post)) return "Managed thread was not found. Reconcile first."; + _actions!.Audit(post, actor, command.Name + ": " + reason); Save(); await _publisher!.PublishStaffAsync(post, ct); + switch (operation) + { + case StaffOperation.Exempt: post.Exempt = true; break; + case StaffOperation.Unexempt: post.Exempt = false; _recoverWindows.Add(post.Id); break; + case StaffOperation.ResolveReports: + foreach (var report in post.Reports.Where(r => r.ResolvedAt is null)) report.ResolvedAt = Now; + break; + case StaffOperation.ReviewChanges: post.ReviewedMaterialChanges = post.MaterialChanges; break; + case StaffOperation.ResetAttempts: + var attemptHistory = ListingTransitions.Author(State, post.OwnerId, Now); + attemptHistory.RecentCreations.Clear(); attemptHistory.AttemptPausedUntil = null; + foreach (var attempt in State.Listings.Values.Where(p => p.OwnerId == post.OwnerId)) attempt.AttemptPausedUntil = null; + break; + case StaffOperation.ResetTimeouts: + var timeoutHistory = ListingTransitions.Author(State, post.OwnerId, Now); + timeoutHistory.ConsecutiveTimeouts = 0; timeoutHistory.TimeoutAlerted = false; break; + case StaffOperation.Reset: + if (post.AcceptedAt.HasValue || post.Status != ListingStatus.Open) return "Only an open unaccepted attempt can have its acknowledgement reset."; + post.AcknowledgedAt = null; post.ValidCodes.Clear(); _recoverWindows.Add(post.Id); + var history = ListingTransitions.Author(State, post.OwnerId, Now); + history.ConsecutiveTimeouts = 0; history.TimeoutAlerted = false; break; + case StaffOperation.Reference: + if (post.Status == ListingStatus.Deleted) return "A deleted thread cannot become a reference post."; + post.Status = ListingStatus.Reference; post.PendingAction = null; post.Deadline = null; post.ValidCodes.Clear(); + post.LastOutcome = "Moderator-designated reference post"; post.Version++; Save(); + await _actions!.BeginActionAsync(post, RecruitmentActionKind.RemoveAdvisory, actor, reason, "", false, ct, requiresModerator: true); + return "Reference post designated; bot advisory removed. Any accepted cooldown is preserved."; + case StaffOperation.Close: + if (post.Status != ListingStatus.Open) return "Only an open listing can be closed."; + await _actions!.BeginActionAsync(post, RecruitmentActionKind.Close, actor, reason, "", false, ct, requiresModerator: true); return post.Status == ListingStatus.Closed ? "Listing closed." : "Closure was cancelled or is awaiting reconciliation."; + case StaffOperation.Reopen: + if (post.Status != ListingStatus.Closed) return "Only a closed listing can be reopened."; + if (!await discover(ct)) return "Inventory is incomplete; reopening is held."; + if (State.Listings.Values.Any(p => p.OwnerId == post.OwnerId && p.Id != post.Id && p.Status == ListingStatus.Open)) + return "Another listing occupies this author's slot."; + await _actions!.BeginActionAsync(post, RecruitmentActionKind.Reopen, actor, reason, "", false, ct, requiresModerator: true); return post.Status == ListingStatus.Open + ? "Listing reopened. Update this thread to preserve its conversation history." : "Reopening was cancelled or is awaiting reconciliation."; + case StaffOperation.ReviewAbsent: + if (!post.AvailabilityUnknown) return "Availability is known; use close or reconcile instead."; + post.Status = ListingStatus.Deleted; post.AvailabilityUnknown = false; post.PendingAction = null; + post.PublicSendPending = false; post.Hold = null; + ListingTransitions.Author(State, post.OwnerId, Now); + post.LastOutcome = "Staff confirmed absence"; break; + default: return "Unknown recruitment operation."; + } + post.Version++; post.UpdatedAt = Now; Save(); + if (operation == StaffOperation.ReviewAbsent) await _publisher!.PublishStaffAsync(post, ct); + else await _publisher!.PublishAsync(post, ct); + return "Recruitment record updated."; + } +} diff --git a/DiscordBot/Services/Recruitment/Application/PortfolioCommands.cs b/DiscordBot/Services/Recruitment/Application/PortfolioCommands.cs new file mode 100644 index 00000000..c6459b35 --- /dev/null +++ b/DiscordBot/Services/Recruitment/Application/PortfolioCommands.cs @@ -0,0 +1,41 @@ +using DiscordBot.Settings; +using DiscordBot.Utils; +using DiscordBot.Services.Recruitment.Domain.Policies; +using DiscordBot.Services.Recruitment.Presentation; + +namespace DiscordBot.Services.Recruitment.Application; + +internal sealed class PortfolioCommands(RecruitmentState state, TimeProvider time, Action save) +{ + private readonly RecruitmentState State = state; + private DateTimeOffset Now => time.GetUtcNow(); + private void Save() => save(); + + public string Execute(string actor, PortfolioCommand command, string domain, string reason) + { + var operation = command.Operation; + var registry = State.PortfolioSites; + if (operation == PortfolioOperation.List) + return "Recognized portfolio sites (recognition is not endorsement):\n" + + (registry.Hosts.Count == 0 ? "None configured." : string.Join("\n", registry.Hosts.Order(StringComparer.Ordinal))) + + $"\nRegistry version {registry.Version}. Subdomains match at a dot boundary."; + if (operation is not (PortfolioOperation.Add or PortfolioOperation.Remove)) return "Choose list, add or remove."; + reason = DiscordDisplayText.Literal(reason, 300).Trim(); + if (string.IsNullOrWhiteSpace(reason)) return "A reason is required for this change."; + if (!RecruitmentPortfolioDomains.TryNormalize(domain, out var host)) + return "Enter a DNS hostname below a public suffix, without a URL, path, port, credentials or wildcard."; + var exists = registry.Hosts.Contains(host, StringComparer.Ordinal); + if (exists == (operation == PortfolioOperation.Add)) return exists ? "That site is already configured." : "That site is not configured."; + if (operation == PortfolioOperation.Add && registry.Hosts.Count >= 100) return "The portfolio-site limit is 100 domains. Remove a site first."; + if (registry.Version == long.MaxValue) return "Registry version limit reached; operator review required."; + if (operation == PortfolioOperation.Add) registry.Hosts.Add(host); else registry.Hosts.Remove(host); + registry.Version++; + registry.Audit.Add(new RecruitmentAudit(Now, actor, $"{command.Name} {host}: {reason}")); + if (registry.Audit.Count > 100) registry.Audit.RemoveRange(0, registry.Audit.Count - 100); + // Version mismatch makes open listings due, using the existing bounded scheduling/retry path. + // This policy update does not change starter versions or manufacture member edits. + Save(); + return $"Portfolio site {host} {(operation == PortfolioOperation.Add ? "added" : "removed")}. Retained open listings will refresh in bounded batches; recognition is not endorsement."; + } + +} diff --git a/DiscordBot/Services/Recruitment/Application/RecruitmentAttempts.cs b/DiscordBot/Services/Recruitment/Application/RecruitmentAttempts.cs index dc2bf097..39535c1a 100644 --- a/DiscordBot/Services/Recruitment/Application/RecruitmentAttempts.cs +++ b/DiscordBot/Services/Recruitment/Application/RecruitmentAttempts.cs @@ -27,29 +27,4 @@ public async Task ThreadCreatedAsync(ThreadCreation creation, CancellationToken } // Called once per newly retained thread identity, regardless of later deletion or acknowledgement. - public async Task EligibilityAsync(string guild, string owner, CancellationToken ct) - { - if (State is null || guild != State.GuildId || !await _discord.IsMemberAsync(guild, owner, ct)) - return "This control is only available to current members of the configured server."; - var lines = new List(); - var open = State.Listings.Values.Where(p => p.OwnerId == owner && p.Status == ListingStatus.Open).Take(10).ToArray(); - foreach (var post in open) - lines.Add($"Your {(post.AcceptedAt.HasValue ? "open" : "pending")} listing occupies your slot: <#{post.Id}>. Update or bump this thread to keep its conversation history. Close it when finished; auto-archive does not free your slot."); - if (State.Authors.TryGetValue(owner, out var author)) - { - if (author.LastAcceptedCreation?.AddDays(_settings.CooldownDays) is { } next && next > Now) - lines.Add($"Your accepted-listing cooldown ends {DiscordDisplayText.Timestamp(next)}. Closing or deleting does not reset this cooldown."); - if (author.AttemptPausedUntil > Now) - lines.Add($"Posting attempts are paused until {DiscordDisplayText.Timestamp(author.AttemptPausedUntil.Value)}. A moderator can review a mistake."); - } - if (!EvidenceCurrent) - lines.Add("Eligibility is being checked; the one-listing rule still applies."); - else if (open.Length == 0) - lines.Add("No open listing is recorded."); - lines.Add($"You may have **one open listing across all four recruitment forums**. Pending listings reserve that slot. An accepted listing starts a **{_settings.CooldownDays}-day cooldown from creation**."); - var used = author is null || author.AttemptPausedUntil.HasValue && author.AttemptPausedUntil <= Now - ? 0 : author.RecentCreations.Count(d => d > Now.AddMinutes(-_settings.AttemptWindowMinutes)); - lines.Add($"Anti-spam: creation attempt {_settings.AttemptLimit + 1} within {_settings.AttemptWindowMinutes} minutes triggers a {_settings.AttemptPauseMinutes}-minute pause; deleted attempts count. Recent attempts: {used}."); - return string.Join("\n", lines); - } } diff --git a/DiscordBot/Services/Recruitment/Application/RecruitmentCommands.cs b/DiscordBot/Services/Recruitment/Application/RecruitmentCommands.cs new file mode 100644 index 00000000..a2d4f3c2 --- /dev/null +++ b/DiscordBot/Services/Recruitment/Application/RecruitmentCommands.cs @@ -0,0 +1,36 @@ +namespace DiscordBot.Services.Recruitment.Application; + +public enum StaffOperation { Unknown, Status, Preview, Lookup, Repair, Reconcile, WaiveCooldown, Exempt, Unexempt, ResolveReports, ReviewChanges, ResetAttempts, ResetTimeouts, Reset, Reference, Close, Reopen, ReviewAbsent } +public enum PortfolioOperation { Unknown, List, Add, Remove } +public sealed record StaffCommand(StaffOperation Operation, string Name); +public sealed record PortfolioCommand(PortfolioOperation Operation, string Name); + +public static class RecruitmentCommands +{ + public static StaffCommand Staff(string name) => new(name switch + { + "status" => StaffOperation.Status, + "preview" => StaffOperation.Preview, + "lookup" => StaffOperation.Lookup, + "repair" => StaffOperation.Repair, + "reconcile" => StaffOperation.Reconcile, + "waive-cooldown" => StaffOperation.WaiveCooldown, + "exempt" => StaffOperation.Exempt, + "unexempt" => StaffOperation.Unexempt, + "resolve-reports" => StaffOperation.ResolveReports, + "review-changes" => StaffOperation.ReviewChanges, + "reset-attempts" => StaffOperation.ResetAttempts, + "reset-timeouts" => StaffOperation.ResetTimeouts, + "reset" => StaffOperation.Reset, + "reference" => StaffOperation.Reference, + "close" => StaffOperation.Close, + "reopen" => StaffOperation.Reopen, + "review-absent" => StaffOperation.ReviewAbsent, + _ => StaffOperation.Unknown + }, name); + public static PortfolioCommand Portfolio(string name) => new(name switch + { + "list" => PortfolioOperation.List, "add" => PortfolioOperation.Add, "remove" => PortfolioOperation.Remove, + _ => PortfolioOperation.Unknown + }, name); +} diff --git a/DiscordBot/Services/Recruitment/Application/RecruitmentEngine.cs b/DiscordBot/Services/Recruitment/Application/RecruitmentEngine.cs index 9a75f5c3..9b951b5c 100644 --- a/DiscordBot/Services/Recruitment/Application/RecruitmentEngine.cs +++ b/DiscordBot/Services/Recruitment/Application/RecruitmentEngine.cs @@ -21,16 +21,19 @@ public sealed partial class RecruitmentEngine : IDisposable private bool _ready; private bool _inventoryComplete; private readonly AcknowledgementRecovery _recoverWindows = new(); - private readonly Dictionary _confirmations = new(); private ListingWorkflow? _workflow; private ListingActionExecutor? _actions; private ListingPublisher? _publisher; private ListingReader? _reader; private ListingForums? _listingForums; + private ListingOwnerCommands? _ownerCommands; + private ListingQueries? _queries; + private ListingReportCommands? _reportCommands; + private ListingStaffCommands? _staffCommands; + private PortfolioCommands? _portfolioCommands; public string Status { get; private set; } = "Not started"; public RecruitmentState? State => _store?.State; private DateTimeOffset Now => _time.GetUtcNow(); - private sealed record RemovalConfirmation(string Guild, string Thread, string Owner, long Version, DateTimeOffset Expires); public RecruitmentEngine(BotSettings bot, IRecruitmentDiscord discord, TimeProvider? time = null, Func? pendingThreadWork = null, Func? evidenceGeneration = null, Func? gatewayReady = null, Func? pendingIdentities = null) { @@ -60,6 +63,14 @@ public async Task StartAsync(CancellationToken ct) var facts = new ListingEvidenceCapture(State!, _time, _publisher.QueueAttention); _workflow = new ListingWorkflow(State!, _settings, _discord, _time, Save, _recoverWindows, _reader, _publisher, _actions, facts, _listingForums, () => EvidenceCurrent, ObserveReferenceCandidate); + _ownerCommands = new ListingOwnerCommands(State!, _settings, _discord, _time, Save, _workflow, _actions, + () => _ready, () => _ready && _feedReady && _feedGeneration == _evidenceGeneration(), () => EvidenceCurrent); + _queries = new ListingQueries(State!, _settings, _discord, _time, _ownerCommands, () => EvidenceCurrent); + _reportCommands = new ListingReportCommands(State!, _time, Save, _queries, _publisher, _workflow, + () => _ready && _feedReady && _gatewayReady() && _feedGeneration == _evidenceGeneration()); + _staffCommands = new ListingStaffCommands(State!, _time, Save, _actions, _publisher, _recoverWindows, + async token => { await DiscoverAsync(token); return _inventoryComplete; }); + _portfolioCommands = new PortfolioCommands(State!, _time, Save); _setup = new RecruitmentSetup(_store, _assets, _settings, _discord); _setup.Progress += () => Changed?.Invoke(); MarkEvidenceGap(); @@ -196,78 +207,30 @@ public async Task ReconcileThreadAsync(string thread, CancellationToken ct) public Task ObserveAsync(ThreadSnapshot live, bool observedFromCreation, CancellationToken ct) => !_ready || _workflow is null ? Task.CompletedTask : _workflow.ObserveAsync(live, observedFromCreation, ct); - public async Task AcknowledgeAsync(string guild, string thread, string owner, string code, CancellationToken ct, DateTimeOffset? receivedAt = null) - { - var post = Owner(guild, thread, owner); - if (post is null) return "This control is unavailable or does not belong to you."; - // Startup/recovery establishes cross-forum identity coverage; ordinary acknowledgements only - // refresh the candidate and its blocking author records. - if (!_ready || !_feedReady || _feedGeneration != _evidenceGeneration()) return "Recruitment is waiting for recovery. Please try again shortly."; - if (post.Status != ListingStatus.Open || post.AcceptedAt.HasValue) return "This listing no longer needs acknowledgement."; - var submittedAt = receivedAt ?? Now; - if (!post.Deadline.HasValue || post.Deadline < submittedAt || post.PromptDeliveredAt > submittedAt) - return "The acknowledgement window is unavailable or has expired."; - if (post.LastCodeAttemptAt?.AddSeconds(5) > Now) return "Please wait a few seconds before trying again."; - post.LastCodeAttemptAt = Now; Save(); - if (!post.ValidCodes.Contains(code.Trim().ToUpperInvariant())) return "That code does not match. Read the native Guidelines for this forum and try again."; - // Persist a timely valid submission before any processing can execute an overdue timeout. - // Invalid/late submissions never extend the window or fence the deadline worker. - post.AcknowledgedAt = submittedAt; post.Version++; post.UpdatedAt = Now; - var author = ListingTransitions.Author(State!, owner, Now); - author.ConsecutiveTimeouts = 0; author.TimeoutAlerted = false; - Save(); - _discord.InvalidateThread(post.Id); - post.Fault = null; - await _workflow!.ProcessAsync(post, ct); - if (post.PendingAction is null && post.Status == ListingStatus.Open && !post.AcceptedAt.HasValue) - await _workflow!.ProcessAsync(post, ct); // A recovered timeout intent may have just been cancelled. - if (post.Status != ListingStatus.Open) return "This listing is no longer open. Use your listing controls to check its status."; - return post.AcceptedAt.HasValue ? "Guidelines acknowledged. Your listing is accepted." : !EvidenceCurrent - ? "Guidelines acknowledged. Acceptance is waiting for inventory recovery." - : "Guidelines acknowledged. The existing listing or creation cooldown still blocks acceptance; see the advisory."; - } - - public async Task CloseOwnerAsync(string guild, string thread, string owner, CancellationToken ct) - { - var post = Owner(guild, thread, owner); - if (post is null || post.Status != ListingStatus.Open) return "This open listing does not belong to you or recruitment is unavailable."; - await _actions!.BeginActionAsync(post, RecruitmentActionKind.Close, owner, "Owner closed listing", "", false, ct); - return "Listing closed. Its accepted creation cooldown is unchanged."; - } - - public (string Message, string? Token) PrepareRemoval(string guild, string thread, string owner) - { - var post = Owner(guild, thread, owner); - if (post is null || post.Status is ListingStatus.Deleted or ListingStatus.Reference) return ("This listing is unavailable or does not belong to you.", null); - foreach (var key in _confirmations.Where(x => x.Value.Expires <= Now || x.Value.Owner == owner).Select(x => x.Key).ToArray()) _confirmations.Remove(key); - var token = Guid.NewGuid().ToString("N"); - _confirmations[token] = new RemovalConfirmation(guild, thread, owner, post.Version, Now.AddMinutes(2)); - var next = State!.Authors.GetValueOrDefault(owner)?.LastAcceptedCreation?.AddDays(_settings.CooldownDays); - var timing = !post.AcceptedAt.HasValue ? "Removing this unaccepted attempt adds no accepted-listing cooldown; its creation still counts toward the attempt limit." : next.HasValue - ? "Its creation cooldown remains " + DiscordDisplayText.Timestamp(next.Value) + "." : "The accepted creation cooldown has been waived."; - return ($"Permanently remove this forum post? {timing} This confirmation expires in two minutes.", token); - } - - public async Task ConfirmRemovalAsync(string guild, string thread, string owner, string token, CancellationToken ct) - { - if (!_confirmations.TryGetValue(token, out var confirm) || confirm.Guild != guild || confirm.Thread != thread || confirm.Owner != owner) - return "This confirmation does not belong to this listing and owner."; - _confirmations.Remove(token); - var post = Owner(guild, thread, owner); - if (post is null || confirm.Expires <= Now || confirm.Version != post.Version || post.Status is ListingStatus.Deleted or ListingStatus.Reference) - return "This confirmation expired or the listing changed. Request removal again."; - await _actions!.BeginActionAsync(post, RecruitmentActionKind.Delete, owner, "Owner confirmed removal", "", false, ct); - return "Post removed. Any accepted creation cooldown is unchanged."; - } - - private Listing? Owner(string guild, string thread, string owner) => - _ready && State?.GuildId == guild && State.Listings.TryGetValue(thread, out var post) && post.OwnerId == owner ? post : null; - - public async Task StaffAsync(string guild, string actor, string operation, string target, string reason, CancellationToken ct) + public Task AcknowledgeAsync(string guild, string thread, string owner, string code, CancellationToken ct, DateTimeOffset? receivedAt = null) => + _ownerCommands?.AcknowledgeAsync(guild, thread, owner, code, ct, receivedAt) ?? Task.FromResult("This control is unavailable or does not belong to you."); + public Task CloseOwnerAsync(string guild, string thread, string owner, CancellationToken ct) => + _ownerCommands?.CloseOwnerAsync(guild, thread, owner, ct) ?? Task.FromResult("This open listing does not belong to you or recruitment is unavailable."); + public (string Message, string? Token) PrepareRemoval(string guild, string thread, string owner) => + _ownerCommands?.PrepareRemoval(guild, thread, owner) ?? ("This listing is unavailable or does not belong to you.", null); + public Task ConfirmRemovalAsync(string guild, string thread, string owner, string token, CancellationToken ct) => + _ownerCommands?.ConfirmRemovalAsync(guild, thread, owner, token, ct) ?? Task.FromResult("This confirmation does not belong to this listing and owner."); + public Task EligibilityAsync(string guild, string owner, CancellationToken ct) => + _queries?.EligibilityAsync(guild, owner, ct) ?? Task.FromResult("This control is only available to current members of the configured server."); + public Task DetailsAsync(string guild, string thread, string reader, CancellationToken ct) => + _queries?.DetailsAsync(guild, thread, reader, ct) ?? Task.FromResult("Listing details are unavailable in this server."); + public Task<(string Message, bool Open, bool Removable)> OwnerDetailsAsync(string guild, string thread, string owner, CancellationToken ct) => + _queries?.OwnerDetailsAsync(guild, thread, owner, ct) ?? Task.FromResult(("This listing does not belong to you or recruitment is unavailable.", false, false)); + public Task ReportAsync(string guild, string thread, string reporter, string concern, CancellationToken ct) => + _reportCommands?.ReportAsync(guild, thread, reporter, concern, ct) ?? Task.FromResult("This listing is unavailable or you are not a current server member."); + public Task StaffAsync(string guild, string actor, string operation, string target, string reason, CancellationToken ct) => + StaffAsync(guild, actor, RecruitmentCommands.Staff(operation), target, reason, ct); + public async Task StaffAsync(string guild, string actor, StaffCommand command, string target, string reason, CancellationToken ct) { + var operation = command.Operation; if (guild != _bot.GuildId.ToString() || !await _discord.IsModeratorAsync(guild, actor, ct)) return "Moderator access is required in the configured server."; - if (operation == "status") return Status; - if (operation == "preview") + if (operation == StaffOperation.Status) return Status; + if (operation == StaffOperation.Preview) { if (_assets is null) return "Assets are unavailable; inspect the startup error."; if (_assets.Templates.ContainsKey(target)) @@ -276,15 +239,15 @@ public async Task StaffAsync(string guild, string actor, string operatio "\nSelect a forum on /recruitment preview to read its proposed native Guidelines. Repair publishes these templates and the persisted rotating code."; } if (State is null || !_settings.Enabled) return "Recruitment is unavailable. Enable valid settings and restart first."; - if (operation == "lookup") + if (operation == StaffOperation.Lookup) { - if (State.Listings.TryGetValue(target, out var found)) return EvidenceDetails(found); + if (State.Listings.TryGetValue(target, out var found)) return _queries!.EvidenceDetails(found); if (State.Authors.TryGetValue(target, out var history)) return $"Author `{target}` · next creation: {history.LastAcceptedCreation?.AddDays(_settings.CooldownDays).ToString("u") ?? "none"} · confirmed timeout streak: {history.ConsecutiveTimeouts}"; return "No retained recruitment record for that thread or author."; } if (string.IsNullOrWhiteSpace(reason)) return "A reason is required for this change."; reason = DiscordDisplayText.Literal(reason, 300); - if (operation == "repair") + if (operation == StaffOperation.Repair) { if (target.Length > 0 && !State.Forums.ContainsKey(target)) return "Unknown recruitment forum purpose."; State.Overview.Fault = null; @@ -297,7 +260,7 @@ public async Task StaffAsync(string guild, string actor, string operatio Status = "Setup repaired"; await SweepAsync(ct); return Status; } - if (operation == "reconcile") + if (operation == StaffOperation.Reconcile) { State.Overview.Fault = null; _verifiedReferences.Clear(); @@ -306,68 +269,17 @@ public async Task StaffAsync(string guild, string actor, string operatio Save(); await SweepAsync(ct); return Status; } if (!_ready) return "Recruitment setup needs recovery before listing changes are available."; - if (operation == "waive-cooldown") - { - if (!State.Authors.TryGetValue(target, out var author)) return "Author history was not found."; - // Audit on every retained listing for this author; waiver never changes occupied capacity. - var posts = State.Listings.Values.Where(p => p.OwnerId == target).ToArray(); - if (posts.Length == 0) return "No listing audit record remains; no active cooldown should need a waiver."; - foreach (var p in posts) { _actions!.Audit(p, actor, "Waive cooldown: " + reason); Save(); await _publisher!.PublishStaffAsync(p, ct); } - author.LastAcceptedCreation = null; author.UpdatedAt = Now; Save(); - return "Creation cooldown waived. Existing open listings still occupy the slot."; - } - if (!State.Listings.TryGetValue(target, out var post)) return "Managed thread was not found. Reconcile first."; - _actions!.Audit(post, actor, operation + ": " + reason); Save(); await _publisher!.PublishStaffAsync(post, ct); - switch (operation) - { - case "exempt": post.Exempt = true; break; - case "unexempt": post.Exempt = false; _recoverWindows.Add(post.Id); break; - case "resolve-reports": - foreach (var report in post.Reports.Where(r => r.ResolvedAt is null)) report.ResolvedAt = Now; - break; - case "review-changes": post.ReviewedMaterialChanges = post.MaterialChanges; break; - case "reset-attempts": - var attemptHistory = ListingTransitions.Author(State, post.OwnerId, Now); - attemptHistory.RecentCreations.Clear(); attemptHistory.AttemptPausedUntil = null; - foreach (var attempt in State.Listings.Values.Where(p => p.OwnerId == post.OwnerId)) attempt.AttemptPausedUntil = null; - break; - case "reset-timeouts": - var timeoutHistory = ListingTransitions.Author(State, post.OwnerId, Now); - timeoutHistory.ConsecutiveTimeouts = 0; timeoutHistory.TimeoutAlerted = false; break; - case "reset": - if (post.AcceptedAt.HasValue || post.Status != ListingStatus.Open) return "Only an open unaccepted attempt can have its acknowledgement reset."; - post.AcknowledgedAt = null; post.ValidCodes.Clear(); _recoverWindows.Add(post.Id); - var history = ListingTransitions.Author(State, post.OwnerId, Now); - history.ConsecutiveTimeouts = 0; history.TimeoutAlerted = false; break; - case "reference": - if (post.Status == ListingStatus.Deleted) return "A deleted thread cannot become a reference post."; - post.Status = ListingStatus.Reference; post.PendingAction = null; post.Deadline = null; post.ValidCodes.Clear(); - post.LastOutcome = "Moderator-designated reference post"; post.Version++; Save(); - await _actions!.BeginActionAsync(post, RecruitmentActionKind.RemoveAdvisory, actor, reason, "", false, ct, requiresModerator: true); - return "Reference post designated; bot advisory removed. Any accepted cooldown is preserved."; - case "close": - if (post.Status != ListingStatus.Open) return "Only an open listing can be closed."; - await _actions!.BeginActionAsync(post, RecruitmentActionKind.Close, actor, reason, "", false, ct, requiresModerator: true); return post.Status == ListingStatus.Closed ? "Listing closed." : "Closure was cancelled or is awaiting reconciliation."; - case "reopen": - if (post.Status != ListingStatus.Closed) return "Only a closed listing can be reopened."; - await DiscoverAsync(ct); - if (!_inventoryComplete) return "Inventory is incomplete; reopening is held."; - if (State.Listings.Values.Any(p => p.OwnerId == post.OwnerId && p.Id != post.Id && p.Status == ListingStatus.Open)) - return "Another listing occupies this author's slot."; - await _actions!.BeginActionAsync(post, RecruitmentActionKind.Reopen, actor, reason, "", false, ct, requiresModerator: true); return post.Status == ListingStatus.Open - ? "Listing reopened. Update this thread to preserve its conversation history." : "Reopening was cancelled or is awaiting reconciliation."; - case "review-absent": - if (!post.AvailabilityUnknown) return "Availability is known; use close or reconcile instead."; - post.Status = ListingStatus.Deleted; post.AvailabilityUnknown = false; post.PendingAction = null; - post.PublicSendPending = false; post.Hold = null; - ListingTransitions.Author(State, post.OwnerId, Now); - post.LastOutcome = "Staff confirmed absence"; break; - default: return "Unknown recruitment operation."; - } - post.Version++; post.UpdatedAt = Now; Save(); - if (operation == "review-absent") await _publisher!.PublishStaffAsync(post, ct); - else await _publisher!.PublishAsync(post, ct); - return "Recruitment record updated."; + return await _staffCommands!.ExecuteAsync(actor, command, target, reason, ct); + } + + public Task PortfolioSitesAsync(string guild, string actor, string operation, string domain, string reason, CancellationToken ct) => + PortfolioSitesAsync(guild, actor, RecruitmentCommands.Portfolio(operation), domain, reason, ct); + public async Task PortfolioSitesAsync(string guild, string actor, PortfolioCommand command, string domain, string reason, CancellationToken ct) + { + if (guild != _bot.GuildId.ToString() || !await _discord.IsAdministratorAsync(guild, actor, ct)) + return "Administrator access is required in the configured server."; + if (State is null || !_settings.Enabled) return "Recruitment is unavailable."; + return _portfolioCommands!.Execute(actor, command, domain, reason); } private void Save() diff --git a/DiscordBot/Services/Recruitment/Application/RecruitmentEvidence.cs b/DiscordBot/Services/Recruitment/Application/RecruitmentEvidence.cs deleted file mode 100644 index 08f0e59c..00000000 --- a/DiscordBot/Services/Recruitment/Application/RecruitmentEvidence.cs +++ /dev/null @@ -1,42 +0,0 @@ -using DiscordBot.Services.Recruitment.Domain.Analysis; -using DiscordBot.Utils; -using System.Security.Cryptography; -using System.Text; - -namespace DiscordBot.Services.Recruitment; - -public sealed partial class RecruitmentEngine -{ - private string EvidenceDetails(Listing post) - { - var text = RecruitmentPresentation.Staff(State!, post, _settings).Content + "\n" + - $"Created {DiscordDisplayText.Timestamp(post.CreatedAt)} · accepted {(post.AcceptedAt.HasValue ? DiscordDisplayText.Timestamp(post.AcceptedAt.Value) : "no")}\n"; - var pin = post.PublicPin; - text += $"Optional response pin: {(pin.MessageId is null ? "not attempted" : pin.Confirmed ? "confirmed; later manual unpins respected" : "pending")} · message `{pin.MessageId ?? "none"}`" + - (pin.RestoreArchive ? " · archive/lock restoration pending" : "") + "\n"; - if (pin.Fault is { } pinFault) - text += $"Pin fault: {DiscordDisplayText.Literal(pinFault.Message, 400)} · retry {(pinFault.RetryAt is { } retry ? DiscordDisplayText.Timestamp(retry) : "after relevant access change or explicit recovery")}\n"; - var versions = new[] { post.OriginalStarter }.Concat(post.EarlierMaterialVersions).Append(post.LatestStarter) - .OfType().DistinctBy(v => (v.ObservedAt, v.Fingerprint)); - foreach (var version in versions) - { - text += $"\n**Starter observed {version.ObservedAt:u}{(version.Truncated ? " (first 2,000 characters)" : "")}**\n" + - DiscordDisplayText.Literal(version.Content, 2000) + "\n"; - if (version.Attachments.Count > 0) text += "Attachments: " + string.Join(", ", version.Attachments.Select(a => DiscordDisplayText.Literal(a.Name, 120))) + "\n"; - } - if (post.LatestStarter is { } latest) - { - static IEnumerable Retained(Listing p) => new[] { p.OriginalStarter }.Concat(p.EarlierMaterialVersions) - .Append(p.LatestStarter).OfType(); - var links = Retained(post).SelectMany(v => v.Links).ToHashSet(StringComparer.Ordinal); - var related = State!.Listings.Values.Where(p => p.Id != post.Id && p.OwnerId != post.OwnerId && - Retained(p).Any(other => latest.Content.Length >= 120 && latest.Fingerprint == other.Fingerprint || links.Overlaps(other.Links))) - .OrderByDescending(p => p.CreatedAt).Take(5).Select(p => $"<#{p.Id}> (author `{p.OwnerId}`)").ToArray(); - if (related.Length > 0) text += "\nShared starter text or link with: " + string.Join(", ", related) + ". Review context; shared projects can be legitimate.\n"; - } - foreach (var report in post.Reports) - text += $"\n**Private report · {(report.ResolvedAt.HasValue ? "resolved" : "open")} · {report.At:u} · reporter `{report.ReporterId}`**\n{DiscordDisplayText.Literal(report.Concern, 1000)}\n"; - foreach (var audit in post.Audit.TakeLast(10)) text += $"\n{audit.At:u} · `{audit.Actor}` · {DiscordDisplayText.Literal(audit.Reason, 300)}"; - return text; - } -} diff --git a/DiscordBot/Services/Recruitment/Application/RecruitmentPortfolioCommands.cs b/DiscordBot/Services/Recruitment/Application/RecruitmentPortfolioCommands.cs deleted file mode 100644 index 4754dc26..00000000 --- a/DiscordBot/Services/Recruitment/Application/RecruitmentPortfolioCommands.cs +++ /dev/null @@ -1,37 +0,0 @@ -using DiscordBot.Utils; -using System.Globalization; -using System.IO; - -namespace DiscordBot.Services.Recruitment; - -public sealed partial class RecruitmentEngine -{ - public async Task PortfolioSitesAsync(string guild, string actor, string operation, string domain, string reason, CancellationToken ct) - { - if (guild != _bot.GuildId.ToString() || !await _discord.IsAdministratorAsync(guild, actor, ct)) - return "Administrator access is required in the configured server."; - if (State is null || !_settings.Enabled) return "Recruitment is unavailable."; - var registry = State.PortfolioSites; - if (operation == "list") - return "Recognized portfolio sites (recognition is not endorsement):\n" + - (registry.Hosts.Count == 0 ? "None configured." : string.Join("\n", registry.Hosts.Order(StringComparer.Ordinal))) + - $"\nRegistry version {registry.Version}. Subdomains match at a dot boundary."; - if (operation is not ("add" or "remove")) return "Choose list, add or remove."; - reason = DiscordDisplayText.Literal(reason, 300).Trim(); - if (string.IsNullOrWhiteSpace(reason)) return "A reason is required for this change."; - if (!RecruitmentPortfolioDomains.TryNormalize(domain, out var host)) - return "Enter a DNS hostname below a public suffix, without a URL, path, port, credentials or wildcard."; - var exists = registry.Hosts.Contains(host, StringComparer.Ordinal); - if (exists == (operation == "add")) return exists ? "That site is already configured." : "That site is not configured."; - if (operation == "add" && registry.Hosts.Count >= 100) return "The portfolio-site limit is 100 domains. Remove a site first."; - if (registry.Version == long.MaxValue) return "Registry version limit reached; operator review required."; - if (operation == "add") registry.Hosts.Add(host); else registry.Hosts.Remove(host); - registry.Version++; - registry.Audit.Add(new RecruitmentAudit(Now, actor, $"{operation} {host}: {reason}")); - if (registry.Audit.Count > 100) registry.Audit.RemoveRange(0, registry.Audit.Count - 100); - // Version mismatch makes open listings due, using the existing bounded scheduling/retry path. - // This policy update does not change starter versions or manufacture member edits. - Save(); - return $"Portfolio site {host} {(operation == "add" ? "added" : "removed")}. Retained open listings will refresh in bounded batches; recognition is not endorsement."; - } -} diff --git a/DiscordBot/Services/Recruitment/Presentation/RecruitmentQueryFormatter.cs b/DiscordBot/Services/Recruitment/Presentation/RecruitmentQueryFormatter.cs new file mode 100644 index 00000000..d4e46554 --- /dev/null +++ b/DiscordBot/Services/Recruitment/Presentation/RecruitmentQueryFormatter.cs @@ -0,0 +1,60 @@ +using DiscordBot.Utils; + +namespace DiscordBot.Services.Recruitment.Presentation; + +internal sealed record EligibilityListing(string Id, bool Accepted); +internal sealed record EligibilityView(IReadOnlyList Open, bool HasAuthor, DateTimeOffset? NextAllowedAt, + DateTimeOffset? AttemptPausedUntil, DateTimeOffset Now, bool EvidenceCurrent, int RecentAttempts, + int CooldownDays, int AttemptLimit, int AttemptWindowMinutes, int AttemptPauseMinutes); +internal sealed record RelatedListing(string Id, string OwnerId); +internal sealed record PrivateEvidenceView(string StaffSummary, DateTimeOffset CreatedAt, DateTimeOffset? AcceptedAt, + MessagePinReceipt Pin, IReadOnlyList Versions, IReadOnlyList Related, + IReadOnlyList Reports, IReadOnlyList Audit); + +internal static class RecruitmentQueryFormatter +{ + public static string Eligibility(EligibilityView view) + { + var lines = new List(); + foreach (var post in view.Open) + lines.Add($"Your {(post.Accepted ? "open" : "pending")} listing occupies your slot: <#{post.Id}>. Update or bump this thread to keep its conversation history. Close it when finished; auto-archive does not free your slot."); + if (view.HasAuthor) + { + if (view.NextAllowedAt is { } next && next > view.Now) + lines.Add($"Your accepted-listing cooldown ends {DiscordDisplayText.Timestamp(next)}. Closing or deleting does not reset this cooldown."); + if (view.AttemptPausedUntil > view.Now) + lines.Add($"Posting attempts are paused until {DiscordDisplayText.Timestamp(view.AttemptPausedUntil.Value)}. A moderator can review a mistake."); + } + if (!view.EvidenceCurrent) + lines.Add("Eligibility is being checked; the one-listing rule still applies."); + else if (view.Open.Count == 0) + lines.Add("No open listing is recorded."); + lines.Add($"You may have **one open listing across all four recruitment forums**. Pending listings reserve that slot. An accepted listing starts a **{view.CooldownDays}-day cooldown from creation**."); + var used = view.RecentAttempts; + lines.Add($"Anti-spam: creation attempt {view.AttemptLimit + 1} within {view.AttemptWindowMinutes} minutes triggers a {view.AttemptPauseMinutes}-minute pause; deleted attempts count. Recent attempts: {used}."); + return string.Join("\n", lines); + } + + public static string Evidence(PrivateEvidenceView view) + { + var text = view.StaffSummary + "\n" + + $"Created {DiscordDisplayText.Timestamp(view.CreatedAt)} · accepted {(view.AcceptedAt.HasValue ? DiscordDisplayText.Timestamp(view.AcceptedAt.Value) : "no")}\n"; + var pin = view.Pin; + text += $"Optional response pin: {(pin.MessageId is null ? "not attempted" : pin.Confirmed ? "confirmed; later manual unpins respected" : "pending")} · message `{pin.MessageId ?? "none"}`" + + (pin.RestoreArchive ? " · archive/lock restoration pending" : "") + "\n"; + if (pin.Fault is { } pinFault) + text += $"Pin fault: {DiscordDisplayText.Literal(pinFault.Message, 400)} · retry {(pinFault.RetryAt is { } retry ? DiscordDisplayText.Timestamp(retry) : "after relevant access change or explicit recovery")}\n"; + foreach (var version in view.Versions) + { + text += $"\n**Starter observed {version.ObservedAt:u}{(version.Truncated ? " (first 2,000 characters)" : "")}**\n" + + DiscordDisplayText.Literal(version.Content, 2000) + "\n"; + if (version.Attachments.Count > 0) text += "Attachments: " + string.Join(", ", version.Attachments.Select(a => DiscordDisplayText.Literal(a.Name, 120))) + "\n"; + } + if (view.Related.Count > 0) text += "\nShared starter text or link with: " + + string.Join(", ", view.Related.Select(p => $"<#{p.Id}> (author `{p.OwnerId}`)")) + ". Review context; shared projects can be legitimate.\n"; + foreach (var report in view.Reports) + text += $"\n**Private report · {(report.ResolvedAt.HasValue ? "resolved" : "open")} · {report.At:u} · reporter `{report.ReporterId}`**\n{DiscordDisplayText.Literal(report.Concern, 1000)}\n"; + foreach (var audit in view.Audit) text += $"\n{audit.At:u} · `{audit.Actor}` · {DiscordDisplayText.Literal(audit.Reason, 300)}"; + return text; + } +} diff --git a/DiscordBot/Services/Recruitment/RecruitmentService.cs b/DiscordBot/Services/Recruitment/RecruitmentService.cs index eb5c78ed..7521db36 100644 --- a/DiscordBot/Services/Recruitment/RecruitmentService.cs +++ b/DiscordBot/Services/Recruitment/RecruitmentService.cs @@ -1,3 +1,4 @@ +using DiscordBot.Services.Recruitment.Application; using DiscordBot.Utils; using System.Threading.Channels; using System.Collections.Concurrent; @@ -305,7 +306,7 @@ public bool CanReadStatus(string guild, string actor) => guild == _settings.Guil ulong.TryParse(actor, out var id) && _client?.GetGuild(_settings.GuildId)?.GetUser(id) is { } user && (user.GuildPermissions.Administrator || user.Roles.Any(r => r.Id == _settings.Roles.Moderator)); - public string QueueRecovery(string guild, string actor, string operation, string target, string reason) + public string QueueRecovery(string guild, string actor, StaffCommand operation, string target, string reason) { if (!CanReadStatus(guild, actor)) return "Moderator access is required in the configured server."; if (string.IsNullOrWhiteSpace(reason)) return "A reason is required for this change."; diff --git a/DiscordBot/Utils/DiscordReplyChunks.cs b/DiscordBot/Utils/DiscordReplyChunks.cs new file mode 100644 index 00000000..f1308fd2 --- /dev/null +++ b/DiscordBot/Utils/DiscordReplyChunks.cs @@ -0,0 +1,17 @@ +namespace DiscordBot.Utils; + +public static class DiscordReplyChunks +{ + public static IEnumerable Split(string text, int limit = 1900) + { + if (limit < 2) throw new ArgumentOutOfRangeException(nameof(limit)); + for (var offset = 0; offset < text.Length;) + { + var length = Math.Min(limit, text.Length - offset); + // Keep a supplementary Unicode character intact while preserving every input character. + if (offset + length < text.Length && char.IsHighSurrogate(text[offset + length - 1]) && char.IsLowSurrogate(text[offset + length])) length--; + yield return text.Substring(offset, length); + offset += length; + } + } +} From b0b28627a6d0398c6389a3801ccfb73237e80ece Mon Sep 17 00:00:00 2001 From: James Kellie Date: Mon, 19 Oct 2026 21:19:58 +1000 Subject: [PATCH 30/31] Separate forum setup and inventory recovery --- .../Application/InventoryRecovery.cs | 43 ++++++ .../Application/RecruitmentEngine.cs | 104 +++++++-------- .../RecruitmentEngineScheduling.cs | 6 +- .../Application/RecruitmentReferences.cs | 90 ------------- .../Application/RecruitmentSetup.cs | 98 +++++++------- .../Application/ReferenceReconciler.cs | 123 ++++++++++++++++++ .../Recruitment/Application/SetupRequest.cs | 4 + .../Domain/Policies/ForumSetupPlan.cs | 39 ++++++ .../Discord/DiscordInventoryReader.cs | 59 +++++++++ .../Discord/DiscordRecruitmentAdapter.cs | 53 +------- 10 files changed, 369 insertions(+), 250 deletions(-) create mode 100644 DiscordBot/Services/Recruitment/Application/InventoryRecovery.cs delete mode 100644 DiscordBot/Services/Recruitment/Application/RecruitmentReferences.cs create mode 100644 DiscordBot/Services/Recruitment/Application/ReferenceReconciler.cs create mode 100644 DiscordBot/Services/Recruitment/Application/SetupRequest.cs create mode 100644 DiscordBot/Services/Recruitment/Domain/Policies/ForumSetupPlan.cs create mode 100644 DiscordBot/Services/Recruitment/Infrastructure/Discord/DiscordInventoryReader.cs diff --git a/DiscordBot/Services/Recruitment/Application/InventoryRecovery.cs b/DiscordBot/Services/Recruitment/Application/InventoryRecovery.cs new file mode 100644 index 00000000..ec0d5417 --- /dev/null +++ b/DiscordBot/Services/Recruitment/Application/InventoryRecovery.cs @@ -0,0 +1,43 @@ +using DiscordBot.Services.Recruitment.Domain.Policies; + +namespace DiscordBot.Services.Recruitment.Application; + +internal sealed class InventoryRecovery(RecruitmentState state, IRecruitmentDiscord discord, AcknowledgementRecovery windows, + ReferenceReconciler references, ListingWorkflow workflow, Func currentGeneration, Action save) +{ + private readonly RecruitmentState State = state; + private readonly IRecruitmentDiscord _discord = discord; + private readonly AcknowledgementRecovery _recoverWindows = windows; + private void Save() => save(); + public bool Complete { get; private set; } + public long Generation { get; private set; } = -1; + public void Invalidate() { Complete = false; Generation = -1; } + public async Task DiscoverAsync(CancellationToken ct) + { + var state = State!; + var generation = currentGeneration(); + var cursors = state.Forums.Values.ToDictionary(f => f.ForumId, f => + { + var cursor = f.ArchiveCursor ?? new ArchiveCursor(state.DiscoveryFloor); + return f.Reference.CreatePending && f.Reference.CreateStartedAt is { } started && cursor.Since > started.AddMinutes(-5) + ? new ArchiveCursor(started.AddMinutes(-5)) : cursor; + }); + var known = state.Listings.Keys.Concat(references.KnownIgnored(state.DiscoveryFloor)) + .Concat(state.Forums.Values.Select(f => f.Reference.ThreadId).OfType()).ToHashSet(); + var inventory = await _discord.InventoryAsync(state.DiscoveryFloor, known, cursors, ct); + Complete = inventory.Complete; + Generation = generation; + references.BeginInventory(); + foreach (var live in inventory.Threads.OrderBy(t => t.CreatedAt).ThenBy(t => ulong.Parse(t.Id))) + { + references.ObserveReferenceCandidate(live); + await workflow.ObserveAsync(live, false, ct); + } + if (inventory.Cursors is not null) + foreach (var forum in state.Forums.Values) forum.ArchiveCursor = inventory.Cursors[forum.ForumId]; + if (!Complete) + foreach (var p in state.Listings.Values.Where(p => p.Status == ListingStatus.Open && !p.AcceptedAt.HasValue)) _recoverWindows.Add(p.Id); + Save(); + } + +} diff --git a/DiscordBot/Services/Recruitment/Application/RecruitmentEngine.cs b/DiscordBot/Services/Recruitment/Application/RecruitmentEngine.cs index 9b951b5c..dac149de 100644 --- a/DiscordBot/Services/Recruitment/Application/RecruitmentEngine.cs +++ b/DiscordBot/Services/Recruitment/Application/RecruitmentEngine.cs @@ -19,7 +19,10 @@ public sealed partial class RecruitmentEngine : IDisposable private RecruitmentAssets? _assets; private RecruitmentSetup? _setup; private bool _ready; - private bool _inventoryComplete; + private InventoryRecovery? _inventory; + private ReferenceReconciler? _references; + private bool _inventoryComplete => _inventory?.Complete == true; + private long _inventoryGeneration => _inventory?.Generation ?? -1; private readonly AcknowledgementRecovery _recoverWindows = new(); private ListingWorkflow? _workflow; private ListingActionExecutor? _actions; @@ -55,6 +58,8 @@ public async Task StartAsync(CancellationToken ct) _assets = new RecruitmentAssets(_bot.AssetsRootPath, _settings); _store = new RecruitmentStateStore(_bot.ServerRootPath, _bot.GuildId.ToString(), _settings.Forums.Mapping().ToDictionary(x => x.Key, x => x.Value.ToString()), Now); + _references = new ReferenceReconciler(State!, _assets, _discord, _time, Save, () => EvidenceCurrent, + () => _nextAudit = Now.AddSeconds(10)); _listingForums = new ListingForums(State!, _assets); _publisher = new ListingPublisher(State!, _settings, _discord, _time, Save, GuardFeedWrite); _reader = new ListingReader(State!, _settings, _discord, _time, Save, _recoverWindows); @@ -62,7 +67,8 @@ public async Task StartAsync(CancellationToken ct) () => EvidenceCurrent, _pendingThreadWork); var facts = new ListingEvidenceCapture(State!, _time, _publisher.QueueAttention); _workflow = new ListingWorkflow(State!, _settings, _discord, _time, Save, _recoverWindows, _reader, _publisher, _actions, - facts, _listingForums, () => EvidenceCurrent, ObserveReferenceCandidate); + facts, _listingForums, () => EvidenceCurrent, _references.ObserveReferenceCandidate); + _inventory = new InventoryRecovery(State!, _discord, _recoverWindows, _references, _workflow, _evidenceGeneration, Save); _ownerCommands = new ListingOwnerCommands(State!, _settings, _discord, _time, Save, _workflow, _actions, () => _ready, () => _ready && _feedReady && _feedGeneration == _evidenceGeneration(), () => EvidenceCurrent); _queries = new ListingQueries(State!, _settings, _discord, _time, _ownerCommands, () => EvidenceCurrent); @@ -83,12 +89,11 @@ public async Task StartAsync(CancellationToken ct) public void MarkEvidenceGap(bool invalidateAccess = true) { if (State is null) return; - _inventoryComplete = false; - _inventoryGeneration = -1; + _inventory?.Invalidate(); _accessInvalid = true; if (invalidateAccess) _feedReady = false; _discord.InvalidateCache(); - _verifiedReferences.Clear(); _referenceCandidates.Clear(); _ignoredThreads.Clear(); + _references!.Invalidate(); foreach (var post in State.Listings.Values.Where(p => p.Status == ListingStatus.Open)) { if (!post.AcceptedAt.HasValue) _recoverWindows.Add(post.Id); @@ -107,19 +112,10 @@ public async Task SweepAsync(CancellationToken ct, bool retryHeld = true) _discord.InvalidateCache(); try { - if (_accessInvalid || auditDue || retryHeld) - { - _feedReady = false; - var feedGeneration = _evidenceGeneration(); - _feedEmbeds = await _discord.ValidateFeedAsync(ct); - _feedGeneration = feedGeneration; - _feedReady = true; - if (!held) await _discord.ValidateAccessAsync(ct); - _accessInvalid = false; - } + if (_accessInvalid || auditDue || retryHeld) await RefreshAccessAsync(held, ct); if (held) { Changed?.Invoke(); return; } State!.RecoveryFault = null; - await _setup.ReconcileAsync(Now, false, ct, retryHeld); + await _setup.ReconcileAsync(Now, new SetupRequest(SetupIntent.Reconcile, RetryHeld: retryHeld), ct); if (State!.Forums.Values.FirstOrDefault(f => f.Fault is not null) is { } failed) { _ready = false; @@ -130,23 +126,8 @@ public async Task SweepAsync(CancellationToken ct, bool retryHeld = true) _ready = true; State.RecoveryFault = null; await DiscoverAsync(ct); - if (retryHeld) - foreach (var post in State.Listings.Values) { post.PublicPin.Fault = null; post.AcknowledgementNotice.Fault = null; } - if (retryHeld) - foreach (var notice in State.Listings.Values.SelectMany(p => p.Attention).Where(n => n.MessageId is null || n.SendPending)) notice.Fault = null; - var cursor = ulong.TryParse(State.SweepAfterId, out var savedCursor) ? savedCursor : 0; - var batch = State.Listings.Values - .Where(p => p.Status == ListingStatus.Open || p.PendingAction is not null || p.StaffSendPending || p.PublicSendPending || p.PublicationPending || _publisher!.AttentionDue(p) || _publisher!.PinDue(p)) - .OrderBy(p => ulong.Parse(p.Id) <= cursor).ThenBy(p => ulong.Parse(p.Id)).Take(10).ToArray(); - foreach (var post in batch) - { - ct.ThrowIfCancellationRequested(); - if (retryHeld) { post.Fault = null; post.PublicPin.Fault = null; } - _discord.InvalidateFacts(post.Id); - await _workflow!.ProcessSafelyAsync(post, ct, verifyHeld: true); - } - if (batch.Length > 0) State.SweepAfterId = batch[^1].Id; - await ReconcileReferencesAsync(ct, retryHeld); + await ReconcileListingBatchAsync(State, retryHeld, ct); + await _references!.ReconcileAsync(ct, retryHeld); RecruitmentRetention.Prune(State, Now, _settings.PostRetentionMonths, _settings.AuthorRetentionMonths, _settings.CooldownDays); if (EvidenceCurrent) State.LastSweepAt = Now; else _nextAudit = Now.AddSeconds(10); @@ -167,34 +148,39 @@ public async Task SweepAsync(CancellationToken ct, bool retryHeld = true) Changed?.Invoke(); } - private async Task DiscoverAsync(CancellationToken ct) + private async Task RefreshAccessAsync(bool held, CancellationToken ct) { - var state = State!; - var generation = _evidenceGeneration(); - var cursors = state.Forums.Values.ToDictionary(f => f.ForumId, f => - { - var cursor = f.ArchiveCursor ?? new ArchiveCursor(state.DiscoveryFloor); - return f.Reference.CreatePending && f.Reference.CreateStartedAt is { } started && cursor.Since > started.AddMinutes(-5) - ? new ArchiveCursor(started.AddMinutes(-5)) : cursor; - }); - foreach (var id in _ignoredThreads.Where(p => p.Value < state.DiscoveryFloor).Select(p => p.Key).ToArray()) _ignoredThreads.Remove(id); - var known = state.Listings.Keys.Concat(_ignoredThreads.Keys).Concat(state.Forums.Values.Select(f => f.Reference.ThreadId).OfType()).ToHashSet(); - var inventory = await _discord.InventoryAsync(state.DiscoveryFloor, known, cursors, ct); - _inventoryComplete = inventory.Complete; - _inventoryGeneration = generation; - _unreadableReferenceForums.Clear(); - foreach (var live in inventory.Threads.OrderBy(t => t.CreatedAt).ThenBy(t => ulong.Parse(t.Id))) + _feedReady = false; + var feedGeneration = _evidenceGeneration(); + _feedEmbeds = await _discord.ValidateFeedAsync(ct); + _feedGeneration = feedGeneration; + _feedReady = true; + if (!held) await _discord.ValidateAccessAsync(ct); + _accessInvalid = false; + } + + private async Task ReconcileListingBatchAsync(RecruitmentState state, bool retryHeld, CancellationToken ct) + { + if (retryHeld) + foreach (var post in state.Listings.Values) { post.PublicPin.Fault = null; post.AcknowledgementNotice.Fault = null; } + if (retryHeld) + foreach (var notice in state.Listings.Values.SelectMany(p => p.Attention).Where(n => n.MessageId is null || n.SendPending)) notice.Fault = null; + var cursor = ulong.TryParse(state.SweepAfterId, out var savedCursor) ? savedCursor : 0; + var batch = state.Listings.Values + .Where(p => p.Status == ListingStatus.Open || p.PendingAction is not null || p.StaffSendPending || p.PublicSendPending || p.PublicationPending || _publisher!.AttentionDue(p) || _publisher!.PinDue(p)) + .OrderBy(p => ulong.Parse(p.Id) <= cursor).ThenBy(p => ulong.Parse(p.Id)).Take(10).ToArray(); + foreach (var post in batch) { - ObserveReferenceCandidate(live); - await ObserveAsync(live, false, ct); + ct.ThrowIfCancellationRequested(); + if (retryHeld) { post.Fault = null; post.PublicPin.Fault = null; } + _discord.InvalidateFacts(post.Id); + await _workflow!.ProcessSafelyAsync(post, ct, verifyHeld: true); } - if (inventory.Cursors is not null) - foreach (var forum in state.Forums.Values) forum.ArchiveCursor = inventory.Cursors[forum.ForumId]; - if (!_inventoryComplete) - foreach (var p in state.Listings.Values.Where(p => p.Status == ListingStatus.Open && !p.AcceptedAt.HasValue)) _recoverWindows.Add(p.Id); - Save(); + if (batch.Length > 0) state.SweepAfterId = batch[^1].Id; } + private Task DiscoverAsync(CancellationToken ct) => _inventory!.DiscoverAsync(ct); + public async Task ReconcileThreadAsync(string thread, CancellationToken ct) { if (!_ready || State?.Listings.GetValueOrDefault(thread) is not { } post) return; @@ -251,19 +237,19 @@ public async Task StaffAsync(string guild, string actor, StaffCommand co { if (target.Length > 0 && !State.Forums.ContainsKey(target)) return "Unknown recruitment forum purpose."; State.Overview.Fault = null; - _verifiedReferences.Clear(); + _references!.ClearVerified(); State.SetupAudit.Add(new RecruitmentAudit(Now, actor, "Repair forum setup: " + reason)); if (State.SetupAudit.Count > 30) State.SetupAudit.RemoveAt(0); Save(); await _discord.ValidateAccessAsync(ct); - await _setup!.ReconcileAsync(Now, true, ct, target: string.IsNullOrEmpty(target) ? null : target); + await _setup!.ReconcileAsync(Now, new SetupRequest(SetupIntent.Repair, Target: string.IsNullOrEmpty(target) ? null : target), ct); Status = "Setup repaired"; await SweepAsync(ct); return Status; } if (operation == StaffOperation.Reconcile) { State.Overview.Fault = null; - _verifiedReferences.Clear(); + _references!.ClearVerified(); State.SetupAudit.Add(new RecruitmentAudit(Now, actor, "Reconcile: " + reason)); if (State.SetupAudit.Count > 30) State.SetupAudit.RemoveAt(0); Save(); await SweepAsync(ct); return Status; diff --git a/DiscordBot/Services/Recruitment/Application/RecruitmentEngineScheduling.cs b/DiscordBot/Services/Recruitment/Application/RecruitmentEngineScheduling.cs index 6025484a..a532bd2d 100644 --- a/DiscordBot/Services/Recruitment/Application/RecruitmentEngineScheduling.cs +++ b/DiscordBot/Services/Recruitment/Application/RecruitmentEngineScheduling.cs @@ -7,7 +7,6 @@ public sealed partial class RecruitmentEngine private readonly Func _evidenceGeneration; private readonly Func _gatewayReady; private readonly Func _pendingIdentities; - private long _inventoryGeneration = -1; private DateTimeOffset _nextAudit = DateTimeOffset.MaxValue; private bool _accessInvalid = true; private bool EvidenceCurrent => _gatewayReady() && !_pendingIdentities() && _inventoryComplete && _inventoryGeneration == _evidenceGeneration(); @@ -119,7 +118,7 @@ public async Task ThreadChangedAsync(string id, CancellationToken ct) { var live = await _discord.GetThreadAsync(id, ct); // ThreadCreated also signals access to an existing thread; it cannot certify reply coverage. - if (live is not null) { ObserveReferenceCandidate(live); await ObserveAsync(live, false, ct); } + if (live is not null) { _references!.ObserveReferenceCandidate(live); await ObserveAsync(live, false, ct); } if (!State.Listings.TryGetValue(id, out post)) return; } post.Fault = null; @@ -131,8 +130,7 @@ public async Task ThreadDeletedAsync(string id, CancellationToken ct) _discord.InvalidateThread(id); if (State?.Forums.Values.FirstOrDefault(f => f.Reference.ThreadId == id) is { } forum) { - forum.Reference = new ReferenceReceipt(); _verifiedReferences.Remove(forum.ForumId); - _referenceCandidates.Remove(forum.ForumId); _ignoredThreads.Remove(id); + forum.Reference = new ReferenceReceipt(); _references!.Forget(forum.ForumId, id); RequestRecovery(); Save(); } if (State?.Listings.GetValueOrDefault(id) is not { } post) return; diff --git a/DiscordBot/Services/Recruitment/Application/RecruitmentReferences.cs b/DiscordBot/Services/Recruitment/Application/RecruitmentReferences.cs deleted file mode 100644 index ac45b326..00000000 --- a/DiscordBot/Services/Recruitment/Application/RecruitmentReferences.cs +++ /dev/null @@ -1,90 +0,0 @@ -using DiscordBot.Services.Recruitment.Infrastructure.Discord; -namespace DiscordBot.Services.Recruitment; - -public sealed partial class RecruitmentEngine -{ - private readonly HashSet _verifiedReferences = new(); - private readonly Dictionary _referenceCandidates = new(); - private readonly Dictionary _ignoredThreads = new(); - private readonly HashSet _unreadableReferenceForums = new(); - private string ReferenceMarker(string forum) => $"recruitment:reference:{State!.InstanceId}:{forum}"; - - private void ObserveReferenceCandidate(ThreadSnapshot live) - { - if (!live.OwnerIsBot && !live.OwnerIsStaff) return; - if (live.OwnerIsBot && live.Starter is null) - { - _unreadableReferenceForums.Add(live.ForumId); - return; // The missing starter could contain an interrupted create's marker; keep it discoverable. - } - // Retain ignored identities for this recovery pass so 50 staff/bot posts cannot starve member discovery. - _ignoredThreads[live.Id] = live.CreatedAt; - var owned = live.OwnerIsBot && live.Starter?.Contains(ReferenceMarker(live.ForumId), StringComparison.Ordinal) == true; - if (owned || live.OwnerIsStaff && live.Pinned && !_referenceCandidates.ContainsKey(live.ForumId)) - _referenceCandidates[live.ForumId] = (live.Id, owned); - } - - private async Task ReconcileReferencesAsync(CancellationToken ct, bool retryHeld) - { - foreach (var (purpose, forum) in State!.Forums) - { - var receipt = forum.Reference; - if (_verifiedReferences.Contains(forum.ForumId) || !retryHeld && receipt.Fault is { } fault && !fault.IsDue(Now)) continue; - // Reuse the normal bounded inventory; never create while discovery could still hide a prior send. - if (!EvidenceCurrent) { _nextAudit = Now.AddSeconds(10); return; } - try - { - var marker = ReferenceMarker(forum.ForumId); - if (receipt.ThreadId is not null && !await _discord.SecureReferenceAsync(forum.ForumId, receipt.ThreadId, receipt.BotOwned, marker, ct)) - { - _referenceCandidates.Remove(forum.ForumId); _ignoredThreads.Remove(receipt.ThreadId); - receipt.ThreadId = null; receipt.CreatePending = false; Save(); - } - if (receipt.ThreadId is null) - { - // Older versions retained designated references as listings. Normal inventory skips - // those known IDs; check them before creating another pinned post. - if (!_referenceCandidates.ContainsKey(forum.ForumId) && !receipt.CreatePending) - { - var retained = State.Listings.Values.Where(p => p.ForumId == forum.ForumId && - p.Status != ListingStatus.Deleted && (p.Status == ListingStatus.Reference || p.Pinned)).ToArray(); - foreach (var post in retained.Take(10)) - { - if (await _discord.GetThreadAsync(post.Id, ct, refresh: true) is { } live) ObserveReferenceCandidate(live); - if (_referenceCandidates.ContainsKey(forum.ForumId)) break; - } - if (retained.Length > 10 && !_referenceCandidates.ContainsKey(forum.ForumId)) - throw new InvalidOperationException("Reference discovery exceeded 10 retained candidates; review earlier reference designations."); - } - if (_unreadableReferenceForums.Contains(forum.ForumId)) - throw new TimeoutException("Reference discovery is waiting for a bot-owned starter message."); - if (_referenceCandidates.TryGetValue(forum.ForumId, out var candidate) && (!receipt.CreatePending || candidate.BotOwned)) - { - receipt.ThreadId = candidate.Id; receipt.BotOwned = candidate.BotOwned; receipt.CreatePending = false; Save(); - } - else - { - if (receipt.CreatePending && receipt.CreateStartedAt < State.DiscoveryFloor) - throw new InvalidOperationException("Reference creation predates retained discovery; inspect the forum before retrying."); - receipt.CreateStartedAt = Now; receipt.CreatePending = true; receipt.BotOwned = true; Save(); - var closed = forum.Tags[_assets!.Tags[purpose].Single(t => t.Closing).Key].Id; - receipt.ThreadId = await _discord.CreateReferenceAsync(forum.ForumId, closed, marker, ct); - receipt.CreatePending = false; Save(); // Identity is durable before pin/lock writes. - } - if (!await _discord.SecureReferenceAsync(forum.ForumId, receipt.ThreadId, receipt.BotOwned, marker, ct)) - { - _ignoredThreads.Remove(receipt.ThreadId); _referenceCandidates.Remove(forum.ForumId); - receipt.ThreadId = null; Save(); - throw new TimeoutException("Reference disappeared before pinning; discovery will retry."); - } - } - receipt.Fault = null; _verifiedReferences.Add(forum.ForumId); Save(); - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not System.IO.IOException) - { - receipt.Fault = RecruitmentFaultClassifier.From(ex, Now, receipt.Fault); - Save(); // Reference availability does not block otherwise valid member listings. - } - } - } -} diff --git a/DiscordBot/Services/Recruitment/Application/RecruitmentSetup.cs b/DiscordBot/Services/Recruitment/Application/RecruitmentSetup.cs index fe1ff558..39de88d3 100644 --- a/DiscordBot/Services/Recruitment/Application/RecruitmentSetup.cs +++ b/DiscordBot/Services/Recruitment/Application/RecruitmentSetup.cs @@ -1,3 +1,4 @@ +using DiscordBot.Services.Recruitment.Domain.Policies; using DiscordBot.Services.Recruitment.Infrastructure.Discord; using System.IO; using System.Security.Cryptography; @@ -17,8 +18,9 @@ public sealed class RecruitmentSetup public RecruitmentSetup(RecruitmentStateStore store, RecruitmentAssets assets, RecruitmentSettings settings, IRecruitmentDiscord discord) => (_store, _assets, _settings, _discord) = (store, assets, settings, discord); - public async Task ReconcileAsync(DateTimeOffset now, bool repair, CancellationToken ct, bool retryHeld = true, string? target = null) + public async Task ReconcileAsync(DateTimeOffset now, SetupRequest request, CancellationToken ct) { + var (repair, retryHeld, target) = (request.Intent == SetupIntent.Repair, request.RetryHeld, request.Target); var state = _store.State; if (state.RotationCode.Length == 0 || (now >= state.RotateAt && state.Forums.Values.All(f => f.PendingTopic is null && f.VisibleCode == state.RotationCode))) { @@ -50,62 +52,56 @@ private async Task ReconcileForumAsync(string purpose, ForumReceipt receipt, boo { var state = _store.State; var live = await _discord.GetForumAsync(receipt.ForumId, ct); - var topic = receipt.PendingTopic ?? _assets.Render(purpose, state.RotationCode, _settings); - var code = receipt.PendingCode ?? state.RotationCode; - var definitions = receipt.PendingTags ?? _assets.Tags[purpose]; - if (!repair && live.Topic != receipt.ConfirmedTopic && live.Topic != receipt.PendingTopic) - throw new InvalidOperationException($"Guidelines changed outside the bot in {purpose}; preview and repair setup."); - if (!repair && receipt.ConfirmedTopic.Length > 0 && receipt.PendingTopic is null && !live.RequireTag) - throw new InvalidOperationException($"Require Tag was disabled outside the bot in {purpose}; preview and repair setup."); - if (!repair && receipt.ConfirmedTopic.Length == 0 && receipt.PendingTopic is null && live.Tags.Count > 0) - throw new InvalidOperationException($"Unexpected existing tags in {purpose}; preview and repair setup."); - if (receipt.Tags.Keys.Except(definitions.Select(d => d.Key)).Any()) - throw new InvalidOperationException($"Removing managed tags in {purpose} needs manual review; existing relationships must be preserved."); - var desired = live.Tags.ToList(); - foreach (var definition in definitions) + var plan = ForumSetupPlan.Prepare(purpose, receipt, live, repair, + receipt.PendingTopic ?? _assets.Render(purpose, state.RotationCode, _settings), state.RotationCode, _assets.Tags[purpose]); + var (topic, code, definitions, desired) = plan; + if (live.Topic != topic || !live.RequireTag || !SameTags(live.Tags, desired)) { - receipt.Tags.TryGetValue(definition.Key, out var saved); - var existing = saved is null ? null : live.Tags.SingleOrDefault(t => t.Id == saved.Id); - if (saved is not null && existing is null && !repair && !receipt.PendingRepair) - throw new InvalidOperationException($"A managed tag is missing in {purpose}; preview and repair setup to recreate it."); - if (!repair && existing is not null && existing.Moderated != saved!.Moderated) - throw new InvalidOperationException($"Managed tag permissions changed outside the bot in {purpose}."); - if (!repair && existing is not null && existing.Name != saved!.Name && existing.Name != definition.Name) - throw new InvalidOperationException($"A managed tag was renamed outside the bot in {purpose}."); - var matches = live.Tags.Where(t => t.Name.Equals(definition.Name, StringComparison.OrdinalIgnoreCase) && t.Id != existing?.Id).ToArray(); - if (existing is null && matches.Length == 1 && (repair || receipt.PendingTags is not null)) existing = matches[0]; - else if (matches.Length > 0) throw new InvalidOperationException($"Conflicting tag names in {purpose}."); - if (existing is not null) desired.Remove(existing); - desired.Add(new LiveTag(existing?.Id ?? "", definition.Name, false)); + PersistIntent(receipt, plan, repair); + live = await PublishAsync(purpose, receipt, live, plan, ct); } - if (desired.Count > 20) throw new InvalidOperationException($"Tag capacity exceeded in {purpose}; unrelated tags were preserved."); - if (live.Topic != topic || !live.RequireTag || !SameTags(live.Tags, desired)) + ConfirmPublication(purpose, receipt, live, plan); + } + + private void PersistIntent(ForumReceipt receipt, ForumSetupPlan plan, bool repair) + { + var (topic, code, definitions, _) = plan; + receipt.PendingRepair |= repair; + receipt.PendingTopic = topic; + receipt.PendingCode = code; + receipt.PendingTags = definitions; + _store.Save(); + } + + private async Task PublishAsync(string purpose, ForumReceipt receipt, ForumSnapshot live, ForumSetupPlan plan, CancellationToken ct) + { + var (topic, _, _, desired) = plan; + // Re-read immediately before writing to detect edits made during setup preparation. + var beforeWrite = await _discord.GetForumAsync(receipt.ForumId, ct); + if (beforeWrite.Topic != live.Topic || !SameTags(beforeWrite.Tags, live.Tags) || beforeWrite.Flags != live.Flags) + throw new InvalidOperationException($"Concurrent forum edit in {purpose}; retry setup preview."); + // Discord validates Require Tag against selectable tags. Establish them first, including + // their assigned IDs, before enabling that flag on an initially empty forum. + live = beforeWrite; + if (live.Topic != topic || !SameTags(live.Tags, desired)) + live = await _discord.PublishForumAsync(live, live.Topic == topic ? null : topic, + SameTags(live.Tags, desired) ? null : desired, live.RequireTag, ct); + if (!MatchesPublication(live, topic, desired) || !live.Tags.Any(t => !t.Moderated && t.Id.Length > 0)) + throw new InvalidOperationException($"Tag/Guidelines verification failed in {purpose}; Require Tag was not enabled."); + if (!live.RequireTag) { - receipt.PendingRepair |= repair; - receipt.PendingTopic = topic; - receipt.PendingCode = code; - receipt.PendingTags = definitions; - _store.Save(); - // Re-read immediately before writing to detect edits made during setup preparation. - var beforeWrite = await _discord.GetForumAsync(receipt.ForumId, ct); + beforeWrite = await _discord.GetForumAsync(receipt.ForumId, ct); if (beforeWrite.Topic != live.Topic || !SameTags(beforeWrite.Tags, live.Tags) || beforeWrite.Flags != live.Flags) throw new InvalidOperationException($"Concurrent forum edit in {purpose}; retry setup preview."); - // Discord validates Require Tag against selectable tags. Establish them first, including - // their assigned IDs, before enabling that flag on an initially empty forum. - live = beforeWrite; - if (live.Topic != topic || !SameTags(live.Tags, desired)) - live = await _discord.PublishForumAsync(live, live.Topic == topic ? null : topic, - SameTags(live.Tags, desired) ? null : desired, live.RequireTag, ct); - if (!MatchesPublication(live, topic, desired) || !live.Tags.Any(t => !t.Moderated && t.Id.Length > 0)) - throw new InvalidOperationException($"Tag/Guidelines verification failed in {purpose}; Require Tag was not enabled."); - if (!live.RequireTag) - { - beforeWrite = await _discord.GetForumAsync(receipt.ForumId, ct); - if (beforeWrite.Topic != live.Topic || !SameTags(beforeWrite.Tags, live.Tags) || beforeWrite.Flags != live.Flags) - throw new InvalidOperationException($"Concurrent forum edit in {purpose}; retry setup preview."); - live = await _discord.PublishForumAsync(beforeWrite, null, null, true, ct); - } + live = await _discord.PublishForumAsync(beforeWrite, null, null, true, ct); } + return live; + } + + private void ConfirmPublication(string purpose, ForumReceipt receipt, ForumSnapshot live, ForumSetupPlan plan) + { + var state = _store.State; + var (topic, code, definitions, desired) = plan; if (!live.RequireTag || !MatchesPublication(live, topic, desired)) throw new InvalidOperationException($"Setup verification failed in {purpose}."); receipt.Tags = definitions.ToDictionary(d => d.Key, d => diff --git a/DiscordBot/Services/Recruitment/Application/ReferenceReconciler.cs b/DiscordBot/Services/Recruitment/Application/ReferenceReconciler.cs new file mode 100644 index 00000000..8430c192 --- /dev/null +++ b/DiscordBot/Services/Recruitment/Application/ReferenceReconciler.cs @@ -0,0 +1,123 @@ +using DiscordBot.Services.Recruitment.Infrastructure.Discord; + +namespace DiscordBot.Services.Recruitment.Application; + +internal sealed record ReferenceCandidate(string Id, bool BotOwned); +internal sealed class ReferenceReconciler(RecruitmentState state, RecruitmentAssets assets, IRecruitmentDiscord discord, + TimeProvider time, Action save, Func evidenceCurrent, Action requestRetry) +{ + private readonly RecruitmentState State = state; + private readonly RecruitmentAssets _assets = assets; + private readonly IRecruitmentDiscord _discord = discord; + private DateTimeOffset Now => time.GetUtcNow(); + private bool EvidenceCurrent => evidenceCurrent(); + private void Save() => save(); + public void Invalidate() { _verifiedReferences.Clear(); _referenceCandidates.Clear(); _ignoredThreads.Clear(); } + public void ClearVerified() => _verifiedReferences.Clear(); + public void BeginInventory() => _unreadableReferenceForums.Clear(); + public IEnumerable KnownIgnored(DateTimeOffset floor) + { + foreach (var id in _ignoredThreads.Where(p => p.Value < floor).Select(p => p.Key).ToArray()) _ignoredThreads.Remove(id); + return _ignoredThreads.Keys; + } + public void Forget(string forum, string id) + { _verifiedReferences.Remove(forum); _referenceCandidates.Remove(forum); _ignoredThreads.Remove(id); } + private readonly HashSet _verifiedReferences = new(); + private readonly Dictionary _referenceCandidates = new(); + private readonly Dictionary _ignoredThreads = new(); + private readonly HashSet _unreadableReferenceForums = new(); + private string ReferenceMarker(string forum) => $"recruitment:reference:{State!.InstanceId}:{forum}"; + + public void ObserveReferenceCandidate(ThreadSnapshot live) + { + if (!live.OwnerIsBot && !live.OwnerIsStaff) return; + if (live.OwnerIsBot && live.Starter is null) + { + _unreadableReferenceForums.Add(live.ForumId); + return; // The missing starter could contain an interrupted create's marker; keep it discoverable. + } + // Retain ignored identities for this recovery pass so 50 staff/bot posts cannot starve member discovery. + _ignoredThreads[live.Id] = live.CreatedAt; + var owned = live.OwnerIsBot && live.Starter?.Contains(ReferenceMarker(live.ForumId), StringComparison.Ordinal) == true; + if (owned || live.OwnerIsStaff && live.Pinned && !_referenceCandidates.ContainsKey(live.ForumId)) + _referenceCandidates[live.ForumId] = new ReferenceCandidate(live.Id, owned); + } + + public async Task ReconcileAsync(CancellationToken ct, bool retryHeld) + { + foreach (var (purpose, forum) in State!.Forums) + { + var receipt = forum.Reference; + if (_verifiedReferences.Contains(forum.ForumId) || !retryHeld && receipt.Fault is { } fault && !fault.IsDue(Now)) continue; + // Reuse the normal bounded inventory; never create while discovery could still hide a prior send. + if (!EvidenceCurrent) { requestRetry(); return; } + try + { + await ReconcileForumAsync(purpose, forum, ct); + receipt.Fault = null; _verifiedReferences.Add(forum.ForumId); Save(); + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not System.IO.IOException) + { + receipt.Fault = RecruitmentFaultClassifier.From(ex, Now, receipt.Fault); + Save(); // Reference availability does not block otherwise valid member listings. + } + } + } + private async Task ReconcileForumAsync(string purpose, ForumReceipt forum, CancellationToken ct) + { + var receipt = forum.Reference; + var marker = ReferenceMarker(forum.ForumId); + if (receipt.ThreadId is not null && !await _discord.SecureReferenceAsync(forum.ForumId, receipt.ThreadId, receipt.BotOwned, marker, ct)) + { + _referenceCandidates.Remove(forum.ForumId); _ignoredThreads.Remove(receipt.ThreadId); + receipt.ThreadId = null; receipt.CreatePending = false; Save(); + } + if (receipt.ThreadId is not null) return; + var candidate = await DiscoverCandidateAsync(forum, ct); + await AdoptOrCreateAsync(purpose, forum, marker, candidate, ct); + if (!await _discord.SecureReferenceAsync(forum.ForumId, receipt.ThreadId!, receipt.BotOwned, marker, ct)) + { + _ignoredThreads.Remove(receipt.ThreadId!); _referenceCandidates.Remove(forum.ForumId); + receipt.ThreadId = null; Save(); + throw new TimeoutException("Reference disappeared before pinning; discovery will retry."); + } + } + + private async Task DiscoverCandidateAsync(ForumReceipt forum, CancellationToken ct) + { + var receipt = forum.Reference; + // Normal inventory skips known listing IDs. Probe retained reference designations first. + if (!_referenceCandidates.ContainsKey(forum.ForumId) && !receipt.CreatePending) + { + var retained = State.Listings.Values.Where(p => p.ForumId == forum.ForumId && + p.Status != ListingStatus.Deleted && (p.Status == ListingStatus.Reference || p.Pinned)).ToArray(); + foreach (var post in retained.Take(10)) + { + if (await _discord.GetThreadAsync(post.Id, ct, refresh: true) is { } live) ObserveReferenceCandidate(live); + if (_referenceCandidates.ContainsKey(forum.ForumId)) break; + } + if (retained.Length > 10 && !_referenceCandidates.ContainsKey(forum.ForumId)) + throw new InvalidOperationException("Reference discovery exceeded 10 retained candidates; review earlier reference designations."); + } + if (_unreadableReferenceForums.Contains(forum.ForumId)) + throw new TimeoutException("Reference discovery is waiting for a bot-owned starter message."); + return _referenceCandidates.TryGetValue(forum.ForumId, out var candidate) && (!receipt.CreatePending || candidate.BotOwned) + ? candidate : null; + } + + private async Task AdoptOrCreateAsync(string purpose, ForumReceipt forum, string marker, ReferenceCandidate? candidate, CancellationToken ct) + { + var receipt = forum.Reference; + if (candidate is not null) + { + receipt.ThreadId = candidate.Id; receipt.BotOwned = candidate.BotOwned; receipt.CreatePending = false; Save(); + return; + } + if (receipt.CreatePending && receipt.CreateStartedAt < State.DiscoveryFloor) + throw new InvalidOperationException("Reference creation predates retained discovery; inspect the forum before retrying."); + receipt.CreateStartedAt = Now; receipt.CreatePending = true; receipt.BotOwned = true; Save(); + var closed = forum.Tags[_assets.Tags[purpose].Single(t => t.Closing).Key].Id; + receipt.ThreadId = await _discord.CreateReferenceAsync(forum.ForumId, closed, marker, ct); + receipt.CreatePending = false; Save(); // Identity is durable before pin/lock writes. + } +} diff --git a/DiscordBot/Services/Recruitment/Application/SetupRequest.cs b/DiscordBot/Services/Recruitment/Application/SetupRequest.cs new file mode 100644 index 00000000..4abe8180 --- /dev/null +++ b/DiscordBot/Services/Recruitment/Application/SetupRequest.cs @@ -0,0 +1,4 @@ +namespace DiscordBot.Services.Recruitment; + +public enum SetupIntent { Reconcile, Repair } +public sealed record SetupRequest(SetupIntent Intent, bool RetryHeld = true, string? Target = null); diff --git a/DiscordBot/Services/Recruitment/Domain/Policies/ForumSetupPlan.cs b/DiscordBot/Services/Recruitment/Domain/Policies/ForumSetupPlan.cs new file mode 100644 index 00000000..7900acf0 --- /dev/null +++ b/DiscordBot/Services/Recruitment/Domain/Policies/ForumSetupPlan.cs @@ -0,0 +1,39 @@ +namespace DiscordBot.Services.Recruitment.Domain.Policies; + +internal sealed record ForumSetupPlan(string Topic, string Code, List Definitions, List Tags) +{ + public static ForumSetupPlan Prepare(string purpose, ForumReceipt receipt, ForumSnapshot live, bool repair, + string renderedTopic, string rotationCode, List tags) + { + var topic = receipt.PendingTopic ?? renderedTopic; + var code = receipt.PendingCode ?? rotationCode; + var definitions = receipt.PendingTags ?? tags; + if (!repair && live.Topic != receipt.ConfirmedTopic && live.Topic != receipt.PendingTopic) + throw new InvalidOperationException($"Guidelines changed outside the bot in {purpose}; preview and repair setup."); + if (!repair && receipt.ConfirmedTopic.Length > 0 && receipt.PendingTopic is null && !live.RequireTag) + throw new InvalidOperationException($"Require Tag was disabled outside the bot in {purpose}; preview and repair setup."); + if (!repair && receipt.ConfirmedTopic.Length == 0 && receipt.PendingTopic is null && live.Tags.Count > 0) + throw new InvalidOperationException($"Unexpected existing tags in {purpose}; preview and repair setup."); + if (receipt.Tags.Keys.Except(definitions.Select(d => d.Key)).Any()) + throw new InvalidOperationException($"Removing managed tags in {purpose} needs manual review; existing relationships must be preserved."); + var desired = live.Tags.ToList(); + foreach (var definition in definitions) + { + receipt.Tags.TryGetValue(definition.Key, out var saved); + var existing = saved is null ? null : live.Tags.SingleOrDefault(t => t.Id == saved.Id); + if (saved is not null && existing is null && !repair && !receipt.PendingRepair) + throw new InvalidOperationException($"A managed tag is missing in {purpose}; preview and repair setup to recreate it."); + if (!repair && existing is not null && existing.Moderated != saved!.Moderated) + throw new InvalidOperationException($"Managed tag permissions changed outside the bot in {purpose}."); + if (!repair && existing is not null && existing.Name != saved!.Name && existing.Name != definition.Name) + throw new InvalidOperationException($"A managed tag was renamed outside the bot in {purpose}."); + var matches = live.Tags.Where(t => t.Name.Equals(definition.Name, StringComparison.OrdinalIgnoreCase) && t.Id != existing?.Id).ToArray(); + if (existing is null && matches.Length == 1 && (repair || receipt.PendingTags is not null)) existing = matches[0]; + else if (matches.Length > 0) throw new InvalidOperationException($"Conflicting tag names in {purpose}."); + if (existing is not null) desired.Remove(existing); + desired.Add(new LiveTag(existing?.Id ?? "", definition.Name, false)); + } + if (desired.Count > 20) throw new InvalidOperationException($"Tag capacity exceeded in {purpose}; unrelated tags were preserved."); + return new(topic, code, definitions, desired); + } +} diff --git a/DiscordBot/Services/Recruitment/Infrastructure/Discord/DiscordInventoryReader.cs b/DiscordBot/Services/Recruitment/Infrastructure/Discord/DiscordInventoryReader.cs new file mode 100644 index 00000000..71b18eeb --- /dev/null +++ b/DiscordBot/Services/Recruitment/Infrastructure/Discord/DiscordInventoryReader.cs @@ -0,0 +1,59 @@ +using Discord.Rest; +using DiscordBot.Settings; +using static DiscordBot.Services.Recruitment.RecruitmentRequests; + +namespace DiscordBot.Services.Recruitment.Infrastructure.Discord; + +internal sealed class DiscordInventoryReader(BotSettings settings, TimeProvider time, + Func> forum, + Func> readSnapshot, + Func> readThread) +{ + public async Task ReadAsync(DateTimeOffset floor, IReadOnlySet knownIds, + IReadOnlyDictionary cursors, CancellationToken ct) + { + using var requestScope = RecruitmentRequests.BeginScope(); + var found = new Dictionary(); + var progress = new Dictionary(); + var complete = true; + var firstId = settings.Recruitment.Forums.Mapping().Values.First().ToString(); + var firstForum = await forum(firstId, ct); + using (var active = DiscordCompatibilityTransport.ObserveActiveInventory(settings.GuildId)) + { + foreach (var thread in await firstForum.GetActiveThreadsAsync(Options(ct))) found[thread.Id] = thread; + if (active.Identities is null) throw new InvalidOperationException("Active-thread inventory response was not observed; acceptance is held."); + foreach (var identity in active.Identities.Where(t => settings.Recruitment.Forums.Contains(t.Parent))) + found.TryAdd(identity.Id, null); + } + foreach (var id in settings.Recruitment.Forums.Mapping().Values) + { + var currentForum = await forum(id.ToString(), ct); + var cursor = cursors[id.ToString()]; + var started = cursor.StartedAt ?? time.GetUtcNow(); + var since = cursor.Since > floor ? cursor.Since : floor; + var before = cursor.Before; + var finished = false; + // Resume bounded archive pages across sweeps. A completed pass advances its lower boundary. + for (var page = 0; page < 2; page++) + { + var batch = await currentForum.GetPublicArchivedThreadsAsync(100, before, Options(ct)); + foreach (var thread in batch.Where(t => t.CreatedAt >= floor)) found[thread.Id] = thread; + if (batch.Count < 100 || batch.Min(t => t.ArchiveTimestamp) < since) { finished = true; break; } + before = batch.Min(t => t.ArchiveTimestamp); + } + complete &= finished; + // An overlap protects against timestamp precision and threads archiving during the scan. + progress[id.ToString()] = finished ? new ArchiveCursor(started.AddMinutes(-5)) : new ArchiveCursor(since, before, started); + } + var snapshots = new List(); + var unseen = found.Where(t => SnowflakeUtils.FromSnowflake(t.Key) >= floor && !knownIds.Contains(t.Key.ToString())).ToArray(); + foreach (var thread in unseen.Take(50)) + { + var snapshot = thread.Value is not null ? await readSnapshot(thread.Value, ct) : await readThread(thread.Key.ToString(), ct); + if (snapshot is not null) snapshots.Add(snapshot); + } + // Do not advance past an archive page until all its unknown identities have been retained. + return unseen.Length > 50 ? new ThreadInventory(snapshots, false, cursors) : new ThreadInventory(snapshots, complete, progress); + } + +} diff --git a/DiscordBot/Services/Recruitment/Infrastructure/Discord/DiscordRecruitmentAdapter.cs b/DiscordBot/Services/Recruitment/Infrastructure/Discord/DiscordRecruitmentAdapter.cs index bd8dec1b..1cdff955 100644 --- a/DiscordBot/Services/Recruitment/Infrastructure/Discord/DiscordRecruitmentAdapter.cs +++ b/DiscordBot/Services/Recruitment/Infrastructure/Discord/DiscordRecruitmentAdapter.cs @@ -170,52 +170,13 @@ await forum.ModifyAsync(p => return Snapshot(forum); } - public async Task InventoryAsync(DateTimeOffset floor, IReadOnlySet knownIds, - IReadOnlyDictionary cursors, CancellationToken ct) - { - using var requestScope = RecruitmentRequests.BeginScope(); - var found = new Dictionary(); - var progress = new Dictionary(); - var complete = true; - var firstId = _settings.Recruitment.Forums.Mapping().Values.First().ToString(); - var firstForum = _forums.TryGetValue(firstId, out var first) ? first.Channel : await ForumAsync(firstId, ct); - using (var active = DiscordCompatibilityTransport.ObserveActiveInventory(_settings.GuildId)) - { - foreach (var thread in await firstForum.GetActiveThreadsAsync(Options(ct))) found[thread.Id] = thread; - if (active.Identities is null) throw new InvalidOperationException("Active-thread inventory response was not observed; acceptance is held."); - foreach (var identity in active.Identities.Where(t => _settings.Recruitment.Forums.Contains(t.Parent))) - found.TryAdd(identity.Id, null); - } - foreach (var id in _settings.Recruitment.Forums.Mapping().Values) - { - var forum = _forums.TryGetValue(id.ToString(), out var observed) ? observed.Channel : await ForumAsync(id.ToString(), ct); - var cursor = cursors[id.ToString()]; - var started = cursor.StartedAt ?? _time.GetUtcNow(); - var since = cursor.Since > floor ? cursor.Since : floor; - var before = cursor.Before; - var finished = false; - // Resume bounded archive pages across sweeps. A completed pass advances its lower boundary. - for (var page = 0; page < 2; page++) - { - var batch = await forum.GetPublicArchivedThreadsAsync(100, before, Options(ct)); - foreach (var thread in batch.Where(t => t.CreatedAt >= floor)) found[thread.Id] = thread; - if (batch.Count < 100 || batch.Min(t => t.ArchiveTimestamp) < since) { finished = true; break; } - before = batch.Min(t => t.ArchiveTimestamp); - } - complete &= finished; - // An overlap protects against timestamp precision and threads archiving during the scan. - progress[id.ToString()] = finished ? new ArchiveCursor(started.AddMinutes(-5)) : new ArchiveCursor(since, before, started); - } - var snapshots = new List(); - var unseen = found.Where(t => SnowflakeUtils.FromSnowflake(t.Key) >= floor && !knownIds.Contains(t.Key.ToString())).ToArray(); - foreach (var thread in unseen.Take(50)) - { - var snapshot = thread.Value is not null ? await SnapshotAsync(thread.Value, ct) : await GetThreadAsync(thread.Key.ToString(), ct); - if (snapshot is not null) snapshots.Add(snapshot); - } - // Do not advance past an archive page until all its unknown identities have been retained. - return unseen.Length > 50 ? new ThreadInventory(snapshots, false, cursors) : new ThreadInventory(snapshots, complete, progress); - } + public Task InventoryAsync(DateTimeOffset floor, IReadOnlySet knownIds, + IReadOnlyDictionary cursors, CancellationToken ct) => + new DiscordInventoryReader(_settings, _time, CachedForumAsync, SnapshotAsync, + (id, token) => GetThreadAsync(id, token)).ReadAsync(floor, knownIds, cursors, ct); + + private Task CachedForumAsync(string id, CancellationToken ct) => + _forums.TryGetValue(id, out var observed) ? Task.FromResult(observed.Channel) : ForumAsync(id, ct); public async Task GetThreadAsync(string threadId, CancellationToken ct, bool refresh = false) { From 14554eb3b0abb848b43f69b50ea7dc160f845cdb Mon Sep 17 00:00:00 2001 From: James Kellie Date: Mon, 19 Oct 2026 21:31:12 +1000 Subject: [PATCH 31/31] Separate recruitment runtime, scheduling and health --- .../Discord/RecruitmentPublicationTests.cs | 18 +- .../Discord/RecruitmentReferenceTests.cs | 1 + .../Runtime/RecruitmentDuePolicyTests.cs | 42 ++ .../Recruitment/Support/RecruitmentTestRig.cs | 5 +- .../Workflows/RecruitmentEvidenceTests.cs | 1 + .../Workflows/RecruitmentReportTests.cs | 1 + .../Modules/Recruitment/RecruitmentModule.cs | 1 + .../Contracts/RecruitmentMessages.cs | 7 + .../Application/InventoryRecovery.cs | 10 +- .../Application/ListingActionExecutor.cs | 13 +- .../Recruitment/Application/ListingEvents.cs | 113 ++++++ .../Application/ListingOwnerCommands.cs | 12 +- .../Application/ListingPublisher.cs | 23 +- .../Recruitment/Application/ListingQueries.cs | 8 +- .../Recruitment/Application/ListingReader.cs | 8 +- .../Application/ListingReportCommands.cs | 9 +- .../Application/ListingStaffCommands.cs | 9 +- .../Application/ListingWorkflow.cs | 16 +- .../Application/RecruitmentAttempts.cs | 30 -- .../Application/RecruitmentAttention.cs | 35 -- .../Application/RecruitmentEngine.cs | 99 ++++- .../RecruitmentEngineScheduling.cs | 163 -------- ...iew.cs => RecruitmentOverviewPublisher.cs} | 50 ++- .../Application/ReferenceReconciler.cs | 10 +- ...oDomains.cs => PortfolioHostnamePolicy.cs} | 30 +- .../Domain/Policies/LegacyListingOutcomes.cs | 7 + .../Domain/Policies/RecruitmentSchedule.cs | 82 ++++ .../Assets/RecruitmentPortfolioDomains.cs | 31 ++ .../Discord/DiscordInventoryReader.cs | 4 +- .../Discord/DiscordRecruitmentAdapter.cs | 27 +- ...rences.cs => DiscordReferencePublisher.cs} | 23 +- ...ruitmentPins.cs => DiscordResponsePins.cs} | 22 +- .../ListingEligibilityText.cs | 4 +- .../RecruitmentHealthFormatter.cs | 44 ++ .../Recruitment/RecruitmentService.cs | 381 ++---------------- .../Recruitment/Runtime/RecruitmentGateway.cs | 146 +++++++ .../Recruitment/Runtime/RecruitmentHealth.cs | 28 +- .../Runtime/RecruitmentHealthQuery.cs | 64 +++ .../Recruitment/Runtime/RecruitmentWorker.cs | 269 +++++++++++++ 39 files changed, 1125 insertions(+), 721 deletions(-) create mode 100644 DiscordBot.Tests/Recruitment/Runtime/RecruitmentDuePolicyTests.cs create mode 100644 DiscordBot/Services/Recruitment/Application/ListingEvents.cs delete mode 100644 DiscordBot/Services/Recruitment/Application/RecruitmentAttempts.cs delete mode 100644 DiscordBot/Services/Recruitment/Application/RecruitmentAttention.cs delete mode 100644 DiscordBot/Services/Recruitment/Application/RecruitmentEngineScheduling.cs rename DiscordBot/Services/Recruitment/Application/{RecruitmentOverview.cs => RecruitmentOverviewPublisher.cs} (70%) rename DiscordBot/Services/Recruitment/Domain/Analysis/{RecruitmentPortfolioDomains.cs => PortfolioHostnamePolicy.cs} (54%) create mode 100644 DiscordBot/Services/Recruitment/Domain/Policies/LegacyListingOutcomes.cs create mode 100644 DiscordBot/Services/Recruitment/Domain/Policies/RecruitmentSchedule.cs create mode 100644 DiscordBot/Services/Recruitment/Infrastructure/Assets/RecruitmentPortfolioDomains.cs rename DiscordBot/Services/Recruitment/Infrastructure/Discord/{DiscordRecruitmentReferences.cs => DiscordReferencePublisher.cs} (79%) rename DiscordBot/Services/Recruitment/Infrastructure/Discord/{DiscordRecruitmentPins.cs => DiscordResponsePins.cs} (65%) rename DiscordBot/Services/Recruitment/{Application => Presentation}/ListingEligibilityText.cs (88%) create mode 100644 DiscordBot/Services/Recruitment/Presentation/RecruitmentHealthFormatter.cs create mode 100644 DiscordBot/Services/Recruitment/Runtime/RecruitmentGateway.cs create mode 100644 DiscordBot/Services/Recruitment/Runtime/RecruitmentHealthQuery.cs create mode 100644 DiscordBot/Services/Recruitment/Runtime/RecruitmentWorker.cs diff --git a/DiscordBot.Tests/Recruitment/Discord/RecruitmentPublicationTests.cs b/DiscordBot.Tests/Recruitment/Discord/RecruitmentPublicationTests.cs index fa90242b..8affdf48 100644 --- a/DiscordBot.Tests/Recruitment/Discord/RecruitmentPublicationTests.cs +++ b/DiscordBot.Tests/Recruitment/Discord/RecruitmentPublicationTests.cs @@ -401,7 +401,7 @@ public async Task InstalledSdkPinCorrectionIsScopedToExactMessageAndLeavesOrdina var transport = new PublicationTransport { MessageMissing = true }; using var client = await Client(transport); var channel = (IThreadChannel)(await client.Rest.GetChannelAsync(100))!; await V2MessageWriter.UpsertAsync(channel, 999, "101", false, DateTimeOffset.UtcNow, V2(media: false), true, () => { }, default); - Assert.True(await DiscordRecruitmentAdapter.PinOwnedAsync(channel, 999, 101, true, false, false, (_, _) => { }, default)); + Assert.True(await DiscordResponsePins.PinOwnedAsync(channel, 999, 101, true, false, false, (_, _) => { }, default)); Assert.Equal(new[] { "channels/100/messages/pins/101" }, transport.PinRoutes); var message = (IUserMessage)(await channel.GetMessageAsync(101))!; await message.PinAsync(); @@ -424,7 +424,7 @@ public async Task OptionalPinWithoutPermissionDoesNotWrite(bool? allowed) var transport = new PublicationTransport { MessageMissing = true }; using var client = await Client(transport); var channel = (IThreadChannel)(await client.Rest.GetChannelAsync(100))!; await V2MessageWriter.UpsertAsync(channel, 999, "101", false, DateTimeOffset.UtcNow, V2(media: false), true, () => { }, default); - await Assert.ThrowsAsync(() => DiscordRecruitmentAdapter.PinOwnedAsync(channel, 999, 101, allowed, false, false, (_, _) => { }, default)); + await Assert.ThrowsAsync(() => DiscordResponsePins.PinOwnedAsync(channel, 999, 101, allowed, false, false, (_, _) => { }, default)); Assert.Empty(transport.PinRoutes); Assert.Empty(transport.ChannelWrites); } @@ -439,12 +439,12 @@ public async Task LostPinResponseRestoresArchivedClosedThreadWhenMessageAlreadyP transport.LosePinResponse = true; transport.FailRestoreOnce = true; var receipt = new MessagePinReceipt { MessageId = "101" }; void SaveIntent(bool archive, bool locked) { receipt.RestoreArchive = archive; receipt.RestoreLocked = locked; } - await Assert.ThrowsAsync(() => DiscordRecruitmentAdapter.PinOwnedAsync(channel, 999, 101, true, false, false, SaveIntent, default)); + await Assert.ThrowsAsync(() => DiscordResponsePins.PinOwnedAsync(channel, 999, 101, true, false, false, SaveIntent, default)); Assert.True(receipt.RestoreArchive); Assert.True(receipt.RestoreLocked); Assert.False((bool)transport.Thread["thread_metadata"]!["archived"]!); Assert.True((bool)transport.Message["pinned"]!); channel = (IThreadChannel)(await client.Rest.GetChannelAsync(100))!; - Assert.True(await DiscordRecruitmentAdapter.PinOwnedAsync(channel, 999, 101, false, receipt.RestoreArchive, receipt.RestoreLocked, SaveIntent, default)); + Assert.True(await DiscordResponsePins.PinOwnedAsync(channel, 999, 101, false, receipt.RestoreArchive, receipt.RestoreLocked, SaveIntent, default)); Assert.Single(transport.PinRoutes); Assert.True((bool)transport.Thread["thread_metadata"]!["archived"]!); Assert.True((bool)transport.Thread["thread_metadata"]!["locked"]!); @@ -457,6 +457,8 @@ public async Task LostPinResponseRestoresArchivedClosedThreadWhenMessageAlreadyP [InlineData(429)] public async Task InstalledSdkArchivedPinFailureRestoresThreadAndKeepsFailureCosmetic(int status) { + var clock = new ManualClock(); + using var requestScope = RecruitmentRequests.BeginScope(new DiscordRetryDeadlines(clock)); var transport = new PublicationTransport { MessageMissing = true }; using var client = await Client(transport); var channel = (IThreadChannel)(await client.Rest.GetChannelAsync(100))!; await V2MessageWriter.UpsertAsync(channel, 999, "101", false, DateTimeOffset.UtcNow, V2(media: false), true, () => { }, default); @@ -464,7 +466,7 @@ public async Task InstalledSdkArchivedPinFailureRestoresThreadAndKeepsFailureCos channel = (IThreadChannel)(await client.Rest.GetChannelAsync(100))!; transport.PinStatus = status; var receipt = new MessagePinReceipt { MessageId = "101" }; - var error = await Record.ExceptionAsync(() => DiscordRecruitmentAdapter.PinOwnedAsync(channel, 999, 101, true, false, false, + var error = await Record.ExceptionAsync(() => DiscordResponsePins.PinOwnedAsync(channel, 999, 101, true, false, false, (archive, locked) => { receipt.RestoreArchive = archive; receipt.RestoreLocked = locked; }, default)); Assert.NotNull(error); // A 429 also defers restoration in the same channel. The saved intent retries once @@ -473,10 +475,10 @@ public async Task InstalledSdkArchivedPinFailureRestoresThreadAndKeepsFailureCos { var deferred = Assert.IsType(error); Assert.True(receipt.RestoreArchive); - await Task.Delay(deferred.RetryAt - DateTimeOffset.UtcNow + TimeSpan.FromMilliseconds(100)); + clock.Advance(deferred.RetryAt - clock.Now + TimeSpan.FromMilliseconds(100)); channel = (IThreadChannel)(await client.Rest.GetChannelAsync(100))!; transport.PinStatus = 0; - await Assert.ThrowsAsync(() => DiscordRecruitmentAdapter.PinOwnedAsync(channel, 999, 101, false, + await Assert.ThrowsAsync(() => DiscordResponsePins.PinOwnedAsync(channel, 999, 101, false, receipt.RestoreArchive, receipt.RestoreLocked, (archive, locked) => { receipt.RestoreArchive = archive; receipt.RestoreLocked = locked; }, default)); } else Assert.IsType(error); @@ -495,7 +497,7 @@ public async Task PinRefusesCopiedMarkerOrWrongOwner() var channel = (IThreadChannel)(await client.Rest.GetChannelAsync(100))!; await V2MessageWriter.UpsertAsync(channel, 999, "101", false, DateTimeOffset.UtcNow, V2(media: false), true, () => { }, default); transport.Message["author"]!["id"] = "200"; - await Assert.ThrowsAsync(() => DiscordRecruitmentAdapter.PinOwnedAsync(channel, 999, 101, true, false, false, (_, _) => { }, default)); + await Assert.ThrowsAsync(() => DiscordResponsePins.PinOwnedAsync(channel, 999, 101, true, false, false, (_, _) => { }, default)); Assert.Empty(transport.PinRoutes); } diff --git a/DiscordBot.Tests/Recruitment/Discord/RecruitmentReferenceTests.cs b/DiscordBot.Tests/Recruitment/Discord/RecruitmentReferenceTests.cs index 0500bbcd..cbe62151 100644 --- a/DiscordBot.Tests/Recruitment/Discord/RecruitmentReferenceTests.cs +++ b/DiscordBot.Tests/Recruitment/Discord/RecruitmentReferenceTests.cs @@ -1,3 +1,4 @@ +using DiscordBot.Services.Recruitment.Presentation; using DiscordBot.Services.Recruitment; namespace DiscordBot.Tests.Recruitment; diff --git a/DiscordBot.Tests/Recruitment/Runtime/RecruitmentDuePolicyTests.cs b/DiscordBot.Tests/Recruitment/Runtime/RecruitmentDuePolicyTests.cs new file mode 100644 index 00000000..c8648f72 --- /dev/null +++ b/DiscordBot.Tests/Recruitment/Runtime/RecruitmentDuePolicyTests.cs @@ -0,0 +1,42 @@ +using DiscordBot.Services.Recruitment; +using DiscordBot.Services.Recruitment.Domain.Policies; + +namespace DiscordBot.Tests.Recruitment; + +public class RecruitmentDuePolicyTests +{ + [Fact] + public void RecoveryHoldSuppressesExpiredChildRetries() + { + var now = DateTimeOffset.UnixEpoch.AddDays(1); + var state = new RecruitmentState { RecoveryFault = new("Review required", null) }; + state.Forums.Add("forum", new ForumReceipt { Fault = new("Retry", now.AddMinutes(-1)) }); + Assert.Null(RecruitmentSchedule.Next(state, now, DateTimeOffset.MaxValue, false, false, 30, new())); + state.RecoveryFault = null; + var due = RecruitmentSchedule.Next(state, now, DateTimeOffset.MaxValue, false, false, 30, new()); + Assert.Equal(RecruitmentDueReason.ForumRetry, due!.Reason); + Assert.Equal(now.AddMinutes(-1), due.At); + } + + [Fact] + public void HeldOptionalPinDoesNotCreateImmediatePolling() + { + var now = DateTimeOffset.UnixEpoch.AddDays(1); + var post = new Listing { Status = ListingStatus.Closed, PublicMessageId = "101", + PublicPin = new MessagePinReceipt { MessageId = "101", Fault = new("Permission required", null) } }; + Assert.Null(RecruitmentSchedule.Listing(new(), post, now, 30, false)); + post.PublicPin.Fault = new("Retry", now.AddMinutes(1)); + var due = RecruitmentSchedule.Listing(new(), post, now, 30, false); + Assert.Equal(RecruitmentDueReason.Pin, due!.Reason); + Assert.Equal(now.AddMinutes(1), due.At); + } + + [Fact] + public async Task AuditDeadlineUsesExplicitJitterAndInjectedClock() + { + using var rig = new RecruitmentRig(); + using var engine = new RecruitmentEngine(rig.Settings, rig.Discord, rig.Clock, auditJitter: () => 0.5); + await engine.StartAsync(default); + Assert.Equal(rig.Clock.Now.AddMinutes(16.5), engine.Health().NextDue); + } +} diff --git a/DiscordBot.Tests/Recruitment/Support/RecruitmentTestRig.cs b/DiscordBot.Tests/Recruitment/Support/RecruitmentTestRig.cs index 8ed6ac4e..ea09798d 100644 --- a/DiscordBot.Tests/Recruitment/Support/RecruitmentTestRig.cs +++ b/DiscordBot.Tests/Recruitment/Support/RecruitmentTestRig.cs @@ -1,3 +1,4 @@ +using DiscordBot.Services.Recruitment.Infrastructure.Discord; using DiscordBot.Services.Recruitment; using DiscordBot.Settings; @@ -168,8 +169,8 @@ public Task CreateReferenceAsync(string forum, string closedTagId, strin { ReferenceCreates++; var id = (++_nextId).ToString(); - Threads[id] = new ThreadSnapshot(id, forum, "999", DiscordRecruitmentAdapter.ReferenceTitle, DateTimeOffset.UtcNow, - true, false, false, false, new[] { closedTagId }, DiscordRecruitmentAdapter.ReferenceIntroduction(marker), null, null); + Threads[id] = new ThreadSnapshot(id, forum, "999", DiscordReferencePublisher.ReferenceTitle, DateTimeOffset.UtcNow, + true, false, false, false, new[] { closedTagId }, DiscordReferencePublisher.ReferenceIntroduction(marker), null, null); if (LoseNextReferenceResponse) { LoseNextReferenceResponse = false; throw new TimeoutException("Reference response lost"); } return Task.FromResult(id); } diff --git a/DiscordBot.Tests/Recruitment/Workflows/RecruitmentEvidenceTests.cs b/DiscordBot.Tests/Recruitment/Workflows/RecruitmentEvidenceTests.cs index 99073d0b..e57ae48f 100644 --- a/DiscordBot.Tests/Recruitment/Workflows/RecruitmentEvidenceTests.cs +++ b/DiscordBot.Tests/Recruitment/Workflows/RecruitmentEvidenceTests.cs @@ -1,3 +1,4 @@ +using DiscordBot.Services.Recruitment.Presentation; using DiscordBot.Services.Recruitment; namespace DiscordBot.Tests.Recruitment; diff --git a/DiscordBot.Tests/Recruitment/Workflows/RecruitmentReportTests.cs b/DiscordBot.Tests/Recruitment/Workflows/RecruitmentReportTests.cs index d26371bd..6e3ff7d8 100644 --- a/DiscordBot.Tests/Recruitment/Workflows/RecruitmentReportTests.cs +++ b/DiscordBot.Tests/Recruitment/Workflows/RecruitmentReportTests.cs @@ -1,3 +1,4 @@ +using DiscordBot.Services.Recruitment.Presentation; using DiscordBot.Services.Recruitment; namespace DiscordBot.Tests.Recruitment; diff --git a/DiscordBot/Modules/Recruitment/RecruitmentModule.cs b/DiscordBot/Modules/Recruitment/RecruitmentModule.cs index 2a3dee92..cb52aa96 100644 --- a/DiscordBot/Modules/Recruitment/RecruitmentModule.cs +++ b/DiscordBot/Modules/Recruitment/RecruitmentModule.cs @@ -1,3 +1,4 @@ +using DiscordBot.Services.Recruitment.Presentation; using DiscordBot.Services.Recruitment.Application; using DiscordBot.Utils; using Discord.Interactions; diff --git a/DiscordBot/Services/Recruitment/Application/Contracts/RecruitmentMessages.cs b/DiscordBot/Services/Recruitment/Application/Contracts/RecruitmentMessages.cs index 300ab206..6667985b 100644 --- a/DiscordBot/Services/Recruitment/Application/Contracts/RecruitmentMessages.cs +++ b/DiscordBot/Services/Recruitment/Application/Contracts/RecruitmentMessages.cs @@ -2,9 +2,16 @@ namespace DiscordBot.Services.Recruitment; public enum RecruitmentMessageFormat { Standard, ComponentsV2 } public sealed record RecruitmentMedia(string FileName, byte[] Bytes, string Description); +public sealed record ThreadRestoration(bool Archived, bool Locked) +{ + public static readonly ThreadRestoration LockedArchive = new(true, true); +} public sealed record RecruitmentMessage(string Content, string Marker, bool OwnerControls, bool RestoreArchive = false, Embed? Embed = null, string? TextFallback = null, bool AcknowledgementRequired = false, bool ReaderControls = false, RecruitmentMessageFormat Format = RecruitmentMessageFormat.Standard, string? RenderIdentity = null, MessageComponent? Components = null, MessageComponent? FallbackComponents = null, RecruitmentMedia? Media = null, bool RestoreLocked = false, ulong? MentionUserId = null) { + public RecruitmentMessage WithRestoration(ThreadRestoration target) => + this with { RestoreArchive = target.Archived, RestoreLocked = target.Locked }; + public string RenderKey => RenderIdentity ?? Content + "\n" + OwnerControls + "\n" + AcknowledgementRequired + "\n" + ReaderControls + "\n" + EmbedKey(Embed); public static string EmbedKey(IEmbed? embed) => embed is null ? "" : Newtonsoft.Json.JsonConvert.SerializeObject(embed.ToEmbedBuilder().Build()); } diff --git a/DiscordBot/Services/Recruitment/Application/InventoryRecovery.cs b/DiscordBot/Services/Recruitment/Application/InventoryRecovery.cs index ec0d5417..88f95605 100644 --- a/DiscordBot/Services/Recruitment/Application/InventoryRecovery.cs +++ b/DiscordBot/Services/Recruitment/Application/InventoryRecovery.cs @@ -2,8 +2,14 @@ namespace DiscordBot.Services.Recruitment.Application; -internal sealed class InventoryRecovery(RecruitmentState state, IRecruitmentDiscord discord, AcknowledgementRecovery windows, - ReferenceReconciler references, ListingWorkflow workflow, Func currentGeneration, Action save) +internal sealed class InventoryRecovery( + RecruitmentState state, + IRecruitmentDiscord discord, + AcknowledgementRecovery windows, + ReferenceReconciler references, + ListingWorkflow workflow, + Func currentGeneration, + Action save) { private readonly RecruitmentState State = state; private readonly IRecruitmentDiscord _discord = discord; diff --git a/DiscordBot/Services/Recruitment/Application/ListingActionExecutor.cs b/DiscordBot/Services/Recruitment/Application/ListingActionExecutor.cs index dc6623f2..48d6788a 100644 --- a/DiscordBot/Services/Recruitment/Application/ListingActionExecutor.cs +++ b/DiscordBot/Services/Recruitment/Application/ListingActionExecutor.cs @@ -7,7 +7,18 @@ namespace DiscordBot.Services.Recruitment.Application; -internal sealed class ListingActionExecutor(RecruitmentState state, RecruitmentSettings settings, IRecruitmentDiscord discord, TimeProvider time, Action save, AcknowledgementRecovery windows, ListingReader reader, ListingPublisher publisher, ListingForums forums, Func evidenceCurrent, Func pendingThreadWork) +internal sealed class ListingActionExecutor( + RecruitmentState state, + RecruitmentSettings settings, + IRecruitmentDiscord discord, + TimeProvider time, + Action save, + AcknowledgementRecovery windows, + ListingReader reader, + ListingPublisher publisher, + ListingForums forums, + Func evidenceCurrent, + Func pendingThreadWork) { private readonly RecruitmentState State = state; private readonly RecruitmentSettings _settings = settings; diff --git a/DiscordBot/Services/Recruitment/Application/ListingEvents.cs b/DiscordBot/Services/Recruitment/Application/ListingEvents.cs new file mode 100644 index 00000000..2c56f164 --- /dev/null +++ b/DiscordBot/Services/Recruitment/Application/ListingEvents.cs @@ -0,0 +1,113 @@ +using DiscordBot.Settings; +using DiscordBot.Utils; + +namespace DiscordBot.Services.Recruitment.Application; + +/// Applies queued facts and identity changes on the existing worker. +internal sealed class ListingEvents( + RecruitmentState state, + RecruitmentSettings settings, + IRecruitmentDiscord discord, + TimeProvider time, + Action save, + ListingWorkflow workflow, + ListingReader reader, + ReferenceReconciler references, + ListingPublisher publisher, + RecruitmentOverviewPublisher overview, + Func ready, + Action requestRecovery) +{ + private readonly RecruitmentState State = state; + private readonly RecruitmentSettings _settings = settings; + private readonly IRecruitmentDiscord _discord = discord; + private readonly ListingWorkflow _workflow = workflow; + private readonly ListingReader _reader = reader; + private readonly ReferenceReconciler _references = references; + private readonly ListingPublisher _publisher = publisher; + private readonly RecruitmentOverviewPublisher _overview = overview; + private DateTimeOffset Now => time.GetUtcNow(); + private void Save() => save(); + public async Task ReconcileThreadAsync(string thread, CancellationToken ct) + { + if (!ready() || State?.Listings.GetValueOrDefault(thread) is not { } post) return; + await _workflow!.ProcessSafelyAsync(post, ct); + foreach (var waiting in State.Listings.Values.Where(p => p.Id != post.Id && p.OwnerId == post.OwnerId && + p.Status == ListingStatus.Open && !p.AcceptedAt.HasValue).OrderBy(p => p.CreatedAt).ToArray()) + await _workflow!.ProcessSafelyAsync(waiting, ct); + } + + public async Task ThreadCreatedAsync(ThreadCreation creation, CancellationToken ct) + { + if (State is null || !ready() || !State.Forums.Values.Any(f => f.ForumId == creation.ForumId)) return; + await ThreadChangedAsync(creation.Id, ct); + if (State.Listings.ContainsKey(creation.Id) || creation.CreatedAt <= Now.AddMinutes(-_settings.AttemptWindowMinutes)) return; + // THREAD_CREATE can also announce access to an old thread. Only recent, confirmed-absent + // creations with a known member identity count here; successful discovery already counted itself. + if (await _discord.GetThreadAsync(creation.Id, ct, refresh: true) is not null) return; + var author = await _discord.GetAuthorAsync(creation.OwnerId, ct); + if (author is null || author.IsBot || author.IsStaff) return; + var post = new Listing + { + Id = creation.Id, ForumId = creation.ForumId, OwnerId = creation.OwnerId, + Title = DiscordDisplayText.Literal(creation.Title), CreatedAt = creation.CreatedAt.ToUniversalTime(), UpdatedAt = Now, + Status = ListingStatus.Deleted, LastOutcome = "Thread absent before first read; creation observed, deletion actor unknown", + PublicationPending = true + }; + State.Listings[post.Id] = post; + _workflow!.RecordCreation(post); Save(); + await _workflow!.ProcessSafelyAsync(post, ct); + } + + public async Task ThreadChangedAsync(string id, CancellationToken ct) + { + _discord.InvalidateThread(id); + if (State?.Forums.Values.Any(f => f.Reference.ThreadId == id) == true) return; + if (State is null || !ready()) return; + if (!State.Listings.TryGetValue(id, out var post)) + { + var live = await _discord.GetThreadAsync(id, ct); + // ThreadCreated also signals access to an existing thread; it cannot certify reply coverage. + if (live is not null) { _references!.ObserveReferenceCandidate(live); await _workflow.ObserveAsync(live, false, ct); } + if (!State.Listings.TryGetValue(id, out post)) return; + } + post.Fault = null; + await ReconcileThreadAsync(id, ct); + } + + public async Task ThreadDeletedAsync(string id, CancellationToken ct) + { + _discord.InvalidateThread(id); + if (State?.Forums.Values.FirstOrDefault(f => f.Reference.ThreadId == id) is { } forum) + { + forum.Reference = new ReferenceReceipt(); _references!.Forget(forum.ForumId, id); + requestRecovery(); Save(); + } + if (State?.Listings.GetValueOrDefault(id) is not { } post) return; + _reader!.RecordAbsence(post); + if (ready()) await ReconcileThreadAsync(id, ct); + } + + public Task FactsChangedAsync(string id, CancellationToken ct) + { + _discord.InvalidateFacts(id); + return ThreadChangedAsync(id, ct); + } + + public async Task MessageDeletedAsync(string channel, IReadOnlySet ids, CancellationToken ct) + { + if (State is null) return; + if (channel == _settings.FeedChannelId.ToString() && State.Overview.MessageId is { } overview && ids.Contains(overview)) + { State.Overview.MessageId = null; State.Overview.Fault = null; _overview!.InvalidateRender(); Save(); } + foreach (var post in State.Listings.Values.Where(p => + channel == _settings.FeedChannelId.ToString() && p.StaffMessageId is not null && ids.Contains(p.StaffMessageId) || + channel == p.Id && p.PublicMessageId is not null && ids.Contains(p.PublicMessageId)).ToArray()) + { + if (channel == post.Id) { post.PublicMessageId = null; _publisher!.InvalidatePublic(post.Id); } + else { post.StaffMessageId = null; _publisher!.InvalidateStaff(post.Id); } + post.PublicationPending = true; post.Fault = null; Save(); + if (ready()) await _workflow!.ProcessSafelyAsync(post, ct); + } + } + +} diff --git a/DiscordBot/Services/Recruitment/Application/ListingOwnerCommands.cs b/DiscordBot/Services/Recruitment/Application/ListingOwnerCommands.cs index cd431f20..81bebad7 100644 --- a/DiscordBot/Services/Recruitment/Application/ListingOwnerCommands.cs +++ b/DiscordBot/Services/Recruitment/Application/ListingOwnerCommands.cs @@ -5,7 +5,17 @@ namespace DiscordBot.Services.Recruitment.Application; -internal sealed class ListingOwnerCommands(RecruitmentState state, RecruitmentSettings settings, IRecruitmentDiscord discord, TimeProvider time, Action save, ListingWorkflow workflow, ListingActionExecutor actions, Func ready, Func acknowledgementReady, Func evidenceCurrent) +internal sealed class ListingOwnerCommands( + RecruitmentState state, + RecruitmentSettings settings, + IRecruitmentDiscord discord, + TimeProvider time, + Action save, + ListingWorkflow workflow, + ListingActionExecutor actions, + Func ready, + Func acknowledgementReady, + Func evidenceCurrent) { private readonly RecruitmentState State = state; private readonly RecruitmentSettings _settings = settings; diff --git a/DiscordBot/Services/Recruitment/Application/ListingPublisher.cs b/DiscordBot/Services/Recruitment/Application/ListingPublisher.cs index 031e0632..466fecae 100644 --- a/DiscordBot/Services/Recruitment/Application/ListingPublisher.cs +++ b/DiscordBot/Services/Recruitment/Application/ListingPublisher.cs @@ -7,7 +7,13 @@ namespace DiscordBot.Services.Recruitment.Application; -internal sealed class ListingPublisher(RecruitmentState state, RecruitmentSettings settings, IRecruitmentDiscord discord, TimeProvider time, Action save, Action guardFeedWrite) +internal sealed class ListingPublisher( + RecruitmentState state, + RecruitmentSettings settings, + IRecruitmentDiscord discord, + TimeProvider time, + Action save, + Action guardFeedWrite) { private readonly RecruitmentState State = state; private readonly RecruitmentSettings _settings = settings; @@ -50,8 +56,8 @@ public async Task PublishAsync(Listing post, CancellationToken ct) if (!post.PublicSendPending && !post.PublicationPending && post.PublicMessageId is not null && _publicRenders.GetValueOrDefault(post.Id) == renderKey) return; var recovering = post.PublicSendPending; var delivery = await _discord.UpsertAsync(post.Id, post.PublicMessageId, recovering, post.PublicSendStartedAt ?? post.CreatedAt, - message with - { RestoreArchive = post.PublicRestoreArchive || post.Status == ListingStatus.Closed, RestoreLocked = post.PublicRestoreLocked || post.Status == ListingStatus.Closed }, () => + message.WithRestoration(post.Status == ListingStatus.Closed ? ThreadRestoration.LockedArchive : + new ThreadRestoration(Archived: post.PublicRestoreArchive, Locked: post.PublicRestoreLocked)), () => { if (!recovering) post.PublicSendStartedAt = Now; post.PublicSendPending = true; Save(); @@ -99,11 +105,7 @@ public async Task PublishAttentionAsync(Listing post, CancellationToken ct) } } - public DateTimeOffset? AcknowledgementNoticeDue(Listing post) => - post.Status == ListingStatus.Open && !post.AcceptedAt.HasValue && !post.AcknowledgedAt.HasValue && - post.PublicMessageId is not null && post.Deadline > Now && - (post.AcknowledgementNotice.MessageId is null || post.AcknowledgementNotice.SendPending) - ? post.AcknowledgementNotice.Fault is { } fault ? fault.RetryAt : Now : null; + public DateTimeOffset? AcknowledgementNoticeDue(Listing post) => ListingDeliveryPolicy.AcknowledgementNoticeDue(post, Now); public async Task PublishAcknowledgementNoticeAsync(Listing post, CancellationToken ct) { @@ -135,10 +137,7 @@ public async Task PublishAcknowledgementNoticeAsync(Listing post, CancellationTo } } - public bool PinDue(Listing post) => post.Status is ListingStatus.Open or ListingStatus.Closed && - post.PublicMessageId is not null && - (post.PublicPin.MessageId != post.PublicMessageId || - (!post.PublicPin.Confirmed || post.PublicPin.RestoreArchive) && (post.PublicPin.Fault is null || post.PublicPin.Fault.IsDue(Now))); + public bool PinDue(Listing post) => ListingDeliveryPolicy.PinDue(post, Now); public async Task PinResponseAsync(Listing post, CancellationToken ct) { diff --git a/DiscordBot/Services/Recruitment/Application/ListingQueries.cs b/DiscordBot/Services/Recruitment/Application/ListingQueries.cs index f012d24b..cf25747d 100644 --- a/DiscordBot/Services/Recruitment/Application/ListingQueries.cs +++ b/DiscordBot/Services/Recruitment/Application/ListingQueries.cs @@ -5,7 +5,13 @@ namespace DiscordBot.Services.Recruitment.Application; -internal sealed class ListingQueries(RecruitmentState state, RecruitmentSettings settings, IRecruitmentDiscord discord, TimeProvider time, ListingOwnerCommands owners, Func evidenceCurrent) +internal sealed class ListingQueries( + RecruitmentState state, + RecruitmentSettings settings, + IRecruitmentDiscord discord, + TimeProvider time, + ListingOwnerCommands owners, + Func evidenceCurrent) { private readonly RecruitmentState State = state; private readonly RecruitmentSettings _settings = settings; diff --git a/DiscordBot/Services/Recruitment/Application/ListingReader.cs b/DiscordBot/Services/Recruitment/Application/ListingReader.cs index f5bd86d1..1efc69dd 100644 --- a/DiscordBot/Services/Recruitment/Application/ListingReader.cs +++ b/DiscordBot/Services/Recruitment/Application/ListingReader.cs @@ -7,7 +7,13 @@ namespace DiscordBot.Services.Recruitment.Application; -internal sealed class ListingReader(RecruitmentState state, RecruitmentSettings settings, IRecruitmentDiscord discord, TimeProvider time, Action save, AcknowledgementRecovery windows) +internal sealed class ListingReader( + RecruitmentState state, + RecruitmentSettings settings, + IRecruitmentDiscord discord, + TimeProvider time, + Action save, + AcknowledgementRecovery windows) { private readonly RecruitmentState State = state; private readonly RecruitmentSettings _settings = settings; diff --git a/DiscordBot/Services/Recruitment/Application/ListingReportCommands.cs b/DiscordBot/Services/Recruitment/Application/ListingReportCommands.cs index 39521062..c9461854 100644 --- a/DiscordBot/Services/Recruitment/Application/ListingReportCommands.cs +++ b/DiscordBot/Services/Recruitment/Application/ListingReportCommands.cs @@ -5,7 +5,14 @@ namespace DiscordBot.Services.Recruitment.Application; -internal sealed class ListingReportCommands(RecruitmentState state, TimeProvider time, Action save, ListingQueries queries, ListingPublisher publisher, ListingWorkflow workflow, Func reportingReady) +internal sealed class ListingReportCommands( + RecruitmentState state, + TimeProvider time, + Action save, + ListingQueries queries, + ListingPublisher publisher, + ListingWorkflow workflow, + Func reportingReady) { private readonly RecruitmentState State = state; private DateTimeOffset Now => time.GetUtcNow(); diff --git a/DiscordBot/Services/Recruitment/Application/ListingStaffCommands.cs b/DiscordBot/Services/Recruitment/Application/ListingStaffCommands.cs index c18533ea..b0bbb987 100644 --- a/DiscordBot/Services/Recruitment/Application/ListingStaffCommands.cs +++ b/DiscordBot/Services/Recruitment/Application/ListingStaffCommands.cs @@ -5,7 +5,14 @@ namespace DiscordBot.Services.Recruitment.Application; -internal sealed class ListingStaffCommands(RecruitmentState state, TimeProvider time, Action save, ListingActionExecutor actions, ListingPublisher publisher, AcknowledgementRecovery windows, Func> discover) +internal sealed class ListingStaffCommands( + RecruitmentState state, + TimeProvider time, + Action save, + ListingActionExecutor actions, + ListingPublisher publisher, + AcknowledgementRecovery windows, + Func> discover) { private readonly RecruitmentState State = state; private readonly ListingActionExecutor _actions = actions; diff --git a/DiscordBot/Services/Recruitment/Application/ListingWorkflow.cs b/DiscordBot/Services/Recruitment/Application/ListingWorkflow.cs index cbadff18..3a7bdeb1 100644 --- a/DiscordBot/Services/Recruitment/Application/ListingWorkflow.cs +++ b/DiscordBot/Services/Recruitment/Application/ListingWorkflow.cs @@ -1,3 +1,4 @@ +using DiscordBot.Services.Recruitment.Presentation; using System.IO; using DiscordBot.Settings; using DiscordBot.Utils; @@ -7,7 +8,20 @@ namespace DiscordBot.Services.Recruitment.Application; -internal sealed class ListingWorkflow(RecruitmentState state, RecruitmentSettings settings, IRecruitmentDiscord discord, TimeProvider time, Action save, AcknowledgementRecovery windows, ListingReader reader, ListingPublisher publisher, ListingActionExecutor actions, ListingEvidenceCapture facts, ListingForums forums, Func evidenceCurrent, Action observeReferenceCandidate) +internal sealed class ListingWorkflow( + RecruitmentState state, + RecruitmentSettings settings, + IRecruitmentDiscord discord, + TimeProvider time, + Action save, + AcknowledgementRecovery windows, + ListingReader reader, + ListingPublisher publisher, + ListingActionExecutor actions, + ListingEvidenceCapture facts, + ListingForums forums, + Func evidenceCurrent, + Action observeReferenceCandidate) { private readonly RecruitmentState State = state; private readonly RecruitmentSettings _settings = settings; diff --git a/DiscordBot/Services/Recruitment/Application/RecruitmentAttempts.cs b/DiscordBot/Services/Recruitment/Application/RecruitmentAttempts.cs deleted file mode 100644 index 39535c1a..00000000 --- a/DiscordBot/Services/Recruitment/Application/RecruitmentAttempts.cs +++ /dev/null @@ -1,30 +0,0 @@ -using DiscordBot.Utils; -using DiscordBot.Services.Recruitment.Domain.Policies; -namespace DiscordBot.Services.Recruitment; - -public sealed partial class RecruitmentEngine -{ - public async Task ThreadCreatedAsync(ThreadCreation creation, CancellationToken ct) - { - if (State is null || !_ready || !State.Forums.Values.Any(f => f.ForumId == creation.ForumId)) return; - await ThreadChangedAsync(creation.Id, ct); - if (State.Listings.ContainsKey(creation.Id) || creation.CreatedAt <= Now.AddMinutes(-_settings.AttemptWindowMinutes)) return; - // THREAD_CREATE can also announce access to an old thread. Only recent, confirmed-absent - // creations with a known member identity count here; successful discovery already counted itself. - if (await _discord.GetThreadAsync(creation.Id, ct, refresh: true) is not null) return; - var author = await _discord.GetAuthorAsync(creation.OwnerId, ct); - if (author is null || author.IsBot || author.IsStaff) return; - var post = new Listing - { - Id = creation.Id, ForumId = creation.ForumId, OwnerId = creation.OwnerId, - Title = DiscordDisplayText.Literal(creation.Title), CreatedAt = creation.CreatedAt.ToUniversalTime(), UpdatedAt = Now, - Status = ListingStatus.Deleted, LastOutcome = "Thread absent before first read; creation observed, deletion actor unknown", - PublicationPending = true - }; - State.Listings[post.Id] = post; - _workflow!.RecordCreation(post); Save(); - await _workflow!.ProcessSafelyAsync(post, ct); - } - - // Called once per newly retained thread identity, regardless of later deletion or acknowledgement. -} diff --git a/DiscordBot/Services/Recruitment/Application/RecruitmentAttention.cs b/DiscordBot/Services/Recruitment/Application/RecruitmentAttention.cs deleted file mode 100644 index c2142556..00000000 --- a/DiscordBot/Services/Recruitment/Application/RecruitmentAttention.cs +++ /dev/null @@ -1,35 +0,0 @@ -using DiscordBot.Utils; -namespace DiscordBot.Services.Recruitment; - -public sealed partial class RecruitmentEngine -{ - private string? AttentionSummary() - { - if (State is null) return null; - var rows = new List(); - foreach (var post in State.Listings.Values.OrderByDescending(p => p.UpdatedAt)) - { - var reasons = new List(); - if (post.Reports.Any(r => r.ResolvedAt is null)) reasons.Add("private reports"); - if (post.MaterialChanges > post.ReviewedMaterialChanges) reasons.Add("changed terms/links"); - if (post.PublicPin.Fault is not null) reasons.Add("optional response pin: " + DiscordDisplayText.Literal(post.PublicPin.Fault.Message, 100)); - if (post.Hold is not null) reasons.Add("processing held"); - if (post.AttemptPausedUntil > Now) reasons.Add("posting pause"); - if (post.TimeoutAlertCount >= 3 && State.Authors.GetValueOrDefault(post.OwnerId)?.TimeoutAlerted == true) reasons.Add("repeated timeouts"); - if (post.Attention.Any(n => n.Fault is not null)) reasons.Add("notice delivery held"); - if (reasons.Count > 0) rows.Add($"• <#{post.Id}> · {string.Join(", ", reasons)}"); - } - if (rows.Count == 0) return null; - return string.Join("\n", rows.Take(5)) + (rows.Count > 5 ? $"\n…and {rows.Count - 5} more; use lookup for individual records." : ""); - } - - private string? ActivitySummary() - { - if (State is null) return null; - var recent = State.Listings.Values.Where(p => p.CreatedAt > Now.AddDays(-1)).ToArray(); - var accepted = recent.Where(p => p.AcceptedAt.HasValue).ToArray(); - var delay = accepted.Length == 0 ? "—" : Math.Round(accepted.Average(p => (p.AcceptedAt!.Value - (p.PromptDeliveredAt ?? p.CreatedAt)).TotalMinutes), 1).ToString("0.#", System.Globalization.CultureInfo.InvariantCulture) + " min"; - return $"Past 24h: {recent.Length} creations · {accepted.Length} accepted · {recent.Count(p => p.Status == ListingStatus.Closed && !p.AcceptedAt.HasValue)} closed conflicts · " + - $"{recent.Count(p => p.LastOutcome == "Guidelines acknowledgement timed out")} timeouts · mean prompt-to-accept: {delay}."; - } -} diff --git a/DiscordBot/Services/Recruitment/Application/RecruitmentEngine.cs b/DiscordBot/Services/Recruitment/Application/RecruitmentEngine.cs index dac149de..960caf6d 100644 --- a/DiscordBot/Services/Recruitment/Application/RecruitmentEngine.cs +++ b/DiscordBot/Services/Recruitment/Application/RecruitmentEngine.cs @@ -1,20 +1,34 @@ using DiscordBot.Services.Recruitment.Application; +using DiscordBot.Services.Recruitment.Domain.Policies; using DiscordBot.Services.Recruitment.Infrastructure.Discord; +using DiscordBot.Services.Recruitment.Runtime; +using DiscordBot.Settings; using DiscordBot.Utils; -using DiscordBot.Services.Recruitment.Domain.Policies; using System.IO; -using DiscordBot.Settings; namespace DiscordBot.Services.Recruitment; /// All entry points are serialized by RecruitmentService, including staff and owner commands. -public sealed partial class RecruitmentEngine : IDisposable +public sealed class RecruitmentEngine : IDisposable { private readonly BotSettings _bot; private readonly RecruitmentSettings _settings; private readonly IRecruitmentDiscord _discord; private readonly TimeProvider _time; + private readonly Func _auditJitter; private readonly Func _pendingThreadWork; + private readonly Func _evidenceGeneration; + private readonly Func _gatewayReady; + private readonly Func _pendingIdentities; + private DateTimeOffset _nextAudit = DateTimeOffset.MaxValue; + private bool _accessInvalid = true; + private bool EvidenceCurrent => _gatewayReady() && !_pendingIdentities() && _inventoryComplete && _inventoryGeneration == _evidenceGeneration(); + private bool RoutinePendingIdentity => _ready && _gatewayReady() && _pendingIdentities() && _inventoryComplete && _inventoryGeneration == _evidenceGeneration(); + public event Action? Changed; + + private bool _feedReady; + private long _feedGeneration = -1; + private bool _feedEmbeds; private RecruitmentStateStore? _store; private RecruitmentAssets? _assets; private RecruitmentSetup? _setup; @@ -34,13 +48,18 @@ public sealed partial class RecruitmentEngine : IDisposable private ListingReportCommands? _reportCommands; private ListingStaffCommands? _staffCommands; private PortfolioCommands? _portfolioCommands; + private RecruitmentOverviewPublisher? _overview; + private ListingEvents? _events; public string Status { get; private set; } = "Not started"; public RecruitmentState? State => _store?.State; private DateTimeOffset Now => _time.GetUtcNow(); - public RecruitmentEngine(BotSettings bot, IRecruitmentDiscord discord, TimeProvider? time = null, Func? pendingThreadWork = null, Func? evidenceGeneration = null, Func? gatewayReady = null, Func? pendingIdentities = null) + public RecruitmentEngine(BotSettings bot, IRecruitmentDiscord discord, TimeProvider? time = null, + Func? pendingThreadWork = null, Func? evidenceGeneration = null, Func? gatewayReady = null, + Func? pendingIdentities = null, Func? auditJitter = null) { (_bot, _settings, _discord, _time) = (bot, bot.Recruitment, discord, time ?? TimeProvider.System); + _auditJitter = auditJitter ?? Random.Shared.NextDouble; _pendingThreadWork = pendingThreadWork ?? (_ => false); _evidenceGeneration = evidenceGeneration ?? (() => 0); _gatewayReady = gatewayReady ?? (() => true); @@ -77,6 +96,10 @@ public async Task StartAsync(CancellationToken ct) _staffCommands = new ListingStaffCommands(State!, _time, Save, _actions, _publisher, _recoverWindows, async token => { await DiscoverAsync(token); return _inventoryComplete; }); _portfolioCommands = new PortfolioCommands(State!, _time, Save); + _overview = new RecruitmentOverviewPublisher(State!, _settings, _discord, _time, Save, + () => _feedReady && _gatewayReady() && _feedGeneration == _evidenceGeneration(), () => _feedEmbeds, GuardFeedWrite, () => Health()); + _events = new ListingEvents(State!, _settings, _discord, _time, Save, _workflow, _reader, _references, + _publisher, _overview, () => _ready, RequestRecovery); _setup = new RecruitmentSetup(_store, _assets, _settings, _discord); _setup.Progress += () => Changed?.Invoke(); MarkEvidenceGap(); @@ -107,7 +130,7 @@ public async Task SweepAsync(CancellationToken ct, bool retryHeld = true) var previousFault = State!.RecoveryFault; var auditDue = _nextAudit <= Now; var held = !retryHeld && State.RecoveryFault is { } recovery && !recovery.IsDue(Now); - _nextAudit = Now.AddMinutes(15 + Random.Shared.NextDouble() * 3); + _nextAudit = Now.AddMinutes(15 + _auditJitter() * 3); _publisher?.ClearRenders(); _discord.InvalidateCache(); try @@ -181,14 +204,12 @@ private async Task ReconcileListingBatchAsync(RecruitmentState state, bool retry private Task DiscoverAsync(CancellationToken ct) => _inventory!.DiscoverAsync(ct); - public async Task ReconcileThreadAsync(string thread, CancellationToken ct) - { - if (!_ready || State?.Listings.GetValueOrDefault(thread) is not { } post) return; - await _workflow!.ProcessSafelyAsync(post, ct); - foreach (var waiting in State.Listings.Values.Where(p => p.Id != post.Id && p.OwnerId == post.OwnerId && - p.Status == ListingStatus.Open && !p.AcceptedAt.HasValue).OrderBy(p => p.CreatedAt).ToArray()) - await _workflow!.ProcessSafelyAsync(waiting, ct); - } + public Task ReconcileThreadAsync(string id, CancellationToken ct) => _events?.ReconcileThreadAsync(id, ct) ?? Task.CompletedTask; + public Task ThreadCreatedAsync(ThreadCreation creation, CancellationToken ct) => _events?.ThreadCreatedAsync(creation, ct) ?? Task.CompletedTask; + public Task ThreadChangedAsync(string id, CancellationToken ct) => _events?.ThreadChangedAsync(id, ct) ?? Task.CompletedTask; + public Task ThreadDeletedAsync(string id, CancellationToken ct) => _events?.ThreadDeletedAsync(id, ct) ?? Task.CompletedTask; + public Task FactsChangedAsync(string id, CancellationToken ct) => _events?.FactsChangedAsync(id, ct) ?? Task.CompletedTask; + public Task MessageDeletedAsync(string channel, IReadOnlySet ids, CancellationToken ct) => _events?.MessageDeletedAsync(channel, ids, ct) ?? Task.CompletedTask; public Task ObserveAsync(ThreadSnapshot live, bool observedFromCreation, CancellationToken ct) => !_ready || _workflow is null ? Task.CompletedTask : _workflow.ObserveAsync(live, observedFromCreation, ct); @@ -278,5 +299,57 @@ private void Save() throw; } } + + public RecruitmentHealth Health(string work = "Idle") => RecruitmentHealthQuery.Build(State, _ready, EvidenceCurrent, + RoutinePendingIdentity, Status, work, Now, NextWorkAt); + + private RecruitmentDueWork? NextWork => State is null || !_settings.Enabled ? null : + RecruitmentSchedule.Next(State, Now, _nextAudit, _ready, EvidenceCurrent, _settings.CooldownDays, _recoverWindows); + private DateTimeOffset? NextWorkAt => NextWork?.At; + private DateTimeOffset? ListingDue(Listing post) => + RecruitmentSchedule.Listing(State!, post, Now, _settings.CooldownDays, _recoverWindows.Contains(post.Id))?.At; + + public async Task RunDueAsync(CancellationToken ct) + { + if (State is null) return; + if (RecruitmentSchedule.RecoveryDue(State, Now, _nextAudit, _ready)) + { + await SweepAsync(ct, retryHeld: false); + return; + } + if (!_ready || !EvidenceCurrent) return; + foreach (var post in State.Listings.Values.Where(p => ListingDue(p) <= Now).OrderBy(p => p.CreatedAt).Take(20).ToArray()) + await _workflow!.ProcessSafelyAsync(post, ct); + } + + public void RequestRecovery() => _nextAudit = Now; + + public void InvalidateAccess(string? forum = null) + { + _accessInvalid = true; + _feedReady = false; + _ready = false; + _discord.InvalidateCache(); + _publisher?.ClearRenders(); + if (State is null) return; + State.RecoveryFault = null; + State.Overview.Fault = null; + foreach (var f in State.Forums.Values.Where(f => forum is null || f.ForumId == forum)) f.Fault = null; + foreach (var f in State.Forums.Values.Where(f => forum is null || f.ForumId == forum)) f.Reference.Fault = null; + foreach (var post in State.Listings.Values.Where(p => forum is null || p.ForumId == forum)) + { post.Fault = null; post.PublicPin.Fault = null; post.AcknowledgementNotice.Fault = null; } + _nextAudit = Now; + MarkEvidenceGap(); + } + + private void GuardFeedWrite() + { + if (!_feedReady || !_gatewayReady() || _feedGeneration != _evidenceGeneration()) + throw new InvalidOperationException("Feed access changed; recruitment recovery is required before publication."); + } + + public DateTimeOffset? NextDueAt => new[] { NextWorkAt, _overview?.NextDueAt }.Where(t => t.HasValue).Min(); + public Task RefreshOverviewAsync(CancellationToken ct) => _overview?.RefreshAsync(ct) ?? Task.CompletedTask; + public void Dispose() => _store?.Dispose(); } diff --git a/DiscordBot/Services/Recruitment/Application/RecruitmentEngineScheduling.cs b/DiscordBot/Services/Recruitment/Application/RecruitmentEngineScheduling.cs deleted file mode 100644 index a532bd2d..00000000 --- a/DiscordBot/Services/Recruitment/Application/RecruitmentEngineScheduling.cs +++ /dev/null @@ -1,163 +0,0 @@ -using DiscordBot.Services.Recruitment.Infrastructure.Discord; -using DiscordBot.Services.Recruitment.Domain.Policies; -namespace DiscordBot.Services.Recruitment; - -public sealed partial class RecruitmentEngine -{ - private readonly Func _evidenceGeneration; - private readonly Func _gatewayReady; - private readonly Func _pendingIdentities; - private DateTimeOffset _nextAudit = DateTimeOffset.MaxValue; - private bool _accessInvalid = true; - private bool EvidenceCurrent => _gatewayReady() && !_pendingIdentities() && _inventoryComplete && _inventoryGeneration == _evidenceGeneration(); - private bool RoutinePendingIdentity => _ready && _gatewayReady() && _pendingIdentities() && _inventoryComplete && _inventoryGeneration == _evidenceGeneration(); - public event Action? Changed; - - public RecruitmentHealth Health(string work = "Idle") => new(_ready ? EvidenceCurrent ? "Operating" : RoutinePendingIdentity ? "Processing new listing identities" : "Inventory incomplete; acceptance and automatic actions held" : Status, work, Now, - State?.Listings.Values.Count(p => p.Status == ListingStatus.Open) ?? 0, - State?.Listings.Values.Count(p => p.Status == ListingStatus.Open && !p.AcknowledgedAt.HasValue) ?? 0, - State?.Listings.Values.Count(p => p.Hold is not null) ?? 0, EvidenceCurrent, - State?.LastSweepAt, NextWorkAt, - State?.Forums.Select(f => new ForumHealth(f.Key, f.Value.ForumId, - f.Value.Fault is not null ? "held" : f.Value.PendingTopic is not null ? "publication pending" : - f.Value.ConfirmedTopic.Length == 0 ? "not verified" : "Guidelines and tags verified", - f.Value.Fault?.Message, f.Value.Fault?.RetryAt, f.Value.Reference.ThreadId, f.Value.Reference.Fault?.Message)).ToArray() ?? Array.Empty(), State?.Overview.Fault?.Message, RoutinePendingIdentity: RoutinePendingIdentity, Attention: AttentionSummary(), Metrics: ActivitySummary()); - - private DateTimeOffset? NextWorkAt - { - get - { - if (State is null || !_settings.Enabled) return null; - var times = new List(); - if (_nextAudit != DateTimeOffset.MaxValue) times.Add(_nextAudit); - if (State.RecoveryFault?.RetryAt is { } retry) times.Add(retry); - // Expired child retries cannot run through a parent hold; scheduling them would wake every second. - if (State.RecoveryFault is null) - times.AddRange(State.Forums.Values.Where(f => f.Fault?.RetryAt is not null).Select(f => f.Fault!.RetryAt!.Value)); - if (_ready) - times.AddRange(State.Forums.Values.Where(f => f.Reference.Fault?.RetryAt is not null).Select(f => f.Reference.Fault!.RetryAt!.Value)); - if (_ready && EvidenceCurrent) - { - times.Add(State.RotateAt); - times.AddRange(State.Listings.Values.Select(ListingDue).Where(d => d.HasValue).Select(d => d!.Value)); - } - return times.Count == 0 ? null : times.Min(); - } - } - - private DateTimeOffset? ListingDue(Listing post) - { - if (post.Fault is { } fault) return fault.RetryAt; - var pinDue = _publisher!.PinDue(post) ? (DateTimeOffset?)Now : - post.Status is ListingStatus.Open or ListingStatus.Closed && post.PublicMessageId is not null - ? post.PublicPin.Fault?.RetryAt : null; - if (post.PendingAction is not null || post.PublicSendPending || post.StaffSendPending || post.PublicationPending) return Now; - var attention = post.Attention.Where(n => n.MessageId is null || n.SendPending) - .Select(n => n.Fault is null ? (DateTimeOffset?)Now : n.Fault.RetryAt).Where(t => t.HasValue).Min(); - if (post.Status != ListingStatus.Open) return new[] { attention, pinDue }.Where(t => t.HasValue).Min(); - if (post.PortfolioRegistryVersion != State!.PortfolioSites.Version) return Now; - if (_recoverWindows.Contains(post.Id)) return Now; - if (!post.PromptDeliveredAt.HasValue && !post.AcceptedAt.HasValue) return Now; - var dates = new List(); - if (_publisher!.AcknowledgementNoticeDue(post) is { } noticeDue) dates.Add(noticeDue); - if (attention.HasValue) dates.Add(attention.Value); - if (pinDue.HasValue) dates.Add(pinDue.Value); - if (!post.Exempt && !post.Pinned) - { - if (!post.AcceptedAt.HasValue && post.Deadline.HasValue) dates.Add(post.Deadline.Value); - } - if (post.AcknowledgedAt.HasValue && !post.AcceptedAt.HasValue) - { - var next = ListingEligibilityPolicy.Evaluate(State!, post, Now, _settings.CooldownDays); - if (next.BlockingListingId is null) dates.Add(next.NextAllowedAt ?? Now); - } - return dates.Count == 0 ? null : dates.Min(); - } - - public async Task RunDueAsync(CancellationToken ct) - { - if (State is null) return; - if (_nextAudit <= Now || State.RecoveryFault?.IsDue(Now) == true || - State.RecoveryFault is null && State.Forums.Values.Any(f => f.Fault?.IsDue(Now) == true) || - _ready && (State.Forums.Values.Any(f => f.Reference.Fault?.IsDue(Now) == true) || State.RotateAt <= Now)) - { - await SweepAsync(ct, retryHeld: false); - return; - } - if (!_ready || !EvidenceCurrent) return; - foreach (var post in State.Listings.Values.Where(p => ListingDue(p) <= Now).OrderBy(p => p.CreatedAt).Take(20).ToArray()) - await _workflow!.ProcessSafelyAsync(post, ct); - } - - public void RequestRecovery() => _nextAudit = Now; - - public void InvalidateAccess(string? forum = null) - { - _accessInvalid = true; - _feedReady = false; - _ready = false; - _discord.InvalidateCache(); - _publisher?.ClearRenders(); - if (State is null) return; - State.RecoveryFault = null; - State.Overview.Fault = null; - foreach (var f in State.Forums.Values.Where(f => forum is null || f.ForumId == forum)) f.Fault = null; - foreach (var f in State.Forums.Values.Where(f => forum is null || f.ForumId == forum)) f.Reference.Fault = null; - foreach (var post in State.Listings.Values.Where(p => forum is null || p.ForumId == forum)) - { post.Fault = null; post.PublicPin.Fault = null; post.AcknowledgementNotice.Fault = null; } - _nextAudit = Now; - MarkEvidenceGap(); - } - - public async Task ThreadChangedAsync(string id, CancellationToken ct) - { - _discord.InvalidateThread(id); - if (State?.Forums.Values.Any(f => f.Reference.ThreadId == id) == true) return; - if (State is null || !_ready) return; - if (!State.Listings.TryGetValue(id, out var post)) - { - var live = await _discord.GetThreadAsync(id, ct); - // ThreadCreated also signals access to an existing thread; it cannot certify reply coverage. - if (live is not null) { _references!.ObserveReferenceCandidate(live); await ObserveAsync(live, false, ct); } - if (!State.Listings.TryGetValue(id, out post)) return; - } - post.Fault = null; - await ReconcileThreadAsync(id, ct); - } - - public async Task ThreadDeletedAsync(string id, CancellationToken ct) - { - _discord.InvalidateThread(id); - if (State?.Forums.Values.FirstOrDefault(f => f.Reference.ThreadId == id) is { } forum) - { - forum.Reference = new ReferenceReceipt(); _references!.Forget(forum.ForumId, id); - RequestRecovery(); Save(); - } - if (State?.Listings.GetValueOrDefault(id) is not { } post) return; - _reader!.RecordAbsence(post); - if (_ready) await ReconcileThreadAsync(id, ct); - } - - public Task FactsChangedAsync(string id, CancellationToken ct) - { - _discord.InvalidateFacts(id); - return ThreadChangedAsync(id, ct); - } - - public async Task MessageDeletedAsync(string channel, IReadOnlySet ids, CancellationToken ct) - { - if (State is null) return; - if (channel == _settings.FeedChannelId.ToString() && State.Overview.MessageId is { } overview && ids.Contains(overview)) - { State.Overview.MessageId = null; State.Overview.Fault = null; _overviewRender = null; Save(); } - foreach (var post in State.Listings.Values.Where(p => - channel == _settings.FeedChannelId.ToString() && p.StaffMessageId is not null && ids.Contains(p.StaffMessageId) || - channel == p.Id && p.PublicMessageId is not null && ids.Contains(p.PublicMessageId)).ToArray()) - { - if (channel == post.Id) { post.PublicMessageId = null; _publisher!.InvalidatePublic(post.Id); } - else { post.StaffMessageId = null; _publisher!.InvalidateStaff(post.Id); } - post.PublicationPending = true; post.Fault = null; Save(); - if (_ready) await _workflow!.ProcessSafelyAsync(post, ct); - } - } - -} diff --git a/DiscordBot/Services/Recruitment/Application/RecruitmentOverview.cs b/DiscordBot/Services/Recruitment/Application/RecruitmentOverviewPublisher.cs similarity index 70% rename from DiscordBot/Services/Recruitment/Application/RecruitmentOverview.cs rename to DiscordBot/Services/Recruitment/Application/RecruitmentOverviewPublisher.cs index 0d28cb16..c6ee34cc 100644 --- a/DiscordBot/Services/Recruitment/Application/RecruitmentOverview.cs +++ b/DiscordBot/Services/Recruitment/Application/RecruitmentOverviewPublisher.cs @@ -1,26 +1,34 @@ +using DiscordBot.Settings; using DiscordBot.Services.Recruitment.Infrastructure.Discord; -namespace DiscordBot.Services.Recruitment; +using DiscordBot.Services.Recruitment.Presentation; -public sealed partial class RecruitmentEngine +namespace DiscordBot.Services.Recruitment.Application; + +internal sealed class RecruitmentOverviewPublisher( + RecruitmentState state, + RecruitmentSettings settings, + IRecruitmentDiscord discord, + TimeProvider time, + Action save, + Func feedAvailable, + Func feedEmbeds, + Action guardFeedWrite, + Func healthSnapshot) { - private bool _feedReady; - private long _feedGeneration = -1; - private bool _feedEmbeds; + private readonly RecruitmentState State = state; + private readonly RecruitmentSettings _settings = settings; + private readonly IRecruitmentDiscord _discord = discord; + private DateTimeOffset Now => time.GetUtcNow(); + private void Save() => save(); + public void InvalidateRender() => _overviewRender = null; private string? _overviewRender; private DateTimeOffset? _overviewDirtyAt; - private void GuardFeedWrite() - { - if (!_feedReady || !_gatewayReady() || _feedGeneration != _evidenceGeneration()) - throw new InvalidOperationException("Feed access changed; recruitment recovery is required before publication."); - } - - public DateTimeOffset? NextDueAt => new[] { NextWorkAt, OverviewDueAt }.Where(t => t.HasValue).Min(); - private DateTimeOffset? OverviewDueAt + public DateTimeOffset? NextDueAt { get { - if (!_feedReady || !_gatewayReady() || _feedGeneration != _evidenceGeneration() || State is null) return null; + if (!feedAvailable()) return null; var receipt = State.Overview; if (receipt.Fault is not null) return receipt.Fault.RetryAt; if (receipt.SendPending || receipt.RetiredMessageId is not null || receipt.MessageId is null) return Now; @@ -32,14 +40,14 @@ private DateTimeOffset? OverviewDueAt } private static DateTimeOffset Max(DateTimeOffset a, DateTimeOffset b) => a > b ? a : b; - public async Task RefreshOverviewAsync(CancellationToken ct) + public async Task RefreshAsync(CancellationToken ct) { - if (!_feedReady || !_gatewayReady() || _feedGeneration != _evidenceGeneration() || State is null) return; + if (!feedAvailable()) return; var receipt = State.Overview; - var health = Health(); + var health = healthSnapshot(); var render = health.Text(); if (render != _overviewRender) _overviewDirtyAt ??= Now; - if (!(OverviewDueAt <= Now)) return; + if (!(NextDueAt <= Now)) return; if (receipt.Fault is not null && !receipt.Fault.IsDue(Now)) return; try { @@ -57,10 +65,10 @@ public async Task RefreshOverviewAsync(CancellationToken ct) receipt.SendStartedAt = Now; } var marker = receipt.PendingMarker ?? throw new InvalidOperationException("Overview delivery marker is missing; reconcile required."); - var message = new RecruitmentMessage(_feedEmbeds ? $"-# {marker}" : "**Recruitment Status**\n" + render[..Math.Min(1800, render.Length)] + $"\n-# {marker}", - marker, false, Embed: _feedEmbeds ? health.Embed(includeFreshness: false) : null); + var message = new RecruitmentMessage(feedEmbeds() ? $"-# {marker}" : "**Recruitment Status**\n" + render[..Math.Min(1800, render.Length)] + $"\n-# {marker}", + marker, false, Embed: feedEmbeds() ? health.Embed(includeFreshness: false) : null); var delivery = await _discord.UpsertAsync(_settings.FeedChannelId.ToString(), replace ? null : receipt.MessageId, - receipt.SendPending, receipt.SendStartedAt ?? Now, message, () => { GuardFeedWrite(); receipt.SendPending = true; Save(); }, ct); + receipt.SendPending, receipt.SendStartedAt ?? Now, message, () => { guardFeedWrite(); receipt.SendPending = true; Save(); }, ct); if (replace && receipt.MessageId is not null && receipt.MessageId != delivery.Id) { receipt.RetiredMessageId = receipt.MessageId; receipt.RetiredMarker = receipt.Marker; } receipt.MessageId = delivery.Id; receipt.Marker = marker; receipt.SendPending = false; diff --git a/DiscordBot/Services/Recruitment/Application/ReferenceReconciler.cs b/DiscordBot/Services/Recruitment/Application/ReferenceReconciler.cs index 8430c192..1b8440ed 100644 --- a/DiscordBot/Services/Recruitment/Application/ReferenceReconciler.cs +++ b/DiscordBot/Services/Recruitment/Application/ReferenceReconciler.cs @@ -3,8 +3,14 @@ namespace DiscordBot.Services.Recruitment.Application; internal sealed record ReferenceCandidate(string Id, bool BotOwned); -internal sealed class ReferenceReconciler(RecruitmentState state, RecruitmentAssets assets, IRecruitmentDiscord discord, - TimeProvider time, Action save, Func evidenceCurrent, Action requestRetry) +internal sealed class ReferenceReconciler( + RecruitmentState state, + RecruitmentAssets assets, + IRecruitmentDiscord discord, + TimeProvider time, + Action save, + Func evidenceCurrent, + Action requestRetry) { private readonly RecruitmentState State = state; private readonly RecruitmentAssets _assets = assets; diff --git a/DiscordBot/Services/Recruitment/Domain/Analysis/RecruitmentPortfolioDomains.cs b/DiscordBot/Services/Recruitment/Domain/Analysis/PortfolioHostnamePolicy.cs similarity index 54% rename from DiscordBot/Services/Recruitment/Domain/Analysis/RecruitmentPortfolioDomains.cs rename to DiscordBot/Services/Recruitment/Domain/Analysis/PortfolioHostnamePolicy.cs index 884f6a13..0061b5ee 100644 --- a/DiscordBot/Services/Recruitment/Domain/Analysis/RecruitmentPortfolioDomains.cs +++ b/DiscordBot/Services/Recruitment/Domain/Analysis/PortfolioHostnamePolicy.cs @@ -1,31 +1,12 @@ using System.Globalization; -using System.IO; -namespace DiscordBot.Services.Recruitment; +namespace DiscordBot.Services.Recruitment.Domain.Analysis; -public static class RecruitmentPortfolioDomains +/// DNS/IDN and public-suffix decisions over supplied rules; no resource or filesystem access. +internal static class PortfolioHostnamePolicy { private static readonly IdnMapping Idn = new(); - private static readonly Lazy> Rules = new(() => - { - using var stream = typeof(RecruitmentPortfolioDomains).Assembly.GetManifestResourceStream("Recruitment.PublicSuffixIcann") - ?? throw new InvalidDataException("Bundled ICANN public suffix rules are missing."); - using var reader = new StreamReader(stream); - var rules = new HashSet(StringComparer.Ordinal); - while (reader.ReadLine() is { } line) - { - line = line.Trim(); - if (line.Length == 0 || line.StartsWith("//", StringComparison.Ordinal)) continue; - var prefix = line.StartsWith('!') ? "!" : line.StartsWith("*.", StringComparison.Ordinal) ? "*." : ""; - rules.Add(prefix + Idn.GetAscii(line[prefix.Length..]).ToLowerInvariant()); - } - return rules; - }); - - private static readonly Lazy> Tlds = new(() => Rules.Value - .Select(r => r.Split('.').Last().TrimStart('!')).ToHashSet(StringComparer.Ordinal)); - - public static bool TryNormalize(string? input, out string host) + public static bool TryNormalize(string? input, IReadOnlySet rules, IReadOnlySet tlds, out string host) { host = ""; if (string.IsNullOrWhiteSpace(input)) return false; @@ -37,9 +18,8 @@ public static bool TryNormalize(string? input, out string host) if (value.Length > 253 || Uri.CheckHostName(value) != UriHostNameType.Dns || !value.Contains('.')) return false; var labels = value.Split('.'); if (labels.Any(l => l.Length is < 1 or > 63 || l[0] == '-' || l[^1] == '-' || l.Any(c => !char.IsAsciiLetterOrDigit(c) && c != '-'))) return false; - var rules = Rules.Value; // An unlisted TLD is not an Internet registry boundary (e.g. a local search suffix). - if (!Tlds.Value.Contains(labels[^1])) return false; + if (!tlds.Contains(labels[^1])) return false; var suffixLabels = 1; for (var i = 0; i < labels.Length; i++) { diff --git a/DiscordBot/Services/Recruitment/Domain/Policies/LegacyListingOutcomes.cs b/DiscordBot/Services/Recruitment/Domain/Policies/LegacyListingOutcomes.cs new file mode 100644 index 00000000..9afe96aa --- /dev/null +++ b/DiscordBot/Services/Recruitment/Domain/Policies/LegacyListingOutcomes.cs @@ -0,0 +1,7 @@ +namespace DiscordBot.Services.Recruitment.Domain.Policies; + +/// Compatibility interpretation of retained v2 outcome text; not an enforcement input. +internal static class LegacyListingOutcomes +{ + public static bool IsGuidelineTimeout(Listing post) => post.LastOutcome == "Guidelines acknowledgement timed out"; +} diff --git a/DiscordBot/Services/Recruitment/Domain/Policies/RecruitmentSchedule.cs b/DiscordBot/Services/Recruitment/Domain/Policies/RecruitmentSchedule.cs new file mode 100644 index 00000000..723c034c --- /dev/null +++ b/DiscordBot/Services/Recruitment/Domain/Policies/RecruitmentSchedule.cs @@ -0,0 +1,82 @@ +namespace DiscordBot.Services.Recruitment.Domain.Policies; + +internal enum RecruitmentDueReason +{ + Audit, RecoveryRetry, ForumRetry, ReferenceRetry, Rotation, ListingRetry, Publication, + Attention, Pin, RegistryRefresh, WindowRecovery, Prompt, AcknowledgementNotice, Deadline, Eligibility +} +internal sealed record RecruitmentDueWork(DateTimeOffset At, RecruitmentDueReason Reason); + +internal static class ListingDeliveryPolicy +{ + public static bool PinDue(Listing post, DateTimeOffset now) => post.Status is ListingStatus.Open or ListingStatus.Closed && + post.PublicMessageId is not null && + (post.PublicPin.MessageId != post.PublicMessageId || + (!post.PublicPin.Confirmed || post.PublicPin.RestoreArchive) && (post.PublicPin.Fault is null || post.PublicPin.Fault.IsDue(now))); + + public static DateTimeOffset? AcknowledgementNoticeDue(Listing post, DateTimeOffset now) + { + if (post.Status != ListingStatus.Open || post.AcceptedAt.HasValue || post.AcknowledgedAt.HasValue || + post.PublicMessageId is null || !(post.Deadline > now) || + post.AcknowledgementNotice.MessageId is not null && !post.AcknowledgementNotice.SendPending) return null; + return post.AcknowledgementNotice.Fault is { } fault ? fault.RetryAt : now; + } +} + +/// Pure due-work selection. Parent holds suppress work that cannot make progress. +internal static class RecruitmentSchedule +{ + public static RecruitmentDueWork? Next(RecruitmentState state, DateTimeOffset now, DateTimeOffset audit, + bool ready, bool evidenceCurrent, int cooldownDays, AcknowledgementRecovery windows) + { + var due = new List(); + void Add(DateTimeOffset? at, RecruitmentDueReason reason) { if (at.HasValue) due.Add(new(at.Value, reason)); } + if (audit != DateTimeOffset.MaxValue) Add(audit, RecruitmentDueReason.Audit); + Add(state.RecoveryFault?.RetryAt, RecruitmentDueReason.RecoveryRetry); + if (state.RecoveryFault is null) + foreach (var forum in state.Forums.Values) Add(forum.Fault?.RetryAt, RecruitmentDueReason.ForumRetry); + if (ready) + foreach (var forum in state.Forums.Values) Add(forum.Reference.Fault?.RetryAt, RecruitmentDueReason.ReferenceRetry); + if (ready && evidenceCurrent) + { + Add(state.RotateAt, RecruitmentDueReason.Rotation); + foreach (var post in state.Listings.Values) + if (Listing(state, post, now, cooldownDays, windows.Contains(post.Id)) is { } work) due.Add(work); + } + return due.MinBy(d => d.At); + } + + public static bool RecoveryDue(RecruitmentState state, DateTimeOffset now, DateTimeOffset audit, bool ready) => + audit <= now || state.RecoveryFault?.IsDue(now) == true || + state.RecoveryFault is null && state.Forums.Values.Any(f => f.Fault?.IsDue(now) == true) || + ready && (state.Forums.Values.Any(f => f.Reference.Fault?.IsDue(now) == true) || state.RotateAt <= now); + + public static RecruitmentDueWork? Listing(RecruitmentState state, Listing post, DateTimeOffset now, int cooldownDays, bool recoverWindow) + { + static RecruitmentDueWork? At(DateTimeOffset? at, RecruitmentDueReason reason) => at.HasValue ? new(at.Value, reason) : null; + if (post.Fault is { } fault) return At(fault.RetryAt, RecruitmentDueReason.ListingRetry); + var pinDue = ListingDeliveryPolicy.PinDue(post, now) ? (DateTimeOffset?)now : null; + if (!pinDue.HasValue && post.Status is ListingStatus.Open or ListingStatus.Closed && post.PublicMessageId is not null) + pinDue = post.PublicPin.Fault?.RetryAt; + if (post.PendingAction is not null || post.PublicSendPending || post.StaffSendPending || post.PublicationPending) + return new(now, RecruitmentDueReason.Publication); + var attention = post.Attention.Where(n => n.MessageId is null || n.SendPending) + .Select(n => n.Fault is null ? (DateTimeOffset?)now : n.Fault.RetryAt).Where(t => t.HasValue).Min(); + var due = new List(); + void Add(DateTimeOffset? at, RecruitmentDueReason reason) { if (At(at, reason) is { } work) due.Add(work); } + Add(attention, RecruitmentDueReason.Attention); + Add(pinDue, RecruitmentDueReason.Pin); + if (post.Status != ListingStatus.Open) return due.MinBy(d => d.At); + if (post.PortfolioRegistryVersion != state.PortfolioSites.Version) return new(now, RecruitmentDueReason.RegistryRefresh); + if (recoverWindow) return new(now, RecruitmentDueReason.WindowRecovery); + if (!post.PromptDeliveredAt.HasValue && !post.AcceptedAt.HasValue) return new(now, RecruitmentDueReason.Prompt); + Add(ListingDeliveryPolicy.AcknowledgementNoticeDue(post, now), RecruitmentDueReason.AcknowledgementNotice); + if (!post.Exempt && !post.Pinned && !post.AcceptedAt.HasValue) Add(post.Deadline, RecruitmentDueReason.Deadline); + if (post.AcknowledgedAt.HasValue && !post.AcceptedAt.HasValue) + { + var next = ListingEligibilityPolicy.Evaluate(state, post, now, cooldownDays); + if (next.BlockingListingId is null) Add(next.NextAllowedAt ?? now, RecruitmentDueReason.Eligibility); + } + return due.MinBy(d => d.At); + } +} diff --git a/DiscordBot/Services/Recruitment/Infrastructure/Assets/RecruitmentPortfolioDomains.cs b/DiscordBot/Services/Recruitment/Infrastructure/Assets/RecruitmentPortfolioDomains.cs new file mode 100644 index 00000000..5a327425 --- /dev/null +++ b/DiscordBot/Services/Recruitment/Infrastructure/Assets/RecruitmentPortfolioDomains.cs @@ -0,0 +1,31 @@ +using DiscordBot.Services.Recruitment.Domain.Analysis; +using System.Globalization; +using System.IO; + +namespace DiscordBot.Services.Recruitment; + +public static class RecruitmentPortfolioDomains +{ + private static readonly IdnMapping Idn = new(); + private static readonly Lazy> Rules = new(() => + { + using var stream = typeof(RecruitmentPortfolioDomains).Assembly.GetManifestResourceStream("Recruitment.PublicSuffixIcann") + ?? throw new InvalidDataException("Bundled ICANN public suffix rules are missing."); + using var reader = new StreamReader(stream); + var rules = new HashSet(StringComparer.Ordinal); + while (reader.ReadLine() is { } line) + { + line = line.Trim(); + if (line.Length == 0 || line.StartsWith("//", StringComparison.Ordinal)) continue; + var prefix = line.StartsWith('!') ? "!" : line.StartsWith("*.", StringComparison.Ordinal) ? "*." : ""; + rules.Add(prefix + Idn.GetAscii(line[prefix.Length..]).ToLowerInvariant()); + } + return rules; + }); + + private static readonly Lazy> Tlds = new(() => Rules.Value + .Select(r => r.Split('.').Last().TrimStart('!')).ToHashSet(StringComparer.Ordinal)); + + public static bool TryNormalize(string? input, out string host) => + PortfolioHostnamePolicy.TryNormalize(input, Rules.Value, Tlds.Value, out host); +} diff --git a/DiscordBot/Services/Recruitment/Infrastructure/Discord/DiscordInventoryReader.cs b/DiscordBot/Services/Recruitment/Infrastructure/Discord/DiscordInventoryReader.cs index 71b18eeb..e91be0b0 100644 --- a/DiscordBot/Services/Recruitment/Infrastructure/Discord/DiscordInventoryReader.cs +++ b/DiscordBot/Services/Recruitment/Infrastructure/Discord/DiscordInventoryReader.cs @@ -4,7 +4,9 @@ namespace DiscordBot.Services.Recruitment.Infrastructure.Discord; -internal sealed class DiscordInventoryReader(BotSettings settings, TimeProvider time, +internal sealed class DiscordInventoryReader( + BotSettings settings, + TimeProvider time, Func> forum, Func> readSnapshot, Func> readThread) diff --git a/DiscordBot/Services/Recruitment/Infrastructure/Discord/DiscordRecruitmentAdapter.cs b/DiscordBot/Services/Recruitment/Infrastructure/Discord/DiscordRecruitmentAdapter.cs index 1cdff955..bb5f6dbe 100644 --- a/DiscordBot/Services/Recruitment/Infrastructure/Discord/DiscordRecruitmentAdapter.cs +++ b/DiscordBot/Services/Recruitment/Infrastructure/Discord/DiscordRecruitmentAdapter.cs @@ -10,11 +10,12 @@ namespace DiscordBot.Services.Recruitment; /// Discord I/O and authoritative pre-action checks for the serialized recruitment engine. -public sealed partial class DiscordRecruitmentAdapter : IRecruitmentDiscord +public sealed class DiscordRecruitmentAdapter : IRecruitmentDiscord { private readonly DiscordSocketClient _client; private readonly BotSettings _settings; private readonly TimeProvider _time; + private readonly DiscordReferencePublisher _referencePublisher; private readonly Dictionary _forums = new(); private readonly ConcurrentDictionary _expectedForums = new(); private readonly Dictionary _threads = new(); @@ -24,7 +25,10 @@ public sealed partial class DiscordRecruitmentAdapter : IRecruitmentDiscord public void InvalidateFacts(string threadId) { _observedFacts.Remove(threadId); InvalidateThread(threadId); } public void InvalidateCache() { _threads.Clear(); _observedFacts.Clear(); _messageChannels.Clear(); _forums.Clear(); } public DiscordRecruitmentAdapter(DiscordSocketClient client, BotSettings settings, TimeProvider? time = null) - => (_client, _settings, _time) = (client, settings, time ?? TimeProvider.System); + { + (_client, _settings, _time) = (client, settings, time ?? TimeProvider.System); + _referencePublisher = new DiscordReferencePublisher(client, settings, CachedForumAsync, () => BotUserId, IsModeratorAsync, InvalidateFacts); + } private static RequestOptions Options(CancellationToken ct) => RecruitmentRequests.Options(ct); private ulong BotUserId => _client.CurrentUser?.Id ?? _client.Rest.CurrentUser?.Id ?? throw new InvalidOperationException("Authenticated bot identity is unavailable."); @@ -303,4 +307,23 @@ public async Task DeleteAdvisoryAsync(string threadId, string messageId, Cancell if (await channel.GetMessageAsync(ulong.Parse(messageId), CacheMode.AllowDownload, Options(ct)) is IUserMessage message && marker is not null && OwnedMessageLookup.OwnsMessage(message, BotUserId, channel.Id, marker, marker.EndsWith(":public", StringComparison.Ordinal) ? RecruitmentMessageFormat.ComponentsV2 : RecruitmentMessageFormat.Standard)) await message.DeleteAsync(Options(ct)); } + public async Task PinAdvisoryAsync(string threadId, string messageId, bool restoreArchive, bool restoreLocked, + Action beforeWrite, CancellationToken ct) + { + using var requestScope = RecruitmentRequests.BeginScope(); + // A fresh channel is essential after a lost unarchive/pin response. + var channel = await _client.Rest.GetChannelAsync(ulong.Parse(threadId), Options(ct)) as RestThreadChannel; + if (channel is null || channel.GuildId != _settings.GuildId || + !_settings.Recruitment.Forums.Mapping().Values.Contains(channel.ParentChannelId)) + throw new InvalidOperationException("Response pin thread unavailable."); + var guild = _client.GetGuild(_settings.GuildId); + var parent = guild?.GetChannel(channel.ParentChannelId); + bool? allowed = parent is null ? null : guild!.CurrentUser.GetPermissions(parent).PinMessages; + return await DiscordResponsePins.PinOwnedAsync(channel, BotUserId, ulong.Parse(messageId), allowed, restoreArchive, restoreLocked, beforeWrite, ct); + } + + public Task CreateReferenceAsync(string forumId, string closedTagId, string marker, CancellationToken ct) => + _referencePublisher.CreateReferenceAsync(forumId, closedTagId, marker, ct); + public Task SecureReferenceAsync(string forumId, string threadId, bool botOwned, string marker, CancellationToken ct) => + _referencePublisher.SecureReferenceAsync(forumId, threadId, botOwned, marker, ct); } diff --git a/DiscordBot/Services/Recruitment/Infrastructure/Discord/DiscordRecruitmentReferences.cs b/DiscordBot/Services/Recruitment/Infrastructure/Discord/DiscordReferencePublisher.cs similarity index 79% rename from DiscordBot/Services/Recruitment/Infrastructure/Discord/DiscordRecruitmentReferences.cs rename to DiscordBot/Services/Recruitment/Infrastructure/Discord/DiscordReferencePublisher.cs index 96679183..6fdba382 100644 --- a/DiscordBot/Services/Recruitment/Infrastructure/Discord/DiscordRecruitmentReferences.cs +++ b/DiscordBot/Services/Recruitment/Infrastructure/Discord/DiscordReferencePublisher.cs @@ -1,15 +1,26 @@ using Discord.Net; using Discord.Rest; +using Discord.WebSocket; +using DiscordBot.Settings; +using static DiscordBot.Services.Recruitment.RecruitmentRequests; -namespace DiscordBot.Services.Recruitment; +namespace DiscordBot.Services.Recruitment.Infrastructure.Discord; -public sealed partial class DiscordRecruitmentAdapter +internal sealed class DiscordReferencePublisher( + DiscordSocketClient client, + BotSettings settings, + Func> readForum, + Func botIdentity, + Func> isModerator, + Action invalidateFacts) { + private readonly DiscordSocketClient _client = client; + private readonly BotSettings _settings = settings; internal const string ReferenceTitle = "Read First · Recruitment Information"; public async Task CreateReferenceAsync(string forumId, string closedTagId, string marker, CancellationToken ct) { using var requestScope = RecruitmentRequests.BeginScope(); - var forum = _forums.TryGetValue(forumId, out var cached) ? cached.Channel : await ForumAsync(forumId, ct); + var forum = await readForum(forumId, ct); var tags = forum.Tags.Where(t => t.Id.ToString() == closedTagId).ToArray(); if (tags.Length != 1) throw new InvalidOperationException("Reference post needs a verified Closed tag."); var thread = await forum.CreatePostAsync(ReferenceTitle, ThreadArchiveDuration.OneWeek, @@ -36,11 +47,11 @@ public async Task SecureReferenceAsync(string forumId, string threadId, bo if (botOwned) { var starter = await thread.GetMessageAsync(thread.Id, Options(ct)); - var botUserId = BotUserId; + var botUserId = botIdentity(); if (((IThreadChannel)thread).OwnerId != botUserId || starter?.Author.Id != botUserId || !starter.Content.Contains(marker, StringComparison.Ordinal)) throw new InvalidOperationException("Reference bot ownership or marker does not match."); } - else if (!await IsModeratorAsync(_settings.GuildId.ToString(), ((IThreadChannel)thread).OwnerId.ToString(), ct)) + else if (!await isModerator(_settings.GuildId.ToString(), ((IThreadChannel)thread).OwnerId.ToString(), ct)) throw new InvalidOperationException("Existing reference is no longer owned by a moderator or administrator."); if (thread.IsLocked && !thread.IsArchived && thread.Flags.HasFlag(ChannelFlags.Pinned)) return true; // Forum threads inherit permissions. Locking is the supported member-posting restriction; @@ -50,7 +61,7 @@ public async Task SecureReferenceAsync(string forumId, string threadId, bo var verified = await _client.Rest.GetChannelAsync(thread.Id, Options(ct)) as RestThreadChannel; if (verified is null || verified.IsArchived || !verified.IsLocked || !verified.Flags.HasFlag(ChannelFlags.Pinned)) throw new InvalidOperationException("Reference pin/lock was not confirmed."); - InvalidateFacts(threadId); + invalidateFacts(threadId); return true; } } diff --git a/DiscordBot/Services/Recruitment/Infrastructure/Discord/DiscordRecruitmentPins.cs b/DiscordBot/Services/Recruitment/Infrastructure/Discord/DiscordResponsePins.cs similarity index 65% rename from DiscordBot/Services/Recruitment/Infrastructure/Discord/DiscordRecruitmentPins.cs rename to DiscordBot/Services/Recruitment/Infrastructure/Discord/DiscordResponsePins.cs index ee827638..55480d43 100644 --- a/DiscordBot/Services/Recruitment/Infrastructure/Discord/DiscordRecruitmentPins.cs +++ b/DiscordBot/Services/Recruitment/Infrastructure/Discord/DiscordResponsePins.cs @@ -1,25 +1,9 @@ -using DiscordBot.Services.Recruitment.Infrastructure.Discord; -using Discord.Rest; +using static DiscordBot.Services.Recruitment.RecruitmentRequests; -namespace DiscordBot.Services.Recruitment; +namespace DiscordBot.Services.Recruitment.Infrastructure.Discord; -public sealed partial class DiscordRecruitmentAdapter +internal static class DiscordResponsePins { - public async Task PinAdvisoryAsync(string threadId, string messageId, bool restoreArchive, bool restoreLocked, - Action beforeWrite, CancellationToken ct) - { - using var requestScope = RecruitmentRequests.BeginScope(); - // A fresh channel is essential after a lost unarchive/pin response. - var channel = await _client.Rest.GetChannelAsync(ulong.Parse(threadId), Options(ct)) as RestThreadChannel; - if (channel is null || channel.GuildId != _settings.GuildId || - !_settings.Recruitment.Forums.Mapping().Values.Contains(channel.ParentChannelId)) - throw new InvalidOperationException("Response pin thread unavailable."); - var guild = _client.GetGuild(_settings.GuildId); - var parent = guild?.GetChannel(channel.ParentChannelId); - bool? allowed = parent is null ? null : guild!.CurrentUser.GetPermissions(parent).PinMessages; - return await PinOwnedAsync(channel, BotUserId, ulong.Parse(messageId), allowed, restoreArchive, restoreLocked, beforeWrite, ct); - } - internal static async Task PinOwnedAsync(IThreadChannel channel, ulong botId, ulong messageId, bool? allowed, bool restoreArchive, bool restoreLocked, Action beforeWrite, CancellationToken ct) { diff --git a/DiscordBot/Services/Recruitment/Application/ListingEligibilityText.cs b/DiscordBot/Services/Recruitment/Presentation/ListingEligibilityText.cs similarity index 88% rename from DiscordBot/Services/Recruitment/Application/ListingEligibilityText.cs rename to DiscordBot/Services/Recruitment/Presentation/ListingEligibilityText.cs index 8867d716..1d5ba37c 100644 --- a/DiscordBot/Services/Recruitment/Application/ListingEligibilityText.cs +++ b/DiscordBot/Services/Recruitment/Presentation/ListingEligibilityText.cs @@ -5,9 +5,9 @@ using DiscordBot.Services.Recruitment.Domain.Policies; using DiscordBot.Services.Recruitment.Infrastructure.Discord; -namespace DiscordBot.Services.Recruitment.Application; +namespace DiscordBot.Services.Recruitment.Presentation; -internal sealed class ListingEligibilityText() +internal static class ListingEligibilityText { public static string EligibilityReason(Eligibility eligibility) diff --git a/DiscordBot/Services/Recruitment/Presentation/RecruitmentHealthFormatter.cs b/DiscordBot/Services/Recruitment/Presentation/RecruitmentHealthFormatter.cs new file mode 100644 index 00000000..70ed7a32 --- /dev/null +++ b/DiscordBot/Services/Recruitment/Presentation/RecruitmentHealthFormatter.cs @@ -0,0 +1,44 @@ +using DiscordBot.Utils; + +namespace DiscordBot.Services.Recruitment.Presentation; + +public static class RecruitmentHealthFormatter +{ + public static Embed Embed(this RecruitmentHealth health,bool includeFreshness = true) + { + var embed = new EmbedBuilder().WithTitle("Recruitment Status").WithColor(health.InventoryComplete && health.Kind == RecruitmentHealthKind.Operating ? Color.Green : Color.Orange) + .WithDescription(health.Text()); + if (includeFreshness) embed.WithFooter($"Snapshot captured {health.ObservedAt:u}"); + return embed.Build(); + } + + public static string Text(this RecruitmentHealth health) + { + var Status = health.Status; + var Open = health.Open; + var Pending = health.Pending; + var Held = health.Held; + var InventoryComplete = health.InventoryComplete; + var Work = health.Work; + var Attention = health.Attention; + var Metrics = health.Metrics; + var LastRecovery = health.LastRecovery; + var NextDue = health.NextDue; + var Forums = health.Forums; + var OverviewFault = health.OverviewFault; + var Job = health.Job; + var forums = string.Join("\n", Forums.Select(f => $"• <#{f.Id}> · {f.Purpose}: {f.Setup}" + + (f.Fault is null ? "" : $" — {DiscordDisplayText.Literal(f.Fault, 250)}" + (f.RetryAt.HasValue ? $"; retry {DiscordDisplayText.Timestamp(f.RetryAt.Value)}" : "; preview and repair required")) + + (f.ReferenceId is null ? " · reference pending" : $" · reference <#{f.ReferenceId}>") + + (f.ReferenceFault is null ? "" : " · reference held: " + DiscordDisplayText.Literal(f.ReferenceFault, 180)))); + return DiscordDisplayText.Literal(Status, 400) + $"\nOpen: {Open} · awaiting acknowledgement: {Pending} · held: {Held}\n" + + $"Inventory: {(InventoryComplete ? "complete" : "recovery needed")} · work: {Work}\n" + + (Attention is null ? "" : "**Needs review**\n" + Attention + "\n") + + (Metrics is null ? "" : Metrics + "\n") + + (LastRecovery.HasValue ? $"Last recovery: {DiscordDisplayText.Timestamp(LastRecovery.Value)}\n" : "No completed recovery yet.\n") + + (NextDue.HasValue ? $"Next scheduled work: {DiscordDisplayText.Timestamp(NextDue.Value)}\n" : "") + forums + + (OverviewFault is null ? "" : "\nOverview: " + OverviewFault) + + (Job is null ? "" : "\nJob: " + Job) + + "\nUse /recruitment status, lookup, preview, repair or reconcile. Repairs and changes require a reason."; + } +} diff --git a/DiscordBot/Services/Recruitment/RecruitmentService.cs b/DiscordBot/Services/Recruitment/RecruitmentService.cs index 7521db36..8abc2420 100644 --- a/DiscordBot/Services/Recruitment/RecruitmentService.cs +++ b/DiscordBot/Services/Recruitment/RecruitmentService.cs @@ -1,374 +1,63 @@ -using DiscordBot.Services.Recruitment.Application; -using DiscordBot.Utils; -using System.Threading.Channels; -using System.Collections.Concurrent; using Discord.WebSocket; using DiscordBot.Settings; +using DiscordBot.Services.Recruitment.Application; +using DiscordBot.Services.Recruitment.Runtime; namespace DiscordBot.Services.Recruitment; -/// Serializes durable work; gateway callbacks only capture facts and wake the next-due scheduler. +/// Stable service facade composing gateway translation and one serialized state worker. public sealed class RecruitmentService { private readonly DiscordSocketClient? _client; private readonly BotSettings _settings; - private readonly RecruitmentEngine _engine; - private readonly IRecruitmentDiscord _discord; - private bool _gatewayReady = true; - private readonly TimeProvider _time; - private readonly ConcurrentDictionary _pendingThreadWork = new(); - private readonly Dictionary> _coalesced = new(); - private readonly object _enqueueGate = new(); - private IReadOnlySet _knownThreads = new HashSet(); - private IReadOnlyDictionary _listingOwners = new Dictionary(); - private IReadOnlyDictionary _messageOwners = new Dictionary(); - private string? _overviewMessage; - private readonly Channel _queue = Channel.CreateBounded(new BoundedChannelOptions(256) - { SingleReader = true, FullMode = BoundedChannelFullMode.Wait }); - private readonly CancellationTokenSource _stop = new(); - private Task? _worker; - private ITimer? _timer; - private int _gap; - private long _evidenceGeneration; - private string _work = "Starting"; - private string? _job; - private RecruitmentHealth _health; - private enum WorkKind { Event, Deadline, Command, Recovery } - private sealed record Work(WorkKind Kind, Func Run, Action Cancel); - public RecruitmentHealth Health => Volatile.Read(ref _health); + private readonly RecruitmentWorker _worker; + private readonly RecruitmentGateway? _gateway; + private bool _started; + public RecruitmentHealth Health => _worker.Health; public RecruitmentService(DiscordSocketClient client, BotSettings settings) - : this(settings, new DiscordRecruitmentAdapter(client, settings), TimeProvider.System) => _client = client; - - internal RecruitmentService(BotSettings settings, IRecruitmentDiscord discord, TimeProvider time) - { - _settings = settings; _time = time; _discord = discord; - _health = RecruitmentHealth.Starting(time.GetUtcNow()); - _engine = new RecruitmentEngine(settings, discord, time, - id => _pendingThreadWork.GetValueOrDefault(id) > 0, () => Interlocked.Read(ref _evidenceGeneration), () => Volatile.Read(ref _gatewayReady), - () => _pendingThreadWork.Keys.Any(id => !Volatile.Read(ref _knownThreads).Contains(id))); - _engine.Changed += PublishHealth; - } - - public async Task StartAsync() - { - if (_worker is not null) return; - _gatewayReady = _client is null || _client.GetGuild(_settings.GuildId)?.IsConnected == true; - Subscribe(true); - _timer = _time.CreateTimer(_ => Enqueue("due", ct => _engine.RunDueAsync(ct), WorkKind.Deadline), null, Timeout.InfiniteTimeSpan, Timeout.InfiniteTimeSpan); - var started = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - _worker = Task.Run(async () => - { - try - { - await _engine.StartAsync(_stop.Token); - _work = "Idle"; - await _engine.RefreshOverviewAsync(_stop.Token); - PublishHealth(); ArmTimer(); started.TrySetResult(); - await foreach (var work in _queue.Reader.ReadAllAsync(_stop.Token)) - { - _work = work.Kind.ToString(); PublishHealth(); - try - { - if (Interlocked.Exchange(ref _gap, 0) != 0) { _engine.MarkEvidenceGap(); _engine.RequestRecovery(); } - await work.Run(_stop.Token); - await _engine.RefreshOverviewAsync(_stop.Token); - } - catch (OperationCanceledException) when (_stop.IsCancellationRequested) { work.Cancel(); break; } - catch (Exception ex) - { - work.Cancel(); EvidenceGap(); - LoggingService.LogToConsole($"Recruitment {work.Kind} held ({ex.GetType().Name}); inspect /recruitment status.", ExtendedLogSeverity.Warning); - } - finally { _work = "Idle"; PublishHealth(); ArmTimer(); } - } - } - catch (OperationCanceledException) when (_stop.IsCancellationRequested) { started.TrySetCanceled(); } - catch (Exception ex) { started.TrySetException(ex); } - finally - { - _stop.Cancel(); _queue.Writer.TryComplete(); _timer?.Dispose(); Subscribe(false); - while (_queue.Reader.TryRead(out var work)) work.Cancel(); - _engine.Dispose(); - Volatile.Write(ref _health, Health with { Status = "Stopped", Work = "Stopped", ObservedAt = _time.GetUtcNow() }); - } - }); - await started.Task; - } - - private void PublishHealth() - { - var next = _engine.Health(_work) with { Job = _job }; - var before = Health; - Volatile.Write(ref _health, next); - Volatile.Write(ref _knownThreads, _engine.State is { } state ? state.Listings.Keys.Concat(state.Forums.Values.Select(f => f.Reference.ThreadId).OfType()).ToHashSet() : new HashSet()); - Volatile.Write(ref _listingOwners, _engine.State?.Listings.Values.Where(p => p.Status == ListingStatus.Open) - .GroupBy(p => p.OwnerId).ToDictionary(g => g.Key, g => g.Select(p => p.Id).ToArray()) ?? new Dictionary()); - var owners = new Dictionary(); - foreach (var post in _engine.State?.Listings.Values ?? Enumerable.Empty()) - { - if (post.PublicMessageId is not null) owners[post.PublicMessageId] = post.Id; - if (post.StaffMessageId is not null) owners[post.StaffMessageId] = post.Id; - } - Volatile.Write(ref _messageOwners, owners); - Volatile.Write(ref _overviewMessage, _engine.State?.Overview.MessageId); - foreach (var forum in next.Forums) - { - var previous = before.Forums.FirstOrDefault(f => f.Id == forum.Id); - if (previous?.Fault == forum.Fault) continue; - LoggingService.LogToConsole($"Recruitment setup [{forum.Purpose}/{forum.Id}]: " + - (forum.Fault is null ? "recovered" : forum.Fault + (forum.RetryAt.HasValue ? $"; retry {forum.RetryAt:u}" : "; preview and repair required")), - forum.Fault is null ? ExtendedLogSeverity.Info : ExtendedLogSeverity.Warning); - } - if (before.Status != next.Status) - { - if (next.RoutinePendingIdentity || before.RoutinePendingIdentity && next.Status == "Operating") - LoggingService.DebugLog("Recruitment: " + next.Status, ExtendedLogSeverity.Debug); - else LoggingService.LogToConsole("Recruitment: " + next.Status, - next.InventoryComplete ? ExtendedLogSeverity.Info : ExtendedLogSeverity.Warning); - } - } - - private void ArmTimer() - { - if (!Volatile.Read(ref _gatewayReady)) { SetTimer(Timeout.InfiniteTimeSpan); return; } - var due = _engine.NextDueAt; - var delay = due.HasValue ? due.Value - _time.GetUtcNow() : Timeout.InfiniteTimeSpan; - // A bounded continuation yields to facts/commands already queued, including a timely acknowledgement. - if (due.HasValue && delay < TimeSpan.FromSeconds(1)) delay = TimeSpan.FromSeconds(1); - SetTimer(delay); - } - - private void SetTimer(TimeSpan delay) - { - if (_stop.IsCancellationRequested) return; - try { _timer?.Change(delay, Timeout.InfiniteTimeSpan); } - catch (ObjectDisposedException) when (_stop.IsCancellationRequested) { } - } - - internal void EvidenceGap() - { - Interlocked.Increment(ref _evidenceGeneration); // Immediately fences an action already awaiting its final read. - Interlocked.Exchange(ref _gap, 1); - Volatile.Write(ref _health, Health with { InventoryComplete = false, Status = "Gateway evidence incomplete; recovery needed" }); - } - - private Action TrackThread(string? thread) - { - if (thread is null) return () => { }; - _pendingThreadWork.AddOrUpdate(thread, 1, (_, count) => count + 1); - var finished = 0; - return () => - { - if (Interlocked.Exchange(ref finished, 1) != 0) return; - var remaining = _pendingThreadWork.AddOrUpdate(thread, 0, (_, count) => Math.Max(0, count - 1)); - if (remaining == 0) ((ICollection>)_pendingThreadWork).Remove(new(thread, 0)); - }; - } - - private bool Enqueue(string key, Func run, WorkKind kind = WorkKind.Event, string? thread = null) - { - lock (_enqueueGate) - { - if (_stop.IsCancellationRequested) return false; - if (_coalesced.ContainsKey(key)) { _coalesced[key] = run; return true; } - _coalesced[key] = run; - var finished = TrackThread(thread); - void Cancel() { lock (_enqueueGate) _coalesced.Remove(key); finished(); } - if (_queue.Writer.TryWrite(new Work(kind, async ct => - { - Func? latest; - lock (_enqueueGate) _coalesced.Remove(key, out latest); - // Only work still waiting in the queue fences automatic actions. This event must not fence itself. - finished(); - if (latest is not null) await latest(ct); - }, Cancel))) return true; - Cancel(); EvidenceGap(); - SetTimer(TimeSpan.FromSeconds(1)); - return false; - } - } - - internal void NotifyThread(string id) => Enqueue("thread:" + id, ct => _engine.ThreadChangedAsync(id, ct), thread: id); - internal void NotifyCreation(ThreadCreation thread) => Enqueue("created:" + thread.Id, ct => _engine.ThreadCreatedAsync(thread, ct), thread: thread.Id); - internal void NotifyFactsChanged(string id) => Enqueue("facts:" + id, ct => _engine.FactsChangedAsync(id, ct), thread: id); - - private Task MessageUpdated(Cacheable before, SocketMessage after, ISocketMessageChannel channel) + : this(settings, new DiscordRecruitmentAdapter(client, settings), TimeProvider.System) { - if (channel is SocketThreadChannel thread && IsRecruitment(thread) && after.Id == thread.Id && - (!before.HasValue || before.Value.Content != after.Content || - !before.Value.Attachments.Select(a => a.Id).SequenceEqual(after.Attachments.Select(a => a.Id)))) NotifyFactsChanged(thread.Id.ToString()); - return Task.CompletedTask; + _client = client; + _gateway = new RecruitmentGateway(client, settings, _discord, _worker); + _worker.Stopped += () => _gateway.Subscribe(false); } - private Task ThreadCreated(SocketThreadChannel thread) - { - if (IsRecruitment(thread)) NotifyCreation(new ThreadCreation(thread.Id.ToString(), thread.ParentChannel.Id.ToString(), - ((IThreadChannel)thread).OwnerId.ToString(), thread.Name, thread.CreatedAt)); - return Task.CompletedTask; - } - private Task ThreadUpdated(Cacheable before, SocketThreadChannel after) - { - if (IsRecruitment(after) && (!before.HasValue || before.Value.Flags != after.Flags || before.Value.IsArchived != after.IsArchived || - before.Value.IsLocked != after.IsLocked || !before.Value.AppliedTags.SequenceEqual(after.AppliedTags))) NotifyThread(after.Id.ToString()); - return Task.CompletedTask; - } - private Task ThreadDeleted(Cacheable thread) - { - NotifyThreadDeletion(thread.Id.ToString()); - return Task.CompletedTask; - } - internal void NotifyThreadDeletion(string id) - { - // Discord's delete event proves absence even when setup or a listing write is held. - if (Volatile.Read(ref _knownThreads).Contains(id)) - Enqueue("deleted:" + id, ct => _engine.ThreadDeletedAsync(id, ct), thread: id); - } - private bool IsRecruitment(SocketThreadChannel thread) => thread.Guild.Id == _settings.GuildId && _settings.Recruitment.Forums.Contains(thread.ParentChannel.Id); - private Task MessageDeleted(Cacheable message, Cacheable channel) => - MessagesDeleted(new[] { message }, channel); - private Task MessagesDeleted(IReadOnlyCollection> messages, Cacheable channel) - { - NotifyMessageDeletion(channel.Id.ToString(), messages.Select(m => m.Id.ToString()).ToHashSet()); - return Task.CompletedTask; - } - internal void NotifyMessageDeletion(string channel, IReadOnlySet ids) + private readonly IRecruitmentDiscord _discord; + internal RecruitmentService(BotSettings settings, IRecruitmentDiscord discord, TimeProvider time) { - var owners = Volatile.Read(ref _messageOwners); - foreach (var group in ids.Where(owners.ContainsKey).GroupBy(id => owners[id])) - { - var affected = group.ToHashSet(); - Enqueue($"messages:{channel}:{group.Key}", ct => _engine.MessageDeletedAsync(channel, affected, ct), thread: group.Key); - } - if (Volatile.Read(ref _overviewMessage) is { } overview && ids.Contains(overview)) - Enqueue("overview-deleted", ct => _engine.MessageDeletedAsync(channel, new HashSet { overview }, ct)); + _settings = settings; + _discord = discord; + _worker = new RecruitmentWorker(settings, discord, time); } - private Task Disconnected(Exception _) { Volatile.Write(ref _gatewayReady, false); EvidenceGap(); return Task.CompletedTask; } - private Task Ready() - { - if (_client?.GetGuild(_settings.GuildId)?.IsConnected == true) { Volatile.Write(ref _gatewayReady, true); ScheduleRecovery(); } - return Task.CompletedTask; - } - private Task GuildAvailable(SocketGuild guild) { if (guild.Id == _settings.GuildId) { Volatile.Write(ref _gatewayReady, true); ScheduleRecovery(); } return Task.CompletedTask; } - private Task GuildUnavailable(SocketGuild guild) { if (guild.Id == _settings.GuildId) { Volatile.Write(ref _gatewayReady, false); EvidenceGap(); } return Task.CompletedTask; } - private void ScheduleRecovery() - { - EvidenceGap(); - Enqueue("recovery", async ct => { _engine.InvalidateAccess(); await _engine.SweepAsync(ct, retryHeld: false); }, WorkKind.Recovery); - } - private Task ChannelUpdated(SocketChannel before, SocketChannel after) - { - if (after is not SocketGuildChannel channel || channel.Guild.Id != _settings.GuildId) return Task.CompletedTask; - var permissionsChanged = before is not SocketGuildChannel prior || !prior.PermissionOverwrites.SequenceEqual(channel.PermissionOverwrites) || - (before as INestedChannel)?.CategoryId != (after as INestedChannel)?.CategoryId; - if (after is SocketForumChannel forum && _settings.Recruitment.Forums.Contains(forum.Id)) - { - var snapshot = new ForumSnapshot(forum.Id.ToString(), forum.Topic ?? "", (forum.Flags & ChannelFlags.RequireTag) != 0, - forum.Tags.Select(t => new LiveTag(t.Id.ToString(), t.Name, t.IsModerated, t.Emoji)).ToArray(), forum.Flags); - if (!permissionsChanged && _discord.IsExpectedForumUpdate(snapshot)) return Task.CompletedTask; - } - else if (!permissionsChanged || after.Id != _settings.Recruitment.FeedChannelId && after is not SocketCategoryChannel) return Task.CompletedTask; - ScheduleRecovery(); return Task.CompletedTask; - } - private Task ChannelDestroyed(SocketChannel channel) + public Task StartAsync() { - if (channel is SocketGuildChannel guild && guild.Guild.Id == _settings.GuildId && - (channel.Id == _settings.Recruitment.FeedChannelId || _settings.Recruitment.Forums.Contains(channel.Id) || channel is SocketCategoryChannel)) ScheduleRecovery(); - return Task.CompletedTask; - } - private Task RoleUpdated(SocketRole before, SocketRole after) - { if (after.Guild.Id == _settings.GuildId && !before.Permissions.Equals(after.Permissions)) ScheduleRecovery(); return Task.CompletedTask; } - private Task RoleDeleted(SocketRole role) { if (role.Guild.Id == _settings.GuildId) ScheduleRecovery(); return Task.CompletedTask; } - private Task UserUpdated(SocketUser before, SocketUser after) - { - if ((before.AvatarId != after.AvatarId || before.Username != after.Username || before.GlobalName != after.GlobalName) && Volatile.Read(ref _listingOwners).TryGetValue(after.Id.ToString(), out var threads)) - foreach (var id in threads) NotifyFactsChanged(id); - return Task.CompletedTask; + if (_started) return Task.CompletedTask; + _started = true; + _worker.InitializeGateway(_client is null || _client.GetGuild(_settings.GuildId)?.IsConnected == true); + _gateway?.Subscribe(true); + return _worker.StartAsync(); } - private Task MemberUpdated(Cacheable before, SocketGuildUser after) + public async Task StopAsync(CancellationToken ct) { - if (after.Guild.Id != _settings.GuildId || before.HasValue && before.Value.Roles.Select(r => r.Id).SequenceEqual(after.Roles.Select(r => r.Id)) && before.Value.DisplayAvatarId == after.DisplayAvatarId && before.Value.Nickname == after.Nickname && before.Value.GlobalName == after.GlobalName && before.Value.Username == after.Username) return Task.CompletedTask; - if (after.Id == _client?.CurrentUser.Id) ScheduleRecovery(); - else if (Volatile.Read(ref _listingOwners).TryGetValue(after.Id.ToString(), out var threads)) - foreach (var id in threads) NotifyFactsChanged(id); - return Task.CompletedTask; + _gateway?.Subscribe(false); + await _worker.StopAsync(ct); } - // Use the immutable ownership snapshot so opening a modal does not wait behind Discord work. - // Submission still authorizes against the persisted listing on the serialized worker. - public bool CanOpenAcknowledgement(string guild, string thread, string actor) => - _settings.Recruitment.Enabled && !_stop.IsCancellationRequested && guild == _settings.GuildId.ToString() && - Volatile.Read(ref _listingOwners).TryGetValue(actor, out var threads) && threads.Contains(thread); - public bool CanReadStatus(string guild, string actor) => guild == _settings.GuildId.ToString() && ulong.TryParse(actor, out var id) && _client?.GetGuild(_settings.GuildId)?.GetUser(id) is { } user && (user.GuildPermissions.Administrator || user.Roles.Any(r => r.Id == _settings.Roles.Moderator)); - public string QueueRecovery(string guild, string actor, StaffCommand operation, string target, string reason) - { - if (!CanReadStatus(guild, actor)) return "Moderator access is required in the configured server."; - if (string.IsNullOrWhiteSpace(reason)) return "A reason is required for this change."; - var id = Guid.NewGuid().ToString("N")[..8]; - var queued = Enqueue("job:" + id, async ct => - { - _job = id + " running"; PublishHealth(); - try - { - var result = await _engine.StaffAsync(guild, actor, operation, target, reason, ct); - _job = id + ": " + DiscordDisplayText.Literal(result, 300); - } - catch (Exception ex) - { - _job = id + ": held — " + DiscordDisplayText.Literal(ex.Message, 250); - throw; - } - }, WorkKind.Recovery); - if (!queued) return "Recruitment is busy or stopped; the job was not queued."; - return $"Queued recruitment job `{id}`. Use /recruitment status to check progress."; - } - - public async Task RunAsync(Func> command, string? ownerThread = null) - { - if (_worker is null || _stop.IsCancellationRequested) throw new InvalidOperationException("Recruitment is starting or stopped."); - var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - var finished = TrackThread(ownerThread); - var work = new Work(WorkKind.Command, async ct => - { - finished(); - try { completion.TrySetResult(await command(_engine, ct)); } - catch (Exception ex) { completion.TrySetException(ex); } - }, () => { finished(); completion.TrySetCanceled(); }); - if (!_queue.Writer.TryWrite(work)) { finished(); throw new InvalidOperationException("Recruitment is busy. Please try again shortly."); } - return await completion.Task; - } - - private void Subscribe(bool subscribe) - { - if (_client is null || !_settings.Recruitment.Enabled) return; - if (subscribe) - { - _client.ThreadCreated += ThreadCreated; _client.ThreadUpdated += ThreadUpdated; _client.ThreadDeleted += ThreadDeleted; - _client.MessageDeleted += MessageDeleted; _client.MessagesBulkDeleted += MessagesDeleted; - _client.MessageUpdated += MessageUpdated; - _client.Disconnected += Disconnected; _client.Ready += Ready; _client.GuildAvailable += GuildAvailable; _client.GuildUnavailable += GuildUnavailable; - _client.ChannelUpdated += ChannelUpdated; _client.ChannelDestroyed += ChannelDestroyed; - _client.RoleUpdated += RoleUpdated; _client.RoleDeleted += RoleDeleted; _client.GuildMemberUpdated += MemberUpdated; _client.UserUpdated += UserUpdated; - } - else - { - _client.ThreadCreated -= ThreadCreated; _client.ThreadUpdated -= ThreadUpdated; _client.ThreadDeleted -= ThreadDeleted; - _client.MessageDeleted -= MessageDeleted; _client.MessagesBulkDeleted -= MessagesDeleted; - _client.MessageUpdated -= MessageUpdated; - _client.Disconnected -= Disconnected; _client.Ready -= Ready; _client.GuildAvailable -= GuildAvailable; _client.GuildUnavailable -= GuildUnavailable; - _client.ChannelUpdated -= ChannelUpdated; _client.ChannelDestroyed -= ChannelDestroyed; - _client.RoleUpdated -= RoleUpdated; _client.RoleDeleted -= RoleDeleted; _client.GuildMemberUpdated -= MemberUpdated; _client.UserUpdated -= UserUpdated; - } - } - public async Task StopAsync(CancellationToken ct) - { - Subscribe(false); _stop.Cancel(); _timer?.Dispose(); _queue.Writer.TryComplete(); - if (_worker is not null) await _worker.WaitAsync(ct); - } + public bool CanOpenAcknowledgement(string guild, string thread, string actor) => _worker.CanOpenAcknowledgement(guild, thread, actor); + public string QueueRecovery(string guild, string actor, StaffCommand command, string target, string reason) => + CanReadStatus(guild, actor) ? _worker.QueueRecovery(guild, actor, command, target, reason) : "Moderator access is required in the configured server."; + public Task RunAsync(Func> command, string? ownerThread = null) => _worker.RunAsync(command, ownerThread); + internal void EvidenceGap() => _worker.EvidenceGap(); + internal void NotifyThread(string id) => _worker.NotifyThread(id); + internal void NotifyCreation(ThreadCreation creation) => _worker.NotifyCreation(creation); + internal void NotifyFactsChanged(string id) => _worker.NotifyFactsChanged(id); + internal void NotifyThreadDeletion(string id) => _worker.NotifyThreadDeletion(id); + internal void NotifyMessageDeletion(string channel, IReadOnlySet ids) => _worker.NotifyMessageDeletion(channel, ids); } diff --git a/DiscordBot/Services/Recruitment/Runtime/RecruitmentGateway.cs b/DiscordBot/Services/Recruitment/Runtime/RecruitmentGateway.cs new file mode 100644 index 00000000..67797480 --- /dev/null +++ b/DiscordBot/Services/Recruitment/Runtime/RecruitmentGateway.cs @@ -0,0 +1,146 @@ +using Discord.WebSocket; +using DiscordBot.Settings; + +namespace DiscordBot.Services.Recruitment.Runtime; + +internal interface IRecruitmentGatewaySink +{ + void NotifyThread(string id); + void NotifyCreation(ThreadCreation creation); + void NotifyFactsChanged(string id); + void NotifyThreadDeletion(string id); + void NotifyMessageDeletion(string channel, IReadOnlySet ids); + void SetGatewayReady(bool ready); + void ScheduleRecovery(); + bool TryGetOwnerThreads(string owner, out string[] threads); +} + +/// Translates gateway events into queued work without reading mutable listing state. +internal sealed class RecruitmentGateway( + DiscordSocketClient client, + BotSettings settings, + IRecruitmentDiscord discord, + IRecruitmentGatewaySink sink) +{ + private readonly DiscordSocketClient _client = client; + private readonly BotSettings _settings = settings; + private readonly IRecruitmentDiscord _discord = discord; + private readonly IRecruitmentGatewaySink _sink = sink; + private Task MessageUpdated(Cacheable before, SocketMessage after, ISocketMessageChannel channel) + { + if (channel is SocketThreadChannel thread && IsRecruitment(thread) && after.Id == thread.Id && + (!before.HasValue || before.Value.Content != after.Content || + !before.Value.Attachments.Select(a => a.Id).SequenceEqual(after.Attachments.Select(a => a.Id)))) _sink.NotifyFactsChanged(thread.Id.ToString()); + return Task.CompletedTask; + } + + private Task ThreadCreated(SocketThreadChannel thread) + { + if (IsRecruitment(thread)) _sink.NotifyCreation(new ThreadCreation(thread.Id.ToString(), thread.ParentChannel.Id.ToString(), + ((IThreadChannel)thread).OwnerId.ToString(), thread.Name, thread.CreatedAt)); + return Task.CompletedTask; + } + + private Task ThreadUpdated(Cacheable before, SocketThreadChannel after) + { + if (IsRecruitment(after) && (!before.HasValue || before.Value.Flags != after.Flags || before.Value.IsArchived != after.IsArchived || + before.Value.IsLocked != after.IsLocked || !before.Value.AppliedTags.SequenceEqual(after.AppliedTags))) _sink.NotifyThread(after.Id.ToString()); + return Task.CompletedTask; + } + + private Task ThreadDeleted(Cacheable thread) + { + _sink.NotifyThreadDeletion(thread.Id.ToString()); + return Task.CompletedTask; + } + + private bool IsRecruitment(SocketThreadChannel thread) => thread.Guild.Id == _settings.GuildId && _settings.Recruitment.Forums.Contains(thread.ParentChannel.Id); + + private Task MessageDeleted(Cacheable message, Cacheable channel) => + MessagesDeleted(new[] { message }, channel); + + private Task MessagesDeleted(IReadOnlyCollection> messages, Cacheable channel) + { + _sink.NotifyMessageDeletion(channel.Id.ToString(), messages.Select(m => m.Id.ToString()).ToHashSet()); + return Task.CompletedTask; + } + + private Task Disconnected(Exception _) { _sink.SetGatewayReady(false); return Task.CompletedTask; } + + private Task Ready() + { + if (_client?.GetGuild(_settings.GuildId)?.IsConnected == true) { _sink.SetGatewayReady(true); _sink.ScheduleRecovery(); } + return Task.CompletedTask; + } + + private Task GuildAvailable(SocketGuild guild) { if (guild.Id == _settings.GuildId) { _sink.SetGatewayReady(true); _sink.ScheduleRecovery(); } return Task.CompletedTask; } + + private Task GuildUnavailable(SocketGuild guild) { if (guild.Id == _settings.GuildId) { _sink.SetGatewayReady(false); } return Task.CompletedTask; } + + private Task ChannelUpdated(SocketChannel before, SocketChannel after) + { + if (after is not SocketGuildChannel channel || channel.Guild.Id != _settings.GuildId) return Task.CompletedTask; + var permissionsChanged = before is not SocketGuildChannel prior || !prior.PermissionOverwrites.SequenceEqual(channel.PermissionOverwrites) || + (before as INestedChannel)?.CategoryId != (after as INestedChannel)?.CategoryId; + if (after is SocketForumChannel forum && _settings.Recruitment.Forums.Contains(forum.Id)) + { + var snapshot = new ForumSnapshot(forum.Id.ToString(), forum.Topic ?? "", (forum.Flags & ChannelFlags.RequireTag) != 0, + forum.Tags.Select(t => new LiveTag(t.Id.ToString(), t.Name, t.IsModerated, t.Emoji)).ToArray(), forum.Flags); + if (!permissionsChanged && _discord.IsExpectedForumUpdate(snapshot)) return Task.CompletedTask; + } + else if (!permissionsChanged || after.Id != _settings.Recruitment.FeedChannelId && after is not SocketCategoryChannel) return Task.CompletedTask; + _sink.ScheduleRecovery(); return Task.CompletedTask; + } + + private Task ChannelDestroyed(SocketChannel channel) + { + if (channel is SocketGuildChannel guild && guild.Guild.Id == _settings.GuildId && + (channel.Id == _settings.Recruitment.FeedChannelId || _settings.Recruitment.Forums.Contains(channel.Id) || channel is SocketCategoryChannel)) _sink.ScheduleRecovery(); + return Task.CompletedTask; + } + + private Task RoleUpdated(SocketRole before, SocketRole after) + { if (after.Guild.Id == _settings.GuildId && !before.Permissions.Equals(after.Permissions)) _sink.ScheduleRecovery(); return Task.CompletedTask; } + + private Task RoleDeleted(SocketRole role) { if (role.Guild.Id == _settings.GuildId) _sink.ScheduleRecovery(); return Task.CompletedTask; } + + private Task UserUpdated(SocketUser before, SocketUser after) + { + if ((before.AvatarId != after.AvatarId || before.Username != after.Username || before.GlobalName != after.GlobalName) && _sink.TryGetOwnerThreads(after.Id.ToString(), out var threads)) + foreach (var id in threads) _sink.NotifyFactsChanged(id); + return Task.CompletedTask; + } + + private Task MemberUpdated(Cacheable before, SocketGuildUser after) + { + if (after.Guild.Id != _settings.GuildId || before.HasValue && before.Value.Roles.Select(r => r.Id).SequenceEqual(after.Roles.Select(r => r.Id)) && before.Value.DisplayAvatarId == after.DisplayAvatarId && before.Value.Nickname == after.Nickname && before.Value.GlobalName == after.GlobalName && before.Value.Username == after.Username) return Task.CompletedTask; + if (after.Id == _client?.CurrentUser.Id) _sink.ScheduleRecovery(); + else if (_sink.TryGetOwnerThreads(after.Id.ToString(), out var threads)) + foreach (var id in threads) _sink.NotifyFactsChanged(id); + return Task.CompletedTask; + } + + public void Subscribe(bool subscribe) + { + if (_client is null || !_settings.Recruitment.Enabled) return; + if (subscribe) + { + _client.ThreadCreated += ThreadCreated; _client.ThreadUpdated += ThreadUpdated; _client.ThreadDeleted += ThreadDeleted; + _client.MessageDeleted += MessageDeleted; _client.MessagesBulkDeleted += MessagesDeleted; + _client.MessageUpdated += MessageUpdated; + _client.Disconnected += Disconnected; _client.Ready += Ready; _client.GuildAvailable += GuildAvailable; _client.GuildUnavailable += GuildUnavailable; + _client.ChannelUpdated += ChannelUpdated; _client.ChannelDestroyed += ChannelDestroyed; + _client.RoleUpdated += RoleUpdated; _client.RoleDeleted += RoleDeleted; _client.GuildMemberUpdated += MemberUpdated; _client.UserUpdated += UserUpdated; + } + else + { + _client.ThreadCreated -= ThreadCreated; _client.ThreadUpdated -= ThreadUpdated; _client.ThreadDeleted -= ThreadDeleted; + _client.MessageDeleted -= MessageDeleted; _client.MessagesBulkDeleted -= MessagesDeleted; + _client.MessageUpdated -= MessageUpdated; + _client.Disconnected -= Disconnected; _client.Ready -= Ready; _client.GuildAvailable -= GuildAvailable; _client.GuildUnavailable -= GuildUnavailable; + _client.ChannelUpdated -= ChannelUpdated; _client.ChannelDestroyed -= ChannelDestroyed; + _client.RoleUpdated -= RoleUpdated; _client.RoleDeleted -= RoleDeleted; _client.GuildMemberUpdated -= MemberUpdated; _client.UserUpdated -= UserUpdated; + } + } + +} diff --git a/DiscordBot/Services/Recruitment/Runtime/RecruitmentHealth.cs b/DiscordBot/Services/Recruitment/Runtime/RecruitmentHealth.cs index 764265ff..840c6985 100644 --- a/DiscordBot/Services/Recruitment/Runtime/RecruitmentHealth.cs +++ b/DiscordBot/Services/Recruitment/Runtime/RecruitmentHealth.cs @@ -1,38 +1,16 @@ using DiscordBot.Utils; namespace DiscordBot.Services.Recruitment; +public enum RecruitmentHealthKind { Unavailable, Operating, PendingIdentities, RecoveryNeeded, Stopped } + public sealed record ForumHealth(string Purpose, string Id, string Setup, string? Fault, DateTimeOffset? RetryAt, string? ReferenceId = null, string? ReferenceFault = null); /// Detached from mutable state so moderators can inspect progress while the worker awaits Discord. public sealed record RecruitmentHealth(string Status, string Work, DateTimeOffset ObservedAt, int Open, int Pending, int Held, bool InventoryComplete, DateTimeOffset? LastRecovery, - DateTimeOffset? NextDue, IReadOnlyList Forums, string? OverviewFault = null, string? Job = null, bool RoutinePendingIdentity = false, string? Attention = null, string? Metrics = null) + DateTimeOffset? NextDue, IReadOnlyList Forums, string? OverviewFault = null, string? Job = null, bool RoutinePendingIdentity = false, string? Attention = null, string? Metrics = null, RecruitmentHealthKind Kind = RecruitmentHealthKind.Unavailable) { public static RecruitmentHealth Starting(DateTimeOffset now) => new("Starting", "Opening recruitment state", now, 0, 0, 0, false, null, null, Array.Empty()); - public Embed Embed(bool includeFreshness = true) - { - var embed = new EmbedBuilder().WithTitle("Recruitment Status").WithColor(InventoryComplete && Status == "Operating" ? Color.Green : Color.Orange) - .WithDescription(Text()); - if (includeFreshness) embed.WithFooter($"Snapshot captured {ObservedAt:u}"); - return embed.Build(); - } - - public string Text() - { - var forums = string.Join("\n", Forums.Select(f => $"• <#{f.Id}> · {f.Purpose}: {f.Setup}" + - (f.Fault is null ? "" : $" — {DiscordDisplayText.Literal(f.Fault, 250)}" + (f.RetryAt.HasValue ? $"; retry {DiscordDisplayText.Timestamp(f.RetryAt.Value)}" : "; preview and repair required")) + - (f.ReferenceId is null ? " · reference pending" : $" · reference <#{f.ReferenceId}>") + - (f.ReferenceFault is null ? "" : " · reference held: " + DiscordDisplayText.Literal(f.ReferenceFault, 180)))); - return DiscordDisplayText.Literal(Status, 400) + $"\nOpen: {Open} · awaiting acknowledgement: {Pending} · held: {Held}\n" + - $"Inventory: {(InventoryComplete ? "complete" : "recovery needed")} · work: {Work}\n" + - (Attention is null ? "" : "**Needs review**\n" + Attention + "\n") + - (Metrics is null ? "" : Metrics + "\n") + - (LastRecovery.HasValue ? $"Last recovery: {DiscordDisplayText.Timestamp(LastRecovery.Value)}\n" : "No completed recovery yet.\n") + - (NextDue.HasValue ? $"Next scheduled work: {DiscordDisplayText.Timestamp(NextDue.Value)}\n" : "") + forums + - (OverviewFault is null ? "" : "\nOverview: " + OverviewFault) + - (Job is null ? "" : "\nJob: " + Job) + - "\nUse /recruitment status, lookup, preview, repair or reconcile. Repairs and changes require a reason."; - } } diff --git a/DiscordBot/Services/Recruitment/Runtime/RecruitmentHealthQuery.cs b/DiscordBot/Services/Recruitment/Runtime/RecruitmentHealthQuery.cs new file mode 100644 index 00000000..ef140d83 --- /dev/null +++ b/DiscordBot/Services/Recruitment/Runtime/RecruitmentHealthQuery.cs @@ -0,0 +1,64 @@ +using DiscordBot.Utils; +using DiscordBot.Services.Recruitment.Domain.Policies; + +namespace DiscordBot.Services.Recruitment.Runtime; + +internal static class RecruitmentHealthQuery +{ + public static RecruitmentHealth Build(RecruitmentState? state, bool ready, bool evidenceCurrent, bool pendingIdentity, + string status, string work, DateTimeOffset now, DateTimeOffset? nextWork) + { + var kind = RecruitmentHealthKind.Unavailable; + if (ready) + { + if (evidenceCurrent) { kind = RecruitmentHealthKind.Operating; status = "Operating"; } + else if (pendingIdentity) { kind = RecruitmentHealthKind.PendingIdentities; status = "Processing new listing identities"; } + else { kind = RecruitmentHealthKind.RecoveryNeeded; status = "Inventory incomplete; acceptance and automatic actions held"; } + } + return new(status, work, now, + state?.Listings.Values.Count(p => p.Status == ListingStatus.Open) ?? 0, + state?.Listings.Values.Count(p => p.Status == ListingStatus.Open && !p.AcknowledgedAt.HasValue) ?? 0, + state?.Listings.Values.Count(p => p.Hold is not null) ?? 0, evidenceCurrent, + state?.LastSweepAt, nextWork, + state?.Forums.Select(f => new ForumHealth(f.Key, f.Value.ForumId, ForumStatus(f.Value), + f.Value.Fault?.Message, f.Value.Fault?.RetryAt, f.Value.Reference.ThreadId, f.Value.Reference.Fault?.Message)).ToArray() ?? Array.Empty(), + state?.Overview.Fault?.Message, RoutinePendingIdentity: pendingIdentity, Attention: AttentionSummary(state, now), Metrics: ActivitySummary(state, now), Kind: kind); + } + + private static string ForumStatus(ForumReceipt forum) + { + if (forum.Fault is not null) return "held"; + if (forum.PendingTopic is not null) return "publication pending"; + return forum.ConfirmedTopic.Length == 0 ? "not verified" : "Guidelines and tags verified"; + } + + private static string? AttentionSummary(RecruitmentState? State, DateTimeOffset Now) + { + if (State is null) return null; + var rows = new List(); + foreach (var post in State.Listings.Values.OrderByDescending(p => p.UpdatedAt)) + { + var reasons = new List(); + if (post.Reports.Any(r => r.ResolvedAt is null)) reasons.Add("private reports"); + if (post.MaterialChanges > post.ReviewedMaterialChanges) reasons.Add("changed terms/links"); + if (post.PublicPin.Fault is not null) reasons.Add("optional response pin: " + DiscordDisplayText.Literal(post.PublicPin.Fault.Message, 100)); + if (post.Hold is not null) reasons.Add("processing held"); + if (post.AttemptPausedUntil > Now) reasons.Add("posting pause"); + if (post.TimeoutAlertCount >= 3 && State.Authors.GetValueOrDefault(post.OwnerId)?.TimeoutAlerted == true) reasons.Add("repeated timeouts"); + if (post.Attention.Any(n => n.Fault is not null)) reasons.Add("notice delivery held"); + if (reasons.Count > 0) rows.Add($"• <#{post.Id}> · {string.Join(", ", reasons)}"); + } + if (rows.Count == 0) return null; + return string.Join("\n", rows.Take(5)) + (rows.Count > 5 ? $"\n…and {rows.Count - 5} more; use lookup for individual records." : ""); + } + + private static string? ActivitySummary(RecruitmentState? State, DateTimeOffset Now) + { + if (State is null) return null; + var recent = State.Listings.Values.Where(p => p.CreatedAt > Now.AddDays(-1)).ToArray(); + var accepted = recent.Where(p => p.AcceptedAt.HasValue).ToArray(); + var delay = accepted.Length == 0 ? "—" : Math.Round(accepted.Average(p => (p.AcceptedAt!.Value - (p.PromptDeliveredAt ?? p.CreatedAt)).TotalMinutes), 1).ToString("0.#", System.Globalization.CultureInfo.InvariantCulture) + " min"; + return $"Past 24h: {recent.Length} creations · {accepted.Length} accepted · {recent.Count(p => p.Status == ListingStatus.Closed && !p.AcceptedAt.HasValue)} closed conflicts · " + + $"{recent.Count(p => LegacyListingOutcomes.IsGuidelineTimeout(p))} timeouts · mean prompt-to-accept: {delay}."; + } +} diff --git a/DiscordBot/Services/Recruitment/Runtime/RecruitmentWorker.cs b/DiscordBot/Services/Recruitment/Runtime/RecruitmentWorker.cs new file mode 100644 index 00000000..c76c2294 --- /dev/null +++ b/DiscordBot/Services/Recruitment/Runtime/RecruitmentWorker.cs @@ -0,0 +1,269 @@ +using DiscordBot.Services.Recruitment.Application; +using DiscordBot.Utils; +using System.Threading.Channels; +using System.Collections.Concurrent; +using Discord.WebSocket; +using DiscordBot.Settings; + +namespace DiscordBot.Services.Recruitment.Runtime; + +/// Serializes durable work; gateway callbacks only capture facts and wake the next-due scheduler. +internal sealed class RecruitmentWorker : IRecruitmentGatewaySink +{ + private readonly BotSettings _settings; + private readonly RecruitmentEngine _engine; + private bool _gatewayReady = true; + private readonly TimeProvider _time; + private readonly ConcurrentDictionary _pendingThreadWork = new(); + private readonly Dictionary> _coalesced = new(); + private readonly object _enqueueGate = new(); + private IReadOnlySet _knownThreads = new HashSet(); + private IReadOnlyDictionary _listingOwners = new Dictionary(); + private IReadOnlyDictionary _messageOwners = new Dictionary(); + private string? _overviewMessage; + private readonly Channel _queue = Channel.CreateBounded(new BoundedChannelOptions(256) + { SingleReader = true, FullMode = BoundedChannelFullMode.Wait }); + private readonly CancellationTokenSource _stop = new(); + private Task? _worker; + private ITimer? _timer; + private int _gap; + private long _evidenceGeneration; + private string _work = "Starting"; + private string? _job; + private RecruitmentHealth _health; + private enum WorkKind { Event, Deadline, Command, Recovery } + private sealed record Work(WorkKind Kind, Func Run, Action Cancel); + public RecruitmentHealth Health => Volatile.Read(ref _health); + + public event Action? Stopped; + public void InitializeGateway(bool ready) => Volatile.Write(ref _gatewayReady, ready); + public void SetGatewayReady(bool ready) + { + Volatile.Write(ref _gatewayReady, ready); + if (!ready) EvidenceGap(); + } + public bool TryGetOwnerThreads(string owner, out string[] threads) => + Volatile.Read(ref _listingOwners).TryGetValue(owner, out threads!); + + public RecruitmentWorker(BotSettings settings, IRecruitmentDiscord discord, TimeProvider time) + { + _settings = settings; _time = time; + _health = RecruitmentHealth.Starting(time.GetUtcNow()); + _engine = new RecruitmentEngine(settings, discord, time, + id => _pendingThreadWork.GetValueOrDefault(id) > 0, () => Interlocked.Read(ref _evidenceGeneration), () => Volatile.Read(ref _gatewayReady), + () => _pendingThreadWork.Keys.Any(id => !Volatile.Read(ref _knownThreads).Contains(id))); + _engine.Changed += PublishHealth; + } + + public async Task StartAsync() + { + if (_worker is not null) return; + _timer = _time.CreateTimer(_ => Enqueue("due", ct => _engine.RunDueAsync(ct), WorkKind.Deadline), null, Timeout.InfiniteTimeSpan, Timeout.InfiniteTimeSpan); + var started = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + _worker = Task.Run(async () => + { + try + { + await _engine.StartAsync(_stop.Token); + _work = "Idle"; + await _engine.RefreshOverviewAsync(_stop.Token); + PublishHealth(); ArmTimer(); started.TrySetResult(); + await foreach (var work in _queue.Reader.ReadAllAsync(_stop.Token)) + { + _work = work.Kind.ToString(); PublishHealth(); + try + { + if (Interlocked.Exchange(ref _gap, 0) != 0) { _engine.MarkEvidenceGap(); _engine.RequestRecovery(); } + await work.Run(_stop.Token); + await _engine.RefreshOverviewAsync(_stop.Token); + } + catch (OperationCanceledException) when (_stop.IsCancellationRequested) { work.Cancel(); break; } + catch (Exception ex) + { + work.Cancel(); EvidenceGap(); + LoggingService.LogToConsole($"Recruitment {work.Kind} held ({ex.GetType().Name}); inspect /recruitment status.", ExtendedLogSeverity.Warning); + } + finally { _work = "Idle"; PublishHealth(); ArmTimer(); } + } + } + catch (OperationCanceledException) when (_stop.IsCancellationRequested) { started.TrySetCanceled(); } + catch (Exception ex) { started.TrySetException(ex); } + finally + { + _stop.Cancel(); _queue.Writer.TryComplete(); _timer?.Dispose(); Stopped?.Invoke(); + while (_queue.Reader.TryRead(out var work)) work.Cancel(); + _engine.Dispose(); + Volatile.Write(ref _health, Health with { Status = "Stopped", Kind = RecruitmentHealthKind.Stopped, Work = "Stopped", ObservedAt = _time.GetUtcNow() }); + } + }); + await started.Task; + } + + private void PublishHealth() + { + var next = _engine.Health(_work) with { Job = _job }; + var before = Health; + Volatile.Write(ref _health, next); + Volatile.Write(ref _knownThreads, _engine.State is { } state ? state.Listings.Keys.Concat(state.Forums.Values.Select(f => f.Reference.ThreadId).OfType()).ToHashSet() : new HashSet()); + Volatile.Write(ref _listingOwners, _engine.State?.Listings.Values.Where(p => p.Status == ListingStatus.Open) + .GroupBy(p => p.OwnerId).ToDictionary(g => g.Key, g => g.Select(p => p.Id).ToArray()) ?? new Dictionary()); + var owners = new Dictionary(); + foreach (var post in _engine.State?.Listings.Values ?? Enumerable.Empty()) + { + if (post.PublicMessageId is not null) owners[post.PublicMessageId] = post.Id; + if (post.StaffMessageId is not null) owners[post.StaffMessageId] = post.Id; + } + Volatile.Write(ref _messageOwners, owners); + Volatile.Write(ref _overviewMessage, _engine.State?.Overview.MessageId); + foreach (var forum in next.Forums) + { + var previous = before.Forums.FirstOrDefault(f => f.Id == forum.Id); + if (previous?.Fault == forum.Fault) continue; + LoggingService.LogToConsole($"Recruitment setup [{forum.Purpose}/{forum.Id}]: " + + (forum.Fault is null ? "recovered" : forum.Fault + (forum.RetryAt.HasValue ? $"; retry {forum.RetryAt:u}" : "; preview and repair required")), + forum.Fault is null ? ExtendedLogSeverity.Info : ExtendedLogSeverity.Warning); + } + if (before.Status != next.Status) + { + if (next.RoutinePendingIdentity || before.RoutinePendingIdentity && next.Kind == RecruitmentHealthKind.Operating) + LoggingService.DebugLog("Recruitment: " + next.Status, ExtendedLogSeverity.Debug); + else LoggingService.LogToConsole("Recruitment: " + next.Status, + next.InventoryComplete ? ExtendedLogSeverity.Info : ExtendedLogSeverity.Warning); + } + } + + private void ArmTimer() + { + if (!Volatile.Read(ref _gatewayReady)) { SetTimer(Timeout.InfiniteTimeSpan); return; } + var due = _engine.NextDueAt; + var delay = due.HasValue ? due.Value - _time.GetUtcNow() : Timeout.InfiniteTimeSpan; + // A bounded continuation yields to facts/commands already queued, including a timely acknowledgement. + if (due.HasValue && delay < TimeSpan.FromSeconds(1)) delay = TimeSpan.FromSeconds(1); + SetTimer(delay); + } + + private void SetTimer(TimeSpan delay) + { + if (_stop.IsCancellationRequested) return; + try { _timer?.Change(delay, Timeout.InfiniteTimeSpan); } + catch (ObjectDisposedException) when (_stop.IsCancellationRequested) { } + } + + internal void EvidenceGap() + { + Interlocked.Increment(ref _evidenceGeneration); // Immediately fences an action already awaiting its final read. + Interlocked.Exchange(ref _gap, 1); + Volatile.Write(ref _health, Health with { InventoryComplete = false, Kind = RecruitmentHealthKind.RecoveryNeeded, Status = "Gateway evidence incomplete; recovery needed" }); + } + + private Action TrackThread(string? thread) + { + if (thread is null) return () => { }; + _pendingThreadWork.AddOrUpdate(thread, 1, (_, count) => count + 1); + var finished = 0; + return () => + { + if (Interlocked.Exchange(ref finished, 1) != 0) return; + var remaining = _pendingThreadWork.AddOrUpdate(thread, 0, (_, count) => Math.Max(0, count - 1)); + if (remaining == 0) ((ICollection>)_pendingThreadWork).Remove(new(thread, 0)); + }; + } + + private bool Enqueue(string key, Func run, WorkKind kind = WorkKind.Event, string? thread = null) + { + lock (_enqueueGate) + { + if (_stop.IsCancellationRequested) return false; + if (_coalesced.ContainsKey(key)) { _coalesced[key] = run; return true; } + _coalesced[key] = run; + var finished = TrackThread(thread); + void Cancel() { lock (_enqueueGate) _coalesced.Remove(key); finished(); } + if (_queue.Writer.TryWrite(new Work(kind, async ct => + { + Func? latest; + lock (_enqueueGate) _coalesced.Remove(key, out latest); + // Only work still waiting in the queue fences automatic actions. This event must not fence itself. + finished(); + if (latest is not null) await latest(ct); + }, Cancel))) return true; + Cancel(); EvidenceGap(); + SetTimer(TimeSpan.FromSeconds(1)); + return false; + } + } + + public void NotifyThread(string id) => Enqueue("thread:" + id, ct => _engine.ThreadChangedAsync(id, ct), thread: id); + public void NotifyCreation(ThreadCreation thread) => Enqueue("created:" + thread.Id, ct => _engine.ThreadCreatedAsync(thread, ct), thread: thread.Id); + public void NotifyFactsChanged(string id) => Enqueue("facts:" + id, ct => _engine.FactsChangedAsync(id, ct), thread: id); + + public void NotifyThreadDeletion(string id) + { + // Discord's delete event proves absence even when setup or a listing write is held. + if (Volatile.Read(ref _knownThreads).Contains(id)) + Enqueue("deleted:" + id, ct => _engine.ThreadDeletedAsync(id, ct), thread: id); + } + public void NotifyMessageDeletion(string channel, IReadOnlySet ids) + { + var owners = Volatile.Read(ref _messageOwners); + foreach (var group in ids.Where(owners.ContainsKey).GroupBy(id => owners[id])) + { + var affected = group.ToHashSet(); + Enqueue($"messages:{channel}:{group.Key}", ct => _engine.MessageDeletedAsync(channel, affected, ct), thread: group.Key); + } + if (Volatile.Read(ref _overviewMessage) is { } overview && ids.Contains(overview)) + Enqueue("overview-deleted", ct => _engine.MessageDeletedAsync(channel, new HashSet { overview }, ct)); + } + + public void ScheduleRecovery() + { + EvidenceGap(); + Enqueue("recovery", async ct => { _engine.InvalidateAccess(); await _engine.SweepAsync(ct, retryHeld: false); }, WorkKind.Recovery); + } + // Modal opening reads only this detached snapshot. Submission reauthorizes on the worker. + public bool CanOpenAcknowledgement(string guild, string thread, string actor) => + _settings.Recruitment.Enabled && !_stop.IsCancellationRequested && guild == _settings.GuildId.ToString() && + Volatile.Read(ref _listingOwners).TryGetValue(actor, out var threads) && threads.Contains(thread); + + public string QueueRecovery(string guild, string actor, StaffCommand operation, string target, string reason) + { + if (string.IsNullOrWhiteSpace(reason)) return "A reason is required for this change."; + var id = Guid.NewGuid().ToString("N")[..8]; + var queued = Enqueue("job:" + id, async ct => + { + _job = id + " running"; PublishHealth(); + try + { + var result = await _engine.StaffAsync(guild, actor, operation, target, reason, ct); + _job = id + ": " + DiscordDisplayText.Literal(result, 300); + } + catch (Exception ex) + { + _job = id + ": held — " + DiscordDisplayText.Literal(ex.Message, 250); + throw; + } + }, WorkKind.Recovery); + if (!queued) return "Recruitment is busy or stopped; the job was not queued."; + return $"Queued recruitment job `{id}`. Use /recruitment status to check progress."; + } + + public async Task RunAsync(Func> command, string? ownerThread = null) + { + if (_worker is null || _stop.IsCancellationRequested) throw new InvalidOperationException("Recruitment is starting or stopped."); + var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var finished = TrackThread(ownerThread); + var work = new Work(WorkKind.Command, async ct => + { + finished(); + try { completion.TrySetResult(await command(_engine, ct)); } + catch (Exception ex) { completion.TrySetException(ex); } + }, () => { finished(); completion.TrySetCanceled(); }); + if (!_queue.Writer.TryWrite(work)) { finished(); throw new InvalidOperationException("Recruitment is busy. Please try again shortly."); } + return await completion.Task; + } + + public async Task StopAsync(CancellationToken ct) + { + _stop.Cancel(); _timer?.Dispose(); _queue.Writer.TryComplete(); + if (_worker is not null) await _worker.WaitAsync(ct); + } +}