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 diff --git a/.vscode/launch.json b/.vscode/launch.json index 12e91de6..997f7a60 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", + // 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 + "console": "internalConsole", + "stopAtEntry": false + }, + { + "name": ".NET Core Attach", + "type": "coreclr", + "request": "attach", + "processId": "${command:pickProcess}" + } + ] +} diff --git a/.vscode/tasks.json b/.vscode/tasks.json index adf4d422..b47a7b4e 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -1,42 +1,62 @@ { - "version": "2.0.0", - "tasks": [ - { - "label": "build", - "command": "dotnet", - "type": "process", - "args": [ - "build", - "${workspaceFolder}/DiscordBot/DiscordBot.csproj", - "/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", - "run", - "${workspaceFolder}/DiscordBot/DiscordBot.csproj", - "/property:GenerateFullPaths=true", - "/consoleloggerparameters:NoSummary" - ], - "problemMatcher": "$msCompile" - } - ] -} \ No newline at end of file + "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/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.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/Analysis/RecruitmentContentTests.cs b/DiscordBot.Tests/Recruitment/Analysis/RecruitmentContentTests.cs new file mode 100644 index 00000000..24e77125 --- /dev/null +++ b/DiscordBot.Tests/Recruitment/Analysis/RecruitmentContentTests.cs @@ -0,0 +1,71 @@ +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", "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)); + + [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("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() + { + 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?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/Analysis/RecruitmentPortfolioTests.cs b/DiscordBot.Tests/Recruitment/Analysis/RecruitmentPortfolioTests.cs new file mode 100644 index 00000000..07fe0fca --- /dev/null +++ b/DiscordBot.Tests/Recruitment/Analysis/RecruitmentPortfolioTests.cs @@ -0,0 +1,316 @@ +using DiscordBot.Services.Recruitment.Domain.Policies; +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)); + 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); } + 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" }; + 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/Analysis/RecruitmentSignalTests.cs b/DiscordBot.Tests/Recruitment/Analysis/RecruitmentSignalTests.cs new file mode 100644 index 00000000..00b97045 --- /dev/null +++ b/DiscordBot.Tests/Recruitment/Analysis/RecruitmentSignalTests.cs @@ -0,0 +1,134 @@ +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("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)] + [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.Tests/Recruitment/Discord/RecruitmentPinTests.cs b/DiscordBot.Tests/Recruitment/Discord/RecruitmentPinTests.cs new file mode 100644 index 00000000..d0f97be3 --- /dev/null +++ b/DiscordBot.Tests/Recruitment/Discord/RecruitmentPinTests.cs @@ -0,0 +1,121 @@ +using DiscordBot.Services.Recruitment.Infrastructure.Discord; +using DiscordBot.Utils; +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(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); + 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/Discord/RecruitmentPublicationTests.cs b/DiscordBot.Tests/Recruitment/Discord/RecruitmentPublicationTests.cs new file mode 100644 index 00000000..8affdf48 --- /dev/null +++ b/DiscordBot.Tests/Recruitment/Discord/RecruitmentPublicationTests.cs @@ -0,0 +1,664 @@ +using DiscordBot.Services.Recruitment.Infrastructure.Discord; +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(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)] + [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); + } + + [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() + { + 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.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); + 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); + } + + [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("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)); + } + + [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); + } + + [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() + { + 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 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 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); + 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 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 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 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"]); + } + + [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 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"]!); + 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(() => 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 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(() => 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(() => V2MessageWriter.UpsertAsync(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 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(() => 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 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"]!); + } + + [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 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 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"]!); + 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 V2MessageWriter.UpsertAsync(channel, 999, null, false, DateTimeOffset.UtcNow, V2(media: false), true, () => { }, default); + var owned = (IUserMessage)(await channel.GetMessageAsync(101))!; + 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(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); + } + + [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(() => V2MessageWriter.UpsertAsync(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 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 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 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"]); + } + + [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 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); + 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 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 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"]); + } + + [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 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 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"]!); + } + + [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 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()); + } + + [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 V2MessageWriter.UpsertAsync(channel, 999, "101", false, DateTimeOffset.UtcNow, V2(media: false), true, () => { }, 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(); + 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 V2MessageWriter.UpsertAsync(channel, 999, "101", false, DateTimeOffset.UtcNow, V2(media: false), true, () => { }, default); + await Assert.ThrowsAsync(() => DiscordResponsePins.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 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; + var receipt = new MessagePinReceipt { MessageId = "101" }; + void SaveIntent(bool archive, bool locked) { receipt.RestoreArchive = archive; receipt.RestoreLocked = locked; } + 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 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"]!); + Assert.Equal(32768, (int)transport.Thread["flags"]!); + } + + [Theory] + [InlineData(400)] + [InlineData(403)] + [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); + 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(() => 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 + // that server deadline passes, without requiring Pin Messages permission. + if (status == 429) + { + var deferred = Assert.IsType(error); + Assert.True(receipt.RestoreArchive); + clock.Advance(deferred.RetryAt - clock.Now + TimeSpan.FromMilliseconds(100)); + channel = (IThreadChannel)(await client.Rest.GetChannelAsync(100))!; + transport.PinStatus = 0; + 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); + 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 = RecruitmentFaultClassifier.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 V2MessageWriter.UpsertAsync(channel, 999, "101", false, DateTimeOffset.UtcNow, V2(media: false), true, () => { }, default); + transport.Message["author"]!["id"] = "200"; + await Assert.ThrowsAsync(() => DiscordResponsePins.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 } }; + + private static async Task Client(PublicationTransport 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. + 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? 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; } + 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(); + 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 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; + 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) { + 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; + 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 = new JObject { + ["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") + { + 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") + { + 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(); + 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(); + } + 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 (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())); } + 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/Discord/RecruitmentReferenceTests.cs b/DiscordBot.Tests/Recruitment/Discord/RecruitmentReferenceTests.cs new file mode 100644 index 00000000..cbe62151 --- /dev/null +++ b/DiscordBot.Tests/Recruitment/Discord/RecruitmentReferenceTests.cs @@ -0,0 +1,150 @@ +using DiscordBot.Services.Recruitment.Presentation; +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.Equal("Read First · Recruitment Information", reference.Title); + 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("Information", rig.Discord.Threads["100"].Title); + 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); + } + [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/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 new file mode 100644 index 00000000..9f6ad67d --- /dev/null +++ b/DiscordBot.Tests/Recruitment/Discord/RecruitmentTransportTests.cs @@ -0,0 +1,188 @@ +using DiscordBot.Services.Recruitment.Infrastructure.Discord; +using System.Net; +using System.Text; +using Discord; +using Discord.Net.Rest; +using Discord.Rest; +using Discord.WebSocket; +using DiscordBot.Settings; +using DiscordBot.Services.Recruitment; +using Newtonsoft.Json.Linq; + +namespace DiscordBot.Tests.Recruitment; + +[Collection("Recruitment transport")] +public class RecruitmentTransportTests +{ + [Fact] + public async Task InstalledSdkPreservesModerationEmojisAndFlagsThroughStagedPublication() + { + var transport = new ForumTransport(); + 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)); + 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 DiscordCompatibilityTransport(inner); + await Assert.ThrowsAsync(() => transport.SendAsync("PATCH", "channels/11", "{}", + default, false, DiscordCompatibilityTransport.TagWriteReason, Array.Empty>>())); + Assert.Empty(inner.Writes); + } + + [Fact] + public async Task InventoryReusesForumsAndFetchesGuildActiveThreadsOnce() + { + var transport = new ForumTransport { InventoryMode = true }; + using var client = new DiscordSocketClient(new DiscordSocketConfig + { 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 } } }; + var adapter = new DiscordRecruitmentAdapter(client, settings); + 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 = DiscordCompatibilityTransport.Wrap(_ => transport) }); + await client.LoginAsync(TokenType.Bot, "offline-placeholder-token", validateToken: false); + var forum = Assert.IsType(await client.GetChannelAsync(11)); + 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")); + } + + [Theory] + [InlineData(235)] + [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 = DiscordCompatibilityTransport.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 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); + } + + [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 = 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))); + 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. + 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, + "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) + { + 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 (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") + { + 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(); + } + 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/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/Persistence/RecruitmentStorageAndSetupTests.cs b/DiscordBot.Tests/Recruitment/Persistence/RecruitmentStorageAndSetupTests.cs new file mode 100644 index 00000000..b00471c8 --- /dev/null +++ b/DiscordBot.Tests/Recruitment/Persistence/RecruitmentStorageAndSetupTests.cs @@ -0,0 +1,309 @@ +using DiscordBot.Services.Recruitment; +using Newtonsoft.Json.Linq; + +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 rules", 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() + { + 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); + } + + [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.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")] + [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 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"); ((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] + 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(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)); + } + + [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(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] + 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.Contains("Unknown recruitment operation", await rig.Staff("review-no-reply")); + await rig.Sweep(); + Assert.Equal(ListingStatus.Open, 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", "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")] + [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)); + +} diff --git a/DiscordBot.Tests/Recruitment/Presentation/RecruitmentBannerTests.cs b/DiscordBot.Tests/Recruitment/Presentation/RecruitmentBannerTests.cs new file mode 100644 index 00000000..31eb7e58 --- /dev/null +++ b/DiscordBot.Tests/Recruitment/Presentation/RecruitmentBannerTests.cs @@ -0,0 +1,97 @@ +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 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() + { + 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"); + 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 }) + 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.Tests/Recruitment/Presentation/RecruitmentPresentationTests.cs b/DiscordBot.Tests/Recruitment/Presentation/RecruitmentPresentationTests.cs new file mode 100644 index 00000000..142ec58b --- /dev/null +++ b/DiscordBot.Tests/Recruitment/Presentation/RecruitmentPresentationTests.cs @@ -0,0 +1,96 @@ +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)); + + private static string Text(RecruitmentMessage message) => Newtonsoft.Json.JsonConvert.SerializeObject(message.FallbackComponents); + + [Fact] + public async Task PublicFallbackKeepsWarningsInstructionsAndAllObservations() + { + using var rig = new RecruitmentRig(); await rig.Start(); var post = await rig.Post(forum: "12"); + 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.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() + { + using var rig = new RecruitmentRig(); await rig.Start(); var post = await rig.Post(forum: "13"); + var message = RecruitmentPresentation.PublicV2(rig.State, post, rig.Settings.Recruitment, rig.Clock.Now); + Assert.Contains("Not Applicable", Text(message)); + } + + [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.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); + 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 Roles", RecruitmentPresentation.Details(post)); + Assert.Contains("0 Roles", Text(rig.Discord.Messages["100:recruitment:100:public"].Message)); + } +} 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/Runtime/RecruitmentOverviewTests.cs b/DiscordBot.Tests/Recruitment/Runtime/RecruitmentOverviewTests.cs new file mode 100644 index 00000000..35cc06b0 --- /dev/null +++ b/DiscordBot.Tests/Recruitment/Runtime/RecruitmentOverviewTests.cs @@ -0,0 +1,80 @@ +using DiscordBot.Services.Recruitment; + +namespace DiscordBot.Tests.Recruitment; + +public class RecruitmentOverviewTests +{ + [Fact] + public async Task OverviewStaysFixedWhileListingsAndHealthChange() + { + using var rig = new RecruitmentRig(); await rig.Start(); await rig.Engine.RefreshOverviewAsync(default); + var first = rig.State.Overview.MessageId; + 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.Clock.Advance(TimeSpan.FromMinutes(1)); await rig.Engine.RefreshOverviewAsync(default); + Assert.Equal(upserts, rig.Discord.Upserts); + } + + [Fact] + public async Task LostInitialOverviewResponseRecoversSameMessageAfterRestart() + { + using var rig = new RecruitmentRig(); await rig.Start(); + rig.Discord.LoseNextSendResponse = true; + await rig.Engine.RefreshOverviewAsync(default); + 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(sent, rig.State.Overview.MessageId); + Assert.Single(rig.Discord.Messages.Values); Assert.False(rig.State.Overview.SendPending); + } + + [Fact] + public async Task LegacyRetirementFinishesWithoutMovingOverviewOrBlockingAcceptance() + { + using var rig = new RecruitmentRig(); await rig.Start(); await rig.Engine.RefreshOverviewAsync(default); + 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); + } + + [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.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/Runtime/RecruitmentSchedulingTests.cs b/DiscordBot.Tests/Recruitment/Runtime/RecruitmentSchedulingTests.cs new file mode 100644 index 00000000..81b712c3 --- /dev/null +++ b/DiscordBot.Tests/Recruitment/Runtime/RecruitmentSchedulingTests.cs @@ -0,0 +1,218 @@ +using DiscordBot.Services.Recruitment; + +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() + { + 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 AcceptedListingsNeverNeedReplyHistoryScans() + { + using var rig = new RecruitmentRig(); await rig.Start(); var post = await rig.Post(); await rig.Ack(); + await rig.Sweep(); await rig.Engine.ThreadChangedAsync(post.Id, default); + Assert.Equal(0, rig.Discord.ReplyReads); + } + + [Fact] + 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); + 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.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 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.Null(waiting.AcceptedAt); Assert.Equal(ListingStatus.Closed, waiting.Status); + } + + [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(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); } + } + + [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/Support/RecruitmentTestRig.cs b/DiscordBot.Tests/Recruitment/Support/RecruitmentTestRig.cs new file mode 100644 index 00000000..ea09798d --- /dev/null +++ b/DiscordBot.Tests/Recruitment/Support/RecruitmentTestRig.cs @@ -0,0 +1,249 @@ +using DiscordBot.Services.Recruitment.Infrastructure.Discord; +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); + private readonly List _timers = new(); + public override DateTimeOffset GetUtcNow() => Now; + 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 +{ + 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.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); } +} + +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 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; } + 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; } + 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; } + public int ForumWrites { get; private set; } + private int _nextId = 1000; + 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 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 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]); } + public Task PublishForumAsync(ForumSnapshot expected, string? topic, IReadOnlyList? tags, bool requireTag, CancellationToken ct) + { + var id = expected.Id; + if (FailForum == id && (FailForumRequireTag is null || FailForumRequireTag == requireTag)) throw new InvalidOperationException("Forum write failed"); + ForumWrites++; + 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) { 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", 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); + } + 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 = false }; + 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) + { + 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)) + { + if (existing.Message.RenderKey != message.RenderKey) 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 (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)); + } + 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"); + 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, Func? allowWrite = null) + { + var id = thread.Id; + 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, 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/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.Tests/Recruitment/Workflows/RecruitmentAcknowledgementTests.cs b/DiscordBot.Tests/Recruitment/Workflows/RecruitmentAcknowledgementTests.cs new file mode 100644 index 00000000..c5fe9a73 --- /dev/null +++ b/DiscordBot.Tests/Recruitment/Workflows/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/Workflows/RecruitmentAttemptTests.cs b/DiscordBot.Tests/Recruitment/Workflows/RecruitmentAttemptTests.cs new file mode 100644 index 00000000..af7d382f --- /dev/null +++ b/DiscordBot.Tests/Recruitment/Workflows/RecruitmentAttemptTests.cs @@ -0,0 +1,197 @@ +using DiscordBot.Services.Recruitment; + +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() + { + 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 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/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.Tests/Recruitment/Workflows/RecruitmentEvidenceTests.cs b/DiscordBot.Tests/Recruitment/Workflows/RecruitmentEvidenceTests.cs new file mode 100644 index 00000000..e57ae48f --- /dev/null +++ b/DiscordBot.Tests/Recruitment/Workflows/RecruitmentEvidenceTests.cs @@ -0,0 +1,102 @@ +using DiscordBot.Services.Recruitment.Presentation; +using DiscordBot.Services.Recruitment; + +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); + 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(); + 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() + { + 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)); + 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/Workflows/RecruitmentFollowUpTests.cs b/DiscordBot.Tests/Recruitment/Workflows/RecruitmentFollowUpTests.cs new file mode 100644 index 00000000..0f54ada5 --- /dev/null +++ b/DiscordBot.Tests/Recruitment/Workflows/RecruitmentFollowUpTests.cs @@ -0,0 +1,95 @@ +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)); + + [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/Workflows/RecruitmentRecoveryTests.cs b/DiscordBot.Tests/Recruitment/Workflows/RecruitmentRecoveryTests.cs new file mode 100644 index 00000000..ed551aeb --- /dev/null +++ b/DiscordBot.Tests/Recruitment/Workflows/RecruitmentRecoveryTests.cs @@ -0,0 +1,161 @@ +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()); + using var provider = new ServiceCollection().AddSingleton(service).BuildServiceProvider(); + var module = await interactions.AddModuleAsync(provider); + 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"); + Assert.Equal(7, module.ComponentCommands.Count); + Assert.Equal(2, module.ModalCommands.Count); + } + + [Fact] + public async Task LostCloseResponseIsConfirmedAfterRestartWithoutRepeatingAction() + { + using var rig = new RecruitmentRig(); await rig.Start(); await rig.Post(); await rig.Ack(); + rig.Discord.LoseNextCloseResponse = true; + 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); + } + + [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 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() + { + 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", 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/Workflows/RecruitmentReportTests.cs b/DiscordBot.Tests/Recruitment/Workflows/RecruitmentReportTests.cs new file mode 100644 index 00000000..6e3ff7d8 --- /dev/null +++ b/DiscordBot.Tests/Recruitment/Workflows/RecruitmentReportTests.cs @@ -0,0 +1,78 @@ +using DiscordBot.Services.Recruitment.Presentation; +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/Workflows/RecruitmentSubmissionTests.cs b/DiscordBot.Tests/Recruitment/Workflows/RecruitmentSubmissionTests.cs new file mode 100644 index 00000000..e5246abf --- /dev/null +++ b/DiscordBot.Tests/Recruitment/Workflows/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/Workflows/RecruitmentWorkflowTests.cs b/DiscordBot.Tests/Recruitment/Workflows/RecruitmentWorkflowTests.cs new file mode 100644 index 00000000..0f3d6690 --- /dev/null +++ b/DiscordBot.Tests/Recruitment/Workflows/RecruitmentWorkflowTests.cs @@ -0,0 +1,264 @@ +using DiscordBot.Services.Recruitment.Domain.Policies; +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 = 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); + } + + [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.Null(second.AcknowledgedAt); Assert.Equal(ListingStatus.Closed, second.Status); + Assert.Equal("100", ListingEligibilityPolicy.Evaluate(rig.State, second, rig.Clock.Now, 30).BlockingListingId); + } + + [Fact] + 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(); + Assert.Equal(ListingStatus.Closed, second.Status); + Assert.DoesNotContain(("101", "delete"), rig.Discord.Actions); + } + + [Fact] + 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.Null(second.AcceptedAt); Assert.Equal(ListingStatus.Closed, second.Status); + } + + [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(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); + } + + [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.Equal(ListingStatus.Open, post.Status); Assert.Empty(rig.Discord.Actions); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task AcceptedListingsRemainOpenIndefinitelyAcrossRestart(bool legacyReplyCoverage) + { + 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 LegacyPendingUnansweredClosureIsCancelledAfterRestart() + { + using var rig = new RecruitmentRig(); await rig.Start(); var post = await rig.Post(); await rig.Ack(); + 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] + 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() + { + 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; rig.Engine.MarkEvidenceGap(); 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 ReopenKeepsListingOpenWithoutAnExpiry() + { + 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.Staff("reopen"); await rig.Sweep(); + Assert.Equal(ListingStatus.Open, rig.State.Listings["100"].Status); + } +} 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..360d0549 --- /dev/null +++ b/DiscordBot/Assets/recruitment/guidelines/hobby-for-hire.md @@ -0,0 +1,13 @@ +# Hobby for hire + +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 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. + +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. 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 new file mode 100644 index 00000000..dc39f45a --- /dev/null +++ b/DiscordBot/Assets/recruitment/guidelines/hobby-recruiting.md @@ -0,0 +1,13 @@ +# Hobby recruiting + +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 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. + +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. 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 new file mode 100644 index 00000000..c372e5bf --- /dev/null +++ b/DiscordBot/Assets/recruitment/guidelines/paid-for-hire.md @@ -0,0 +1,13 @@ +# Paid for hire + +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 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. + +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. 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 new file mode 100644 index 00000000..0ae179b8 --- /dev/null +++ b/DiscordBot/Assets/recruitment/guidelines/paid-recruiting.md @@ -0,0 +1,13 @@ +# Paid recruiting + +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 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. + +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. 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/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/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..83af7711 100644 --- a/DiscordBot/DiscordBot.csproj +++ b/DiscordBot/DiscordBot.csproj @@ -2,6 +2,7 @@ Exe net8.0 + Major 12 enable true @@ -21,4 +22,9 @@ - \ 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..cb52aa96 --- /dev/null +++ b/DiscordBot/Modules/Recruitment/RecruitmentModule.cs @@ -0,0 +1,183 @@ +using DiscordBot.Services.Recruitment.Presentation; +using DiscordBot.Services.Recruitment.Application; +using DiscordBot.Utils; +using Discord.Interactions; +using DiscordBot.Services.Recruitment; + +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 +{ + 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("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() + { + 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("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, [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")] + 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); + [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, RecruitmentCommands.Portfolio(operation), domain, reason, ct))); + } + + private async Task Staff(string operation, string target = "", string reason = "") + { + await DeferAsync(ephemeral: true); + 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 (!await MatchesThreadAsync(thread)) 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 (!await MatchesThreadAsync(thread)) 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 (!await MatchesThreadAsync(thread)) 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) + { + if (thread != Thread || Context.Guild is null) { await RespondAsync("This control belongs to another thread.", ephemeral: true); return; } + 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 + { + 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 (!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 (!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 (!await MatchesThreadAsync(thread)) 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 (!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(); + 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/Program.cs b/DiscordBot/Program.cs index b3bf6eb0..6544eddd 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(); @@ -37,6 +39,7 @@ private async Task MainAsync() _client = new DiscordSocketClient(new DiscordSocketConfig { + RestClientProvider = DiscordCompatibilityTransport.Wrap(Discord.Net.Rest.DefaultRestClientProvider.Create()), LogLevel = LogSeverity.Verbose, AlwaysDownloadUsers = true, MessageCacheSize = 1024, @@ -49,9 +52,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 +75,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 +90,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 +105,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 +147,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/Application/Contracts/IRecruitmentDiscord.cs b/DiscordBot/Services/Recruitment/Application/Contracts/IRecruitmentDiscord.cs new file mode 100644 index 00000000..87845c8d --- /dev/null +++ b/DiscordBot/Services/Recruitment/Application/Contracts/IRecruitmentDiscord.cs @@ -0,0 +1,33 @@ +namespace DiscordBot.Services.Recruitment; + +/// 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 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); + 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(); + // 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); + Task DeleteAdvisoryAsync(string threadId, string messageId, CancellationToken ct, string? marker = null); +} diff --git a/DiscordBot/Services/Recruitment/Application/Contracts/RecruitmentMessages.cs b/DiscordBot/Services/Recruitment/Application/Contracts/RecruitmentMessages.cs new file mode 100644 index 00000000..6667985b --- /dev/null +++ b/DiscordBot/Services/Recruitment/Application/Contracts/RecruitmentMessages.cs @@ -0,0 +1,18 @@ +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()); +} +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/Application/InventoryRecovery.cs b/DiscordBot/Services/Recruitment/Application/InventoryRecovery.cs new file mode 100644 index 00000000..88f95605 --- /dev/null +++ b/DiscordBot/Services/Recruitment/Application/InventoryRecovery.cs @@ -0,0 +1,49 @@ +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/ListingActionExecutor.cs b/DiscordBot/Services/Recruitment/Application/ListingActionExecutor.cs new file mode 100644 index 00000000..48d6788a --- /dev/null +++ b/DiscordBot/Services/Recruitment/Application/ListingActionExecutor.cs @@ -0,0 +1,151 @@ +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/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/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/ListingOwnerCommands.cs b/DiscordBot/Services/Recruitment/Application/ListingOwnerCommands.cs new file mode 100644 index 00000000..81bebad7 --- /dev/null +++ b/DiscordBot/Services/Recruitment/Application/ListingOwnerCommands.cs @@ -0,0 +1,97 @@ +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/ListingPublisher.cs b/DiscordBot/Services/Recruitment/Application/ListingPublisher.cs new file mode 100644 index 00000000..466fecae --- /dev/null +++ b/DiscordBot/Services/Recruitment/Application/ListingPublisher.cs @@ -0,0 +1,175 @@ +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.WithRestoration(post.Status == ListingStatus.Closed ? ThreadRestoration.LockedArchive : + new ThreadRestoration(Archived: post.PublicRestoreArchive, Locked: post.PublicRestoreLocked)), () => + { + 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) => ListingDeliveryPolicy.AcknowledgementNoticeDue(post, Now); + + 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) => ListingDeliveryPolicy.PinDue(post, 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/ListingQueries.cs b/DiscordBot/Services/Recruitment/Application/ListingQueries.cs new file mode 100644 index 00000000..cf25747d --- /dev/null +++ b/DiscordBot/Services/Recruitment/Application/ListingQueries.cs @@ -0,0 +1,73 @@ +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/ListingReader.cs b/DiscordBot/Services/Recruitment/Application/ListingReader.cs new file mode 100644 index 00000000..1efc69dd --- /dev/null +++ b/DiscordBot/Services/Recruitment/Application/ListingReader.cs @@ -0,0 +1,67 @@ +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/ListingReportCommands.cs b/DiscordBot/Services/Recruitment/Application/ListingReportCommands.cs new file mode 100644 index 00000000..c9461854 --- /dev/null +++ b/DiscordBot/Services/Recruitment/Application/ListingReportCommands.cs @@ -0,0 +1,47 @@ +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 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 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 (!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."; + 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 = 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); + post.Reports.Add(new RecruitmentReport { ReporterId = reporter, At = Now, Concern = DiscordDisplayText.Literal(concern, 1000) }); + history.RecentReports.Add(Now); + 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 _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."; + } + +} diff --git a/DiscordBot/Services/Recruitment/Application/ListingStaffCommands.cs b/DiscordBot/Services/Recruitment/Application/ListingStaffCommands.cs new file mode 100644 index 00000000..b0bbb987 --- /dev/null +++ b/DiscordBot/Services/Recruitment/Application/ListingStaffCommands.cs @@ -0,0 +1,89 @@ +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/ListingWorkflow.cs b/DiscordBot/Services/Recruitment/Application/ListingWorkflow.cs new file mode 100644 index 00000000..3a7bdeb1 --- /dev/null +++ b/DiscordBot/Services/Recruitment/Application/ListingWorkflow.cs @@ -0,0 +1,227 @@ +using DiscordBot.Services.Recruitment.Presentation; +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/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/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 new file mode 100644 index 00000000..960caf6d --- /dev/null +++ b/DiscordBot/Services/Recruitment/Application/RecruitmentEngine.cs @@ -0,0 +1,355 @@ +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 System.IO; + +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 _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; + private bool _ready; + 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; + private ListingPublisher? _publisher; + private ListingReader? _reader; + private ListingForums? _listingForums; + private ListingOwnerCommands? _ownerCommands; + private ListingQueries? _queries; + 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, 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); + _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 + { + _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); + _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, _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); + _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); + _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(); + await SweepAsync(ct); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { Status = $"Recruitment stopped: {ex.GetType().Name}: {ex.Message}"; _ready = false; } + } + + public void MarkEvidenceGap(bool invalidateAccess = true) + { + if (State is null) return; + _inventory?.Invalidate(); + _accessInvalid = true; + if (invalidateAccess) _feedReady = false; + _discord.InvalidateCache(); + _references!.Invalidate(); + foreach (var post in State.Listings.Values.Where(p => p.Status == ListingStatus.Open)) + { + if (!post.AcceptedAt.HasValue) _recoverWindows.Add(post.Id); + } + Save(); + } + + public async Task SweepAsync(CancellationToken ct, bool retryHeld = true) + { + if (_store is null || _setup is null) return; + var previousFault = State!.RecoveryFault; + var auditDue = _nextAudit <= Now; + var held = !retryHeld && State.RecoveryFault is { } recovery && !recovery.IsDue(Now); + _nextAudit = Now.AddMinutes(15 + _auditJitter() * 3); + _publisher?.ClearRenders(); + _discord.InvalidateCache(); + try + { + if (_accessInvalid || auditDue || retryHeld) await RefreshAccessAsync(held, ct); + if (held) { Changed?.Invoke(); return; } + State!.RecoveryFault = null; + 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; + Status = "Recruitment held: " + failed.Fault!.Message; + Changed?.Invoke(); + return; + } + _ready = true; + State.RecoveryFault = null; + await DiscoverAsync(ct); + 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); + Save(); + 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; + try { MarkEvidenceGap(invalidateAccess: false); } catch (IOException) { return; } + if (State is not null) + { + State.RecoveryFault = RecruitmentFaultClassifier.From(ex, Now, previousFault); + try { Save(); } catch { return; } + } + Status = $"Recruitment held: {ex.GetType().Name}: {ex.Message}"; + } + Changed?.Invoke(); + } + + private async Task RefreshAccessAsync(bool held, CancellationToken ct) + { + _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) + { + 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; + } + + private Task DiscoverAsync(CancellationToken ct) => _inventory!.DiscoverAsync(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); + + 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 == 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)) + 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 == StaffOperation.Lookup) + { + 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 == StaffOperation.Repair) + { + if (target.Length > 0 && !State.Forums.ContainsKey(target)) return "Unknown recruitment forum purpose."; + State.Overview.Fault = null; + _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, 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; + _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; + } + if (!_ready) return "Recruitment setup needs recovery before listing changes are available."; + 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() + { + try { _store!.Save(); Changed?.Invoke(); } + catch + { + _ready = false; + Status = "Recruitment stopped after a state write failure; restore storage and restart before making changes."; + 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/RecruitmentOverviewPublisher.cs b/DiscordBot/Services/Recruitment/Application/RecruitmentOverviewPublisher.cs new file mode 100644 index 00000000..c6ee34cc --- /dev/null +++ b/DiscordBot/Services/Recruitment/Application/RecruitmentOverviewPublisher.cs @@ -0,0 +1,93 @@ +using DiscordBot.Settings; +using DiscordBot.Services.Recruitment.Infrastructure.Discord; +using DiscordBot.Services.Recruitment.Presentation; + +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 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; + + public DateTimeOffset? NextDueAt + { + get + { + 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; + var times = new List(); + if (_overviewDirtyAt is { } dirty) + times.Add(Max(dirty.AddSeconds(5), (receipt.LastEditedAt ?? DateTimeOffset.MinValue).AddSeconds(30))); + return times.Count == 0 ? null : times.Min(); + } + } + private static DateTimeOffset Max(DateTimeOffset a, DateTimeOffset b) => a > b ? a : b; + + public async Task RefreshAsync(CancellationToken ct) + { + if (!feedAvailable()) return; + var receipt = State.Overview; + var health = healthSnapshot(); + var render = health.Text(); + if (render != _overviewRender) _overviewDirtyAt ??= Now; + if (!(NextDueAt <= 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; + 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; } + _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 = RecruitmentFaultClassifier.From(ex, Now, receipt.Fault); + Save(); // Cosmetic failures never change listing eligibility or evidence. + } + } +} diff --git a/DiscordBot/Services/Recruitment/Application/RecruitmentSetup.cs b/DiscordBot/Services/Recruitment/Application/RecruitmentSetup.cs new file mode 100644 index 00000000..39de88d3 --- /dev/null +++ b/DiscordBot/Services/Recruitment/Application/RecruitmentSetup.cs @@ -0,0 +1,131 @@ +using DiscordBot.Services.Recruitment.Domain.Policies; +using DiscordBot.Services.Recruitment.Infrastructure.Discord; +using System.IO; +using System.Security.Cryptography; +using DiscordBot.Settings; +using Newtonsoft.Json; + +namespace DiscordBot.Services.Recruitment; + +/// 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; + 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, 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))) + { + 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) + { + if (target is not null && target != purpose) continue; + if (!repair && !retryHeld && receipt.Fault is { } fault && !fault.IsDue(now)) continue; + try + { + await ReconcileForumAsync(purpose, receipt, repair, ct); + receipt.Fault = null; + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not IOException) + { + receipt.Fault = RecruitmentFaultClassifier.From(ex, now, receipt.Fault); + } + _store.Save(); + Progress?.Invoke(); + } + } + + 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 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)) + { + PersistIntent(receipt, plan, repair); + live = await PublishAsync(purpose, receipt, live, plan, ct); + } + 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) + { + 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); + } + 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 => + { + 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/Application/ReferenceReconciler.cs b/DiscordBot/Services/Recruitment/Application/ReferenceReconciler.cs new file mode 100644 index 00000000..1b8440ed --- /dev/null +++ b/DiscordBot/Services/Recruitment/Application/ReferenceReconciler.cs @@ -0,0 +1,129 @@ +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/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/PortfolioHostnamePolicy.cs b/DiscordBot/Services/Recruitment/Domain/Analysis/PortfolioHostnamePolicy.cs new file mode 100644 index 00000000..0061b5ee --- /dev/null +++ b/DiscordBot/Services/Recruitment/Domain/Analysis/PortfolioHostnamePolicy.cs @@ -0,0 +1,36 @@ +using System.Globalization; + +namespace DiscordBot.Services.Recruitment.Domain.Analysis; + +/// DNS/IDN and public-suffix decisions over supplied rules; no resource or filesystem access. +internal static class PortfolioHostnamePolicy +{ + private static readonly IdnMapping Idn = new(); + public static bool TryNormalize(string? input, IReadOnlySet rules, IReadOnlySet tlds, 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; + // An unlisted TLD is not an Internet registry boundary (e.g. a local search suffix). + if (!tlds.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/Domain/Analysis/RecruitmentContent.cs b/DiscordBot/Services/Recruitment/Domain/Analysis/RecruitmentContent.cs new file mode 100644 index 00000000..710f4306 --- /dev/null +++ b/DiscordBot/Services/Recruitment/Domain/Analysis/RecruitmentContent.cs @@ -0,0 +1,211 @@ +using DiscordBot.Utils; +using System.Text.RegularExpressions; + +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, string? ReuseUrl = null); +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 +{ + private static string Bounded(string text) => text[..Math.Min(text.Length, 8000)]; + 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. + public static PaymentObservation ReadPayment(string? content) + { + if (content is null) return new("unknown", "", CompensationKind.Unavailable); + var text = Bounded(content); + try + { + var sentences = Sentences().Split(text); + // 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)); + 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), CompensationKind.MonetaryPromise) : new("no concrete rate or budget found", ""); + } + catch (RegexMatchTimeoutException) { return new("unknown", "", CompensationKind.Unavailable); } + } + + 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) + { + 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; + var candidates = new List(); + if (content is null) return new(false, media, candidates); + var text = Bounded(content); + try + { + foreach (Match match in Links().Matches(text)) + { + if (!TryLink(match.Value, out var uri) || RecruitmentPortfolioUrls.Read(uri!) is not { } identity) continue; + 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]; + candidates.Add(new(identity.DisplayUrl, identity.Host, identity.Specific, + (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); + } + catch (RegexMatchTimeoutException) { return new(false, media, candidates); } + } + + public static string Portfolio(string? content, IReadOnlyList? attachments = null) + => PortfolioSummary(ReadPortfolio(content, attachments)); + + public static string PortfolioSummary(PortfolioEvidence facts) + { + 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 null; } + } + + 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; + 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) && !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. */ } + 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; + } + + 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) => 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; + + + [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(?: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|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(); + [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(?: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(?: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(); + [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(@"\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/Domain/Analysis/RecruitmentPortfolioUrls.cs b/DiscordBot/Services/Recruitment/Domain/Analysis/RecruitmentPortfolioUrls.cs new file mode 100644 index 00000000..a9dde433 --- /dev/null +++ b/DiscordBot/Services/Recruitment/Domain/Analysis/RecruitmentPortfolioUrls.cs @@ -0,0 +1,77 @@ +using DiscordBot.Utils; +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 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 (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 => 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") + 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 (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] : + 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/Domain/Analysis/RecruitmentSignals.cs b/DiscordBot/Services/Recruitment/Domain/Analysis/RecruitmentSignals.cs new file mode 100644 index 00000000..c96d355c --- /dev/null +++ b/DiscordBot/Services/Recruitment/Domain/Analysis/RecruitmentSignals.cs @@ -0,0 +1,122 @@ +using DiscordBot.Services.Recruitment.Domain.Analysis; +using DiscordBot.Utils; +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) + => 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); + 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; + } + 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 = 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", 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 = analysis.Duration; + if (duration is null) Unknown("duration", "Job Duration"); + else Add("duration", "Job Duration", DurationSeverity(duration, paid && compensationRed), + duration.Length > 0 ? "Window Stated" : "Timing Unclear", duration); + 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", PortfolioSeverity(supported is not null, any || portfolio.MediaSample || !paid), + PortfolioValue(supported is not null, portfolio.MediaSample, any), supported?.Url); + } + var length = analysis.NormalizedLength; + var dm = analysis.DmRequest; + if (dm is null) Unknown("dm-requests", "DM Requests"); + 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", 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 = 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" && + uri.Host is "cdn.discordapp.com" or "media.discordapp.net" && value.Length <= 500 ? value : null; +} 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/Domain/Models/RecruitmentFault.cs b/DiscordBot/Services/Recruitment/Domain/Models/RecruitmentFault.cs new file mode 100644 index 00000000..17876deb --- /dev/null +++ b/DiscordBot/Services/Recruitment/Domain/Models/RecruitmentFault.cs @@ -0,0 +1,7 @@ +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; +} 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/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/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/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. + }; + } +} 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/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/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/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/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/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/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 new file mode 100644 index 00000000..e91be0b0 --- /dev/null +++ b/DiscordBot/Services/Recruitment/Infrastructure/Discord/DiscordInventoryReader.cs @@ -0,0 +1,61 @@ +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 new file mode 100644 index 00000000..bb5f6dbe --- /dev/null +++ b/DiscordBot/Services/Recruitment/Infrastructure/Discord/DiscordRecruitmentAdapter.cs @@ -0,0 +1,329 @@ +using DiscordBot.Services.Recruitment.Infrastructure.Discord; +using DiscordBot.Utils; +using System.IO; +using System.Collections.Concurrent; +using Discord.Net; +using Discord.Rest; +using Discord.WebSocket; +using DiscordBot.Settings; + +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 TimeProvider _time; + private readonly DiscordReferencePublisher _referencePublisher; + private readonly Dictionary _forums = new(); + private readonly ConcurrentDictionary _expectedForums = new(); + private readonly Dictionary _threads = 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) { _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); + _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."); + private SocketGuild Guild => _client.GetGuild(_settings.GuildId) ?? throw new InvalidOperationException("Configured guild is unavailable."); + + 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 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) + { + 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."); + 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 == 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; + return access.EmbedLinks; + } + + 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; + } + + private async Task MemberAsync(ulong userId, CancellationToken ct) + { + try { return await _client.Rest.GetGuildUserAsync(_settings.GuildId, userId, Options(ct)); } + catch (HttpException ex) when (ex.DiscordCode == DiscordErrorCode.UnknownMember) { return null; } + } + + 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)); + } + + 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; + } + + 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)); + } + + 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) + { + using var requestScope = RecruitmentRequests.BeginScope(); + var forum = await ForumAsync(forumId, ct); + var snapshot = Snapshot(forum); + _forums[forumId] = (snapshot, forum); + _expectedForums[forumId] = snapshot; + return snapshot; + } + + 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) + { + 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, + 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) + { + 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; + // 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); + return Snapshot(forum); + } + + 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) + { + using var requestScope = RecruitmentRequests.BeginScope(); + if (!refresh && _threads.TryGetValue(threadId, out var cached)) return cached; + 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) { _threads.Remove(threadId); return null; } + } + + private async Task SnapshotAsync(RestThreadChannel thread, CancellationToken ct) + { + var ownerId = ((IThreadChannel)thread).OwnerId; + var id = thread.Id.ToString(); + if (!_observedFacts.TryGetValue(id, out var facts)) + { + 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 is null ? null : !string.IsNullOrEmpty(user.DisplayAvatarId), + 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)), + RecruitmentSignals.SafeIdentity(user?.DisplayAvatarId, 200)); + // Missing starters remain retryable. Bound cached initial facts independently of retained history. + if (starter is not null) + { + 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, + 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; + } + + 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; + } + + 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) + { + var permissions = channel is RestThreadChannel listingThread + ? _client.GetGuild(_settings.GuildId)?.CurrentUser?.GetPermissions(_client.GetGuild(_settings.GuildId)?.GetChannel(listingThread.ParentChannelId)) + : null; + 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 }; + return await StandardMessageWriter.UpsertAsync(channel, botUserId, messageId, recovering, since, message, beforeWrite, ct); + } + + 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."); + 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."); + } + 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)); + 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, 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."); + var options = Options(ct); options.AuditLogReason = "Recruitment: " + DiscordDisplayText.Literal(reason); + await live.DeleteAsync(options); + InvalidateThread(thread.Id); + } + 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, 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 && 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/DiscordReferencePublisher.cs b/DiscordBot/Services/Recruitment/Infrastructure/Discord/DiscordReferencePublisher.cs new file mode 100644 index 00000000..6fdba382 --- /dev/null +++ b/DiscordBot/Services/Recruitment/Infrastructure/Discord/DiscordReferencePublisher.cs @@ -0,0 +1,67 @@ +using Discord.Net; +using Discord.Rest; +using Discord.WebSocket; +using DiscordBot.Settings; +using static DiscordBot.Services.Recruitment.RecruitmentRequests; + +namespace DiscordBot.Services.Recruitment.Infrastructure.Discord; + +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 = 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, + 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. 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) + { + using var requestScope = RecruitmentRequests.BeginScope(); + 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 = 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 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; + // 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 = 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)) + throw new InvalidOperationException("Reference pin/lock was not confirmed."); + invalidateFacts(threadId); + return true; + } +} diff --git a/DiscordBot/Services/Recruitment/Infrastructure/Discord/DiscordResponsePins.cs b/DiscordBot/Services/Recruitment/Infrastructure/Discord/DiscordResponsePins.cs new file mode 100644 index 00000000..55480d43 --- /dev/null +++ b/DiscordBot/Services/Recruitment/Infrastructure/Discord/DiscordResponsePins.cs @@ -0,0 +1,46 @@ +using static DiscordBot.Services.Recruitment.RecruitmentRequests; + +namespace DiscordBot.Services.Recruitment.Infrastructure.Discord; + +internal static class DiscordResponsePins +{ + 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; + 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 (!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"); + 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/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/DiscordCompatibilityTransport.cs b/DiscordBot/Services/Recruitment/Infrastructure/Discord/Transport/DiscordCompatibilityTransport.cs new file mode 100644 index 00000000..bae14f99 --- /dev/null +++ b/DiscordBot/Services/Recruitment/Infrastructure/Discord/Transport/DiscordCompatibilityTransport.cs @@ -0,0 +1,112 @@ +using Discord.Net.Rest; +using System.IO; +using System.Net; +using System.Text; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace DiscordBot.Services.Recruitment; + +/// 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(); + 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 DiscordCompatibilityTransport(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 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; + 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); + // 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 async Task SendAsync(string method, string endpoint, IReadOnlyDictionary multipart, + CancellationToken cancelToken, bool headerOnly, string reason, + 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 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 + // 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); + } + 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/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/RecruitmentMessageTransport.cs b/DiscordBot/Services/Recruitment/Infrastructure/Discord/Transport/RecruitmentMessageTransport.cs new file mode 100644 index 00000000..10be6bba --- /dev/null +++ b/DiscordBot/Services/Recruitment/Infrastructure/Discord/Transport/RecruitmentMessageTransport.cs @@ -0,0 +1,95 @@ +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<(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) + { + 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/Infrastructure/Discord/Transport/RecruitmentRequests.cs b/DiscordBot/Services/Recruitment/Infrastructure/Discord/Transport/RecruitmentRequests.cs new file mode 100644 index 00000000..f149ee41 --- /dev/null +++ b/DiscordBot/Services/Recruitment/Infrastructure/Discord/Transport/RecruitmentRequests.cs @@ -0,0 +1,34 @@ +using Discord.Net.Rest; + +namespace DiscordBot.Services.Recruitment; + +/// Opt-in retry behavior lives only for the enclosing Discord operation, including SDK queue dispatch. +internal static class RecruitmentRequests +{ + private static readonly DiscordRetryDeadlines SharedDeadlines = new(); + private static readonly AsyncLocal Current = new(); + internal static bool IsScoped => Current.Value is not null; + + internal static IDisposable BeginScope(DiscordRetryDeadlines? deadlines = null) + { + var scope = new RequestScope(deadlines ?? Current.Value?.Deadlines ?? SharedDeadlines, Current.Value); + Current.Value = scope; + return scope; + } + + 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) + { + ct.ThrowIfCancellationRequested(); + (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/V2MessageWriter.cs b/DiscordBot/Services/Recruitment/Infrastructure/Discord/V2MessageWriter.cs new file mode 100644 index 00000000..695f6192 --- /dev/null +++ b/DiscordBot/Services/Recruitment/Infrastructure/Discord/V2MessageWriter.cs @@ -0,0 +1,99 @@ +using static DiscordBot.Services.Recruitment.RecruitmentRequests; +using Discord.Net; +using Newtonsoft.Json.Linq; + +namespace DiscordBot.Services.Recruitment.Infrastructure.Discord; + +internal static class V2MessageWriter +{ + // 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 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); + 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); + 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 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) + 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/Infrastructure/Persistence/RecruitmentStateStore.cs b/DiscordBot/Services/Recruitment/Infrastructure/Persistence/RecruitmentStateStore.cs new file mode 100644 index 00000000..2d3746d9 --- /dev/null +++ b/DiscordBot/Services/Recruitment/Infrastructure/Persistence/RecruitmentStateStore.cs @@ -0,0 +1,103 @@ +using DiscordBot.Services.Recruitment.Infrastructure.Persistence; +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; + 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)) + { + // 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) => + 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)); + +} diff --git a/DiscordBot/Services/Recruitment/Presentation/ListingEligibilityText.cs b/DiscordBot/Services/Recruitment/Presentation/ListingEligibilityText.cs new file mode 100644 index 00000000..1d5ba37c --- /dev/null +++ b/DiscordBot/Services/Recruitment/Presentation/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.Presentation; + +internal static 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/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 new file mode 100644 index 00000000..cde80d31 --- /dev/null +++ b/DiscordBot/Services/Recruitment/Presentation/RecruitmentBanner.cs @@ -0,0 +1,73 @@ +using DiscordBot.Utils; +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-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 == 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}: {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]}: {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."; + } + 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 * 252 + 12; + var status = Status(c); + 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)); + // 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 = 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); + 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(); + var bytes = image.ToByteArray(); + if (Cache.Count >= 32) Cache.Clear(); + Cache[key] = bytes; + return bytes.ToArray(); + } + } +} 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/Presentation/RecruitmentPresentation.cs b/DiscordBot/Services/Recruitment/Presentation/RecruitmentPresentation.cs new file mode 100644 index 00000000..11c7ad19 --- /dev/null +++ b/DiscordBot/Services/Recruitment/Presentation/RecruitmentPresentation.cs @@ -0,0 +1,47 @@ +using DiscordBot.Services.Recruitment.Presentation; +using static DiscordBot.Utils.DiscordDisplayText; +using DiscordBot.Settings; +using System.IO; + +namespace DiscordBot.Services.Recruitment; + +public static class RecruitmentPresentation +{ + 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) + { + 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}:** {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."; + } + + public static RecruitmentMessage Staff(RecruitmentState state, Listing post, RecruitmentSettings settings) + { + var text = $"**Recruitment · {post.Status}** · {Literal(post.Title, 100)}\n" + + $"<#{post.Id}> · author `{post.OwnerId}` · {(post.AcceptedAt.HasValue ? "acknowledged" : "unaccepted")}\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}` · {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"; + return new RecruitmentMessage(text + $"||{marker}||", marker, false); + } + +} 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 new file mode 100644 index 00000000..8abc2420 --- /dev/null +++ b/DiscordBot/Services/Recruitment/RecruitmentService.cs @@ -0,0 +1,63 @@ +using Discord.WebSocket; +using DiscordBot.Settings; +using DiscordBot.Services.Recruitment.Application; +using DiscordBot.Services.Recruitment.Runtime; + +namespace DiscordBot.Services.Recruitment; + +/// 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 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; + _gateway = new RecruitmentGateway(client, settings, _discord, _worker); + _worker.Stopped += () => _gateway.Subscribe(false); + } + + private readonly IRecruitmentDiscord _discord; + internal RecruitmentService(BotSettings settings, IRecruitmentDiscord discord, TimeProvider time) + { + _settings = settings; + _discord = discord; + _worker = new RecruitmentWorker(settings, discord, time); + } + + public Task StartAsync() + { + if (_started) return Task.CompletedTask; + _started = true; + _worker.InitializeGateway(_client is null || _client.GetGuild(_settings.GuildId)?.IsConnected == true); + _gateway?.Subscribe(true); + return _worker.StartAsync(); + } + + public async Task StopAsync(CancellationToken ct) + { + _gateway?.Subscribe(false); + await _worker.StopAsync(ct); + } + + 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 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 new file mode 100644 index 00000000..840c6985 --- /dev/null +++ b/DiscordBot/Services/Recruitment/Runtime/RecruitmentHealth.cs @@ -0,0 +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, RecruitmentHealthKind Kind = RecruitmentHealthKind.Unavailable) +{ + public static RecruitmentHealth Starting(DateTimeOffset now) => new("Starting", "Opening recruitment state", now, + 0, 0, 0, false, null, null, Array.Empty()); + +} 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); + } +} 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..7df9b7c7 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,51 @@ 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 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; + + 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) + 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; + } +} + +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..9c554e70 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,20 @@ "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, + "AttemptLimit": 2, + "AttemptWindowMinutes": 60, + "AttemptPauseMinutes": 60, + "PostRetentionMonths": 12, + "AuthorRetentionMonths": 24 }, "UnityHelp": { 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/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; + } + } +} 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); +} 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/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. diff --git a/docs/recruitment.md b/docs/recruitment.md new file mode 100644 index 00000000..c2b0768c --- /dev/null +++ b/docs/recruitment.md @@ -0,0 +1,167 @@ +# 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. + +## 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. +- 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 **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. 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. +- 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, + "AttemptLimit": 2, + "AttemptWindowMinutes": 60, + "AttemptPauseMinutes": 60, + "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. 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. + +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 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. + +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. + +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) + +`/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 + +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` | 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. | +| `/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 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 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 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, 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. + +### 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. + +## 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/`: + +- `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. + +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. + +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) + +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. + +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 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; 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) + +No live bot login, command registration, staging mutation, deployment or remote push is part of local validation. + +### Creation attempts + +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. diff --git a/k8s/dev/bot-config.yaml b/k8s/dev/bot-config.yaml index 1c9f60e7..dc4b0a81 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,21 @@ 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, + "AttemptLimit": 2, + "AttemptWindowMinutes": 60, + "AttemptPauseMinutes": 60, + "PostRetentionMonths": 12, + "AuthorRetentionMonths": 24 }, "UnityHelp": { diff --git a/k8s/prod/bot-config.yaml b/k8s/prod/bot-config.yaml index de4335cd..938afff4 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,20 @@ 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, + "AttemptLimit": 2, + "AttemptWindowMinutes": 60, + "AttemptPauseMinutes": 60, + "PostRetentionMonths": 12, + "AuthorRetentionMonths": 24 }, "UnityHelp": {