From 6c0f8163aa046deab7629616b641b13458d60a3e Mon Sep 17 00:00:00 2001 From: Adam Frisby Date: Mon, 21 Sep 2026 23:03:07 +0000 Subject: [PATCH 1/2] Slack notification provider: Block Kit threads, buttons and verified inbound loop One plugin (codeybox.slack, off by default) implementing the notification contract against the bidirectional foundation: severity-coloured Block Kit rendering with fields, per-work-item threads, question actions as native buttons bound to the work item, Agnes/answer deep links, and chat.update decision loop-close. Host foundation maps native block_actions form bodies after slack-v0 verification and hands landed decisions to interactive render providers; correlation-token format moves to Core so both sides share one source of truth. Tests: 43 new Slack cases (render, thread, update, parse, signed end-to-end incl. tamper/replay/stale rejection); 118+36+444 related cases green; full build warnings-clean; gitleaks clean. semgrep unavailable in this environment (no installer present). CodeyBox-Prompt-Revision: 1 Co-Authored-By: CodeyBox Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CodeyBox.slnx | 1 + docs/extending/interactions.md | 5 + docs/extending/slack-notifications.md | 161 ++++++ plugins/README.md | 1 + .../CodeyBox.SlackPlugin.csproj | 25 + .../CodeyBox.SlackPlugin/SlackApiClient.cs | 165 ++++++ .../CodeyBox.SlackPlugin/SlackBlockKit.cs | 402 +++++++++++++++ .../SlackNotificationProvider.cs | 251 +++++++++ .../SlackPluginOptions.cs | 93 ++++ .../CodeyBox.SlackPlugin/SlackThreadStore.cs | 117 +++++ src/CodeyBox.Api/InteractionEndpoints.cs | 98 +++- src/CodeyBox.Core/NotificationCorrelation.cs | 36 ++ .../InteractionDedup.cs | 21 +- .../SlackInteractionParser.cs | 248 +++++++++ .../ChatNotificationProviderTests.cs | 5 +- tests/CodeyBox.Tests/CodeyBox.Tests.csproj | 1 + .../PluginLayoutConventionTests.cs | 1 + tests/CodeyBox.Tests/SlackBlockKitTests.cs | 78 +++ .../SlackInteractionEndpointsTests.cs | 281 ++++++++++ .../SlackInteractionParserTests.cs | 116 +++++ .../SlackNotificationProviderTests.cs | 481 ++++++++++++++++++ 21 files changed, 2565 insertions(+), 22 deletions(-) create mode 100644 docs/extending/slack-notifications.md create mode 100644 plugins/notifications/CodeyBox.SlackPlugin/CodeyBox.SlackPlugin.csproj create mode 100644 plugins/notifications/CodeyBox.SlackPlugin/SlackApiClient.cs create mode 100644 plugins/notifications/CodeyBox.SlackPlugin/SlackBlockKit.cs create mode 100644 plugins/notifications/CodeyBox.SlackPlugin/SlackNotificationProvider.cs create mode 100644 plugins/notifications/CodeyBox.SlackPlugin/SlackPluginOptions.cs create mode 100644 plugins/notifications/CodeyBox.SlackPlugin/SlackThreadStore.cs create mode 100644 src/CodeyBox.Core/NotificationCorrelation.cs create mode 100644 src/CodeyBox.Notifications/SlackInteractionParser.cs create mode 100644 tests/CodeyBox.Tests/SlackBlockKitTests.cs create mode 100644 tests/CodeyBox.Tests/SlackInteractionEndpointsTests.cs create mode 100644 tests/CodeyBox.Tests/SlackInteractionParserTests.cs create mode 100644 tests/CodeyBox.Tests/SlackNotificationProviderTests.cs diff --git a/CodeyBox.slnx b/CodeyBox.slnx index 9c5028072..d2fb6482e 100644 --- a/CodeyBox.slnx +++ b/CodeyBox.slnx @@ -67,6 +67,7 @@ + diff --git a/docs/extending/interactions.md b/docs/extending/interactions.md index a9b4187b5..02638823a 100644 --- a/docs/extending/interactions.md +++ b/docs/extending/interactions.md @@ -59,6 +59,11 @@ Supported schemes (`Scheme` per provider entry): | `hmac-sha256` | `X-CodeyBox-Signature: sha256=`, `X-CodeyBox-Timestamp` (unix seconds) | env var named by `SigningSecretEnvVar` | | `slack-v0` | `X-Slack-Signature: v0=`, `X-Slack-Request-Timestamp` | Slack signing secret via `SigningSecretEnvVar` | +Slack posts its native `block_actions` form body (`payload={...}`) rather +than the canonical JSON: on a `slack-v0` provider the endpoint maps that +shape after verification (see +[`slack-notifications.md`](slack-notifications.md)). + Header names are overridable per provider (`SignatureHeader`, `TimestampHeader`). Timestamps outside `ReplayWindow` (default 5 minutes) are rejected as replays. Secrets come from the credential chain diff --git a/docs/extending/slack-notifications.md b/docs/extending/slack-notifications.md new file mode 100644 index 000000000..5f5813101 --- /dev/null +++ b/docs/extending/slack-notifications.md @@ -0,0 +1,161 @@ +# Slack notifications (`codeybox.slack`) + +A CodeyBox notification provider plugin for Slack: work-item and fleet +notifications rendered as native Block Kit, follow-ups threaded per work +item, offered actions as buttons that resolve through the verified inbound +endpoint, and landed decisions reflected back into the originating message. +Deep links route the operator to Agnes for live steering — linked, never +reimplemented here. + +One project, one plugin: +`plugins/notifications/CodeyBox.SlackPlugin/`. Off unless an operator +enables it (see below). + +## How it fits together + +``` +rule fires → provider "slack" → chat.postMessage (Block Kit + buttons) + │ + │ operator presses a button + ▼ +Slack POSTs block_actions to the Request URL (form body, signed) + ▼ +POST /webhooks/interactions/slack ← slack-v0 HMAC + replay window + ▼ +existing question store AnswerAsync (no second answer path) + ▼ +original message updated (chat.update with what was decided and by whom) +``` + +Outbound and inbound meet only at the foundation's contracts: the button +`value` this plugin emits is the binding the host's inbound parser accepts, +and both ends are pinned by the same recorded-shape test +(`SlackInteractionParserTests`, `SlackInteractionEndpointsTests`). + +## Slack-side setup + +1. Create a Slack app at https://api.slack.com/apps (from scratch or from + the manifest below) and install it to the workspace. +2. **OAuth & Permissions → Bot Token Scopes**: add `chat:write`. Copy the + **Bot User OAuth Token** (`xoxb-…`) into the credential chain (see below). +3. Invite the bot to the channel (`/invite @botname`) — or post to a channel + the bot is already in. +4. **Interactivity & Shortcuts → Interactivity**: on, with Request URL + `https:///webhooks/interactions/slack`. Slack retries until + the URL answers; the host verifies every delivery and answers replays + with `{status: "duplicate"}` without touching state. +5. **Basic Information → App Credentials**: copy the **Signing Secret** into + the credential chain for the inbound verifier. + +Minimal app manifest (JSON) equivalent: + +```json +{ + "display_information": { "name": "CodeyBox" }, + "oauth_config": { "scopes": { "bot": ["chat:write"] } }, + "settings": { + "interactivity": { + "is_enabled": true, + "request_url": "https://HOST/webhooks/interactions/slack" + } + } +} +``` + +## CodeyBox configuration + +The plugin loads only when it is both allowlisted and enabled, like every +plugin (see [`plugins.md`](plugins.md)): + +```json +{ + "CodeyBox": { + "Plugins": { + "Allowlist": ["codeybox.slack"], + "Enabled": ["codeybox.slack"], + "codeybox.slack": { + "Enabled": true, + "BotTokenEnvVar": "CODEYBOX_SLACK_BOT_TOKEN", + "DefaultChannel": "C012345", + "AgnesBaseUrl": "https://agnes.example.invalid" + } + }, + "Notifications": { + "Rules": [ + { "Condition": "operator_question", "Providers": ["slack"] } + ], + "Interactions": { + "Enabled": true, + "Providers": [ + { + "Provider": "slack", + "Scheme": "slack-v0", + "SigningSecretEnvVar": "CODEYBOX_SLACK_SIGNING_SECRET", + "ReplayWindow": "00:05:00", + "AllowedChannels": ["C012345"], + "AllowedUsers": [] + } + ] + } + } + } +} +``` + +Credentials and verification secrets come from the credential chain +(environment variables named above), never configuration files: + +| Secret | Env var (configurable name) | What it is | +|---|---|---| +| Bot token | `CODEYBOX_SLACK_BOT_TOKEN` | `xoxb-…` from OAuth & Permissions | +| Signing secret | `CODEYBOX_SLACK_SIGNING_SECRET` | From Basic Information → App Credentials | + +Connecting the integration is itself the grant (see +[`interactions.md`](interactions.md)): any member of the connected channel +may approve from it once the signature verifies. Narrow with +`AllowedChannels` (exact channel IDs) and optionally `AllowedUsers` (exact +platform user IDs). + +## Inbound exposure + +- **Buttons mode** (default) needs Slack to reach the host: expose + `POST /webhooks/interactions/slack` at a public HTTPS URL and set it as + the app's Request URL. +- **Outbound-only deployments** (no inbound path) set + `"ActionsMode": "Links"`: questions render with *Answer here* / + *Open in Agnes* link buttons only, so a prompt is never unanswerable. + Leave `Interactions.Enabled` off entirely in that case — nothing inbound + is exposed. + +## Behaviour notes + +- **Threads**: the first notification for a work item posts top-level; its + timestamp becomes the thread root and every follow-up for the same work + item replies in that thread, so a long-running item reads as one + conversation. Fleet notifications (no work item bound) post top-level. + Thread state is bounded (10 000 entries, 24 h lifetime by default) and + kept in memory — a restart starts new threads rather than failing. +- **Severity and fields** render natively: attachment colour + (`good`/`warning`/`danger`), an emoji header, section text, and up to 10 + structured fields. Over-long text truncates with a marker; buttons whose + binding would exceed Slack's 2000-character value budget degrade to the + answer links instead of posting an unresolvable button. +- **Loop-close**: after an answer lands, the originating message is updated + to `Decided: — by slack: ()`, via `chat.update` + when the bot token is available and via Slack's `response_url` in any + case. Both are best-effort — a delivery failure never affects the work + item. +- **Agnes links**: `AgnesBaseUrl` supplies an *Open in Agnes* button per + work item (`{AgnesBaseUrl}/workitems/{id}`); leave it empty to omit the + link. The *Answer here* link points at CodeyBox's own questions page and + appears whenever the notification carries one. + +## Verification + +- `dotnet test --filter "FullyQualifiedName~Slack"` — outbound rendering, + threading, decision updates, native-payload parsing, and the signed + end-to-end loop (answer-once, tamper/replay rejection, stale-button 409, + capability declaration). +- No live-workspace test exists: Slack has no sandbox API and CI cannot + hold workspace credentials, so the suite runs against the recorded + `block_actions` envelope in `SlackInteractionParserTests` instead. diff --git a/plugins/README.md b/plugins/README.md index 4b4ef7b97..41e4028c2 100644 --- a/plugins/README.md +++ b/plugins/README.md @@ -40,6 +40,7 @@ sit alongside them rather than buried among them. | `auditors-secrets` | Auditors scanning for leaked secrets and credentials | | `auditors-static-analysis` | Auditors running static analysis (compiler warnings, analysers) | | `credentials` | Secret providers and credential brokers (e.g. `CodeyBox.InfisicalPlugin`) | +| `notifications` | Chat/push notification providers (`INotificationProvider`, e.g. `CodeyBox.SlackPlugin`) | | `quota` | Quota probes, reset notifiers, and quota usage telemetry (e.g. `CodeyBox.OpencodeGoQuotaPlugin`, `CodeyBox.QuotaResetNotifier`, `CodeyBox.StatisticsPlugin`) | | `telemetry` | Metric samplers (`IMetricSampler`) outside the quota domain | | `test-runners` | Test-execution plugins (`ITestRunnerAuditor`, e.g. `CodeyBox.DotnetTestRunnerPlugin`) | diff --git a/plugins/notifications/CodeyBox.SlackPlugin/CodeyBox.SlackPlugin.csproj b/plugins/notifications/CodeyBox.SlackPlugin/CodeyBox.SlackPlugin.csproj new file mode 100644 index 000000000..a4dfd2c4b --- /dev/null +++ b/plugins/notifications/CodeyBox.SlackPlugin/CodeyBox.SlackPlugin.csproj @@ -0,0 +1,25 @@ + + + + net10.0 + enable + enable + + + + + + + + + + + + + + + + + + + diff --git a/plugins/notifications/CodeyBox.SlackPlugin/SlackApiClient.cs b/plugins/notifications/CodeyBox.SlackPlugin/SlackApiClient.cs new file mode 100644 index 000000000..9d1d6976e --- /dev/null +++ b/plugins/notifications/CodeyBox.SlackPlugin/SlackApiClient.cs @@ -0,0 +1,165 @@ +using System.Net.Http.Headers; +using System.Text; +using System.Text.Json; + +namespace CodeyBox.SlackPlugin; + +/// +/// Typed transport failure from the Slack Web API: the request never +/// completed (DNS, connection, TLS). Routine API-level outcomes +/// (ok:false, HTTP status, timeouts) stay as +/// values; only a dead transport throws, so the provider can log it as an +/// error while still swallowing it per the notification contract. +/// +internal sealed class SlackApiException : Exception +{ + public string ErrorCode { get; } + + public SlackApiException(string errorCode, string message, Exception? inner = null) + : base(message, inner) + { + ErrorCode = errorCode; + } +} + +/// +/// Thin transport over the Slack Web API (chat.postMessage / +/// chat.update). The bot token travels per call and is never stored +/// or logged; routine failures surface as +/// values (the provider logs and swallows, per the notification contract), +/// a dead transport throws , and cancellation +/// propagates. +/// +internal sealed class SlackApiClient +{ + private static readonly JsonSerializerOptions JsonOpts = new() + { + DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull, + }; + + private readonly HttpClient _http; + + public SlackApiClient(HttpClient http) + { + _http = http; + } + + public sealed record PostResult(bool Ok, string? Channel, string? Ts, string? Error); + + public async Task PostMessageAsync( + string botToken, + string channel, + Dictionary payload, + string? threadTs, + TimeSpan timeout, + CancellationToken ct) + { + payload["channel"] = channel; + if (!string.IsNullOrWhiteSpace(threadTs)) + payload["thread_ts"] = threadTs; + + return await SendAsync(botToken, "https://slack.com/api/chat.postMessage", payload, timeout, ct); + } + + public async Task UpdateMessageAsync( + string botToken, + string channel, + string messageTs, + string fallbackText, + List blocks, + TimeSpan timeout, + CancellationToken ct) + { + var payload = new Dictionary + { + ["channel"] = channel, + ["ts"] = messageTs, + ["text"] = fallbackText, + ["blocks"] = blocks, + }; + return await SendAsync(botToken, "https://slack.com/api/chat.update", payload, timeout, ct); + } + + private async Task SendAsync( + string botToken, + string url, + Dictionary payload, + TimeSpan timeout, + CancellationToken ct) + { + using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct); + timeoutCts.CancelAfter(timeout); + + using var request = new HttpRequestMessage(HttpMethod.Post, url); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", botToken); + var json = JsonSerializer.Serialize(payload, JsonOpts); + request.Content = new StringContent(json, Encoding.UTF8); + request.Content.Headers.ContentType = new MediaTypeHeaderValue("application/json") { CharSet = "utf-8" }; + + HttpResponseMessage response; + try + { + response = await _http.SendAsync(request, timeoutCts.Token); + } + catch (OperationCanceledException) + { + // Our own timeout is the only OCE this scope converts to a + // result; a requested shutdown — or anyone else's cancellation + // — propagates so work stops promptly. + if (!timeoutCts.IsCancellationRequested || ct.IsCancellationRequested) + throw; + return new PostResult(false, null, null, "timeout"); + } + catch (Exception ex) when (ex is HttpRequestException or IOException) + { + throw new SlackApiException("transport", "Slack Web API request did not complete.", ex); + } + + using (response) + { + string body; + try + { + body = await response.Content.ReadAsStringAsync(timeoutCts.Token); + } + catch (OperationCanceledException) + { + if (!timeoutCts.IsCancellationRequested || ct.IsCancellationRequested) + throw; + throw new SlackApiException("timeout", "Slack Web API response read timed out."); + } + catch (Exception ex) when (ex is HttpRequestException or IOException) + { + throw new SlackApiException("transport", "Slack Web API response could not be read.", ex); + } + + if (!response.IsSuccessStatusCode) + return new PostResult(false, null, null, $"http-{(int)response.StatusCode}"); + + try + { + using var doc = JsonDocument.Parse(body); + var root = doc.RootElement; + var ok = root.TryGetProperty("ok", out var okProp) && okProp.ValueKind == JsonValueKind.True; + if (!ok) + { + var error = root.TryGetProperty("error", out var err) && err.ValueKind == JsonValueKind.String + ? err.GetString() + : "unknown_error"; + return new PostResult(false, null, null, error); + } + var channel = root.TryGetProperty("channel", out var ch) && ch.ValueKind == JsonValueKind.String + ? ch.GetString() + : null; + var ts = root.TryGetProperty("ts", out var tsProp) && tsProp.ValueKind == JsonValueKind.String + ? tsProp.GetString() + : null; + return new PostResult(true, channel, ts, null); + } + catch (JsonException) + { + return new PostResult(false, null, null, "malformed_response"); + } + } + } +} diff --git a/plugins/notifications/CodeyBox.SlackPlugin/SlackBlockKit.cs b/plugins/notifications/CodeyBox.SlackPlugin/SlackBlockKit.cs new file mode 100644 index 000000000..30c7ad917 --- /dev/null +++ b/plugins/notifications/CodeyBox.SlackPlugin/SlackBlockKit.cs @@ -0,0 +1,402 @@ +using System.Text.Json; +using CodeyBox.Core; + +namespace CodeyBox.SlackPlugin; + +/// +/// Pure Block Kit rendering for Slack notifications: severity, summary and +/// fields rendered as native blocks rather than a dumped text blob. All +/// decision logic lives here as input→output functions; the provider only +/// transports the result. Bounds come from +/// so every cap is operator-configurable, never a literal at the sink. +/// +internal static class SlackBlockKit +{ + /// Action id carried by every CodeyBox answer button. The + /// inbound parser only honours this id; anything else is not ours. + public const string AnswerActionId = "codeybox_answer"; + + /// Slack button value limit in characters. + public const int MaxButtonValueChars = 2000; + + /// Slack header-block text limit in characters. + public const int MaxHeaderChars = 150; + + /// Slack button-label limit in characters. + public const int MaxLabelChars = 75; + + private static readonly JsonSerializerOptions CodecJson = new() + { + PropertyNamingPolicy = null, + }; + + /// Attachment colour token for a severity. + public static string ColorFor(NotificationSeverity severity) => severity switch + { + NotificationSeverity.Critical => "danger", + NotificationSeverity.Warning => "warning", + _ => "good", + }; + + /// Emoji prefix for a severity (Slack colon syntax). + public static string EmojiFor(NotificationSeverity severity) => severity switch + { + NotificationSeverity.Critical => ":rotating_light:", + NotificationSeverity.Warning => ":warning:", + _ => ":information_source:", + }; + + /// Escape Slack mrkdwn control characters in untrusted text. + public static string EscapeMrkdwn(string? text) + { + if (string.IsNullOrEmpty(text)) + return string.Empty; + return text.Replace("&", "&").Replace("<", "<").Replace(">", ">"); + } + + /// Truncate to a character budget, marking the cut. + public static string Truncate(string text, int maxChars) + { + if (maxChars < 1) + return string.Empty; + if (text.Length <= maxChars) + return text; + const string marker = "… (truncated)"; + if (maxChars <= marker.Length) + return text[..maxChars]; + return text[..(maxChars - marker.Length)] + marker; + } + + /// Encode one offered action as the button value. + /// Returns null when the binding does not fit Slack's value budget — + /// the caller must keep the question answerable another way (answer / + /// Agnes links) rather than posting a button that cannot resolve. + public static string? EncodeButtonValue(string workItemId, string questionId, string answer, string correlationToken) + { + var value = JsonSerializer.Serialize( + new Dictionary + { + ["w"] = workItemId, + ["q"] = questionId, + ["a"] = answer, + ["c"] = correlationToken, + }, + CodecJson); + return value.Length > MaxButtonValueChars ? null : value; + } + + /// Decode a button value back to its binding. Mirrors the + /// contract the host-side inbound parser honours; both sides are pinned + /// by the same recorded-shape test. + public static bool TryDecodeButtonValue(string? value, out string workItemId, out string questionId, out string answer, out string correlationToken) + { + workItemId = questionId = answer = correlationToken = string.Empty; + if (string.IsNullOrEmpty(value) || value.Length > MaxButtonValueChars) + return false; + try + { + using var doc = JsonDocument.Parse(value); + var root = doc.RootElement; + if (root.ValueKind != JsonValueKind.Object) + return false; + if (!root.TryGetProperty("w", out var w) || w.ValueKind != JsonValueKind.String + || !root.TryGetProperty("q", out var q) || q.ValueKind != JsonValueKind.String + || !root.TryGetProperty("a", out var a) || a.ValueKind != JsonValueKind.String + || !root.TryGetProperty("c", out var c) || c.ValueKind != JsonValueKind.String) + return false; + workItemId = w.GetString() ?? string.Empty; + questionId = q.GetString() ?? string.Empty; + answer = a.GetString() ?? string.Empty; + correlationToken = c.GetString() ?? string.Empty; + return !string.IsNullOrEmpty(workItemId) + && !string.IsNullOrEmpty(questionId) + && !string.IsNullOrEmpty(answer); + } + catch (JsonException) + { + return false; + } + } + + /// Build the chat.postMessage body for a notification. + /// Returns the payload plus the work item id when one could be bound + /// (used for thread routing and decision updates). + public static (Dictionary Payload, string? WorkItemId) BuildMessage( + Notification notification, + SlackPluginOptions options) + { + var workItemId = BindWorkItemId(notification); + var color = ColorFor(notification.Severity); + var emoji = EmojiFor(notification.Severity); + var body = Truncate(notification.Body ?? notification.Summary ?? notification.Title, options.MaxTextChars); + + var fallback = $"{emoji} [{notification.Severity}] {notification.Title}"; + if (!string.IsNullOrWhiteSpace(notification.AnswerUrl)) + fallback += $"\nAnswer here: {notification.AnswerUrl}"; + + var blocks = new List(); + + blocks.Add(new Dictionary + { + ["type"] = "header", + ["text"] = new Dictionary + { + ["type"] = "plain_text", + ["text"] = Truncate($"{emoji} {notification.Title}", MaxHeaderChars), + ["emoji"] = true, + }, + }); + + blocks.Add(new Dictionary + { + ["type"] = "section", + ["text"] = new Dictionary + { + ["type"] = "mrkdwn", + ["text"] = EscapeMrkdwn(body), + }, + }); + + var fields = RenderFields(notification, options.MaxFields); + if (fields.Count > 0) + { + blocks.Add(new Dictionary + { + ["type"] = "section", + ["fields"] = fields, + }); + } + + var actionsRendered = RenderActions(blocks, notification, workItemId, options); + + RenderLinkButtons(blocks, notification, workItemId, options, actionsRendered); + + blocks.Add(new Dictionary + { + ["type"] = "context", + ["elements"] = new object[] + { + new Dictionary + { + ["type"] = "mrkdwn", + ["text"] = EscapeMrkdwn($"CodeyBox · {notification.ConditionId} · {notification.Timestamp.UtcDateTime:yyyy-MM-dd HH:mm} UTC"), + }, + }, + }); + + var payload = new Dictionary + { + ["text"] = fallback, + ["unfurl_links"] = false, + ["unfurl_media"] = false, + ["attachments"] = new object[] + { + new Dictionary + { + ["color"] = color, + ["fallback"] = fallback, + ["blocks"] = blocks, + }, + }, + }; + return (payload, workItemId); + } + + /// Build the chat.update blocks showing what was decided + /// and by whom, for the visible loop-close after an answer lands. + /// already reads as the finished + /// line (e.g. Decided: … — by …); it is escaped, never trusted. + public static List BuildDecidedBlocks(string title, string decisionSummary, string conditionId) + { + var safeSummary = Truncate(decisionSummary, 3000); + return + [ + new Dictionary + { + ["type"] = "header", + ["text"] = new Dictionary + { + ["type"] = "plain_text", + ["text"] = Truncate($":white_check_mark: {title}", MaxHeaderChars), + ["emoji"] = true, + }, + }, + new Dictionary + { + ["type"] = "section", + ["text"] = new Dictionary + { + ["type"] = "mrkdwn", + ["text"] = $"*Decided:* {EscapeMrkdwn(safeSummary)}", + }, + }, + new Dictionary + { + ["type"] = "context", + ["elements"] = new object[] + { + new Dictionary + { + ["type"] = "mrkdwn", + ["text"] = EscapeMrkdwn($"CodeyBox · {conditionId}"), + }, + }, + }, + ]; + } + + /// Deep link from a notification to the Agnes front end for the + /// owning work item. Agnes steers; this integration only links. Returns + /// null when no base URL is configured or no work item is bound. + public static string? AgnesWorkItemUrl(SlackPluginOptions options, string? workItemId) + { + if (string.IsNullOrWhiteSpace(options.AgnesBaseUrl) || string.IsNullOrWhiteSpace(workItemId)) + return null; + if (!Uri.TryCreate(options.AgnesBaseUrl, UriKind.Absolute, out var baseUri)) + return null; + return $"{baseUri.ToString().TrimEnd('/')}/workitems/{Uri.EscapeDataString(workItemId)}"; + } + + private static string? BindWorkItemId(Notification notification) + { + if (notification.Actions is { Count: > 0 }) + { + var first = notification.Actions[0]; + if (!string.IsNullOrWhiteSpace(first.WorkItemId)) + return first.WorkItemId; + } + if (!string.IsNullOrWhiteSpace(notification.CorrelationToken) + && NotificationCorrelation.TryParse(notification.CorrelationToken, out var workItemId, out _)) + return workItemId; + return null; + } + + private static List RenderFields(Notification notification, int maxFields) + { + var fields = new List(); + if (notification.Fields is null || maxFields <= 0) + return fields; + foreach (var (key, value) in notification.Fields) + { + if (fields.Count >= maxFields) + break; + var name = Truncate(key, 100); + var val = Truncate(value, 500); + if (string.IsNullOrWhiteSpace(name)) + continue; + fields.Add(new Dictionary + { + ["type"] = "mrkdwn", + ["text"] = $"*{EscapeMrkdwn(name)}*\n{EscapeMrkdwn(val)}", + }); + } + return fields; + } + + private static bool RenderActions( + List blocks, + Notification notification, + string? workItemId, + SlackPluginOptions options) + { + if (notification.Actions is not { Count: > 0 }) + return false; + if (options.ActionsMode != SlackActionsMode.Buttons) + return false; + if (options.MaxActions <= 0 || string.IsNullOrWhiteSpace(workItemId)) + return false; + + var buttons = new List(); + var rendered = 0; + foreach (var action in notification.Actions) + { + if (rendered >= options.MaxActions) + break; + if (!string.Equals(action.WorkItemId, workItemId, StringComparison.Ordinal)) + continue; + var correlation = NotificationCorrelation.TokenFor(action.WorkItemId, action.QuestionId); var value = EncodeButtonValue(action.WorkItemId, action.QuestionId, action.Value, correlation); + if (value is null) + continue; + buttons.Add(new Dictionary + { + ["type"] = "button", + ["action_id"] = AnswerActionId, + ["text"] = new Dictionary + { + ["type"] = "plain_text", + ["text"] = Truncate(action.Label, MaxLabelChars), + ["emoji"] = true, + }, + ["value"] = value, + ["style"] = rendered == 0 ? "primary" : null, + }); + rendered++; + } + if (buttons.Count == 0) + return false; + + foreach (var chunk in buttons.Chunk(5)) + { + blocks.Add(new Dictionary + { + ["type"] = "actions", + ["block_id"] = $"codeybox_actions_{blocks.Count}", + ["elements"] = chunk, + }); + } + return true; + } + + private static void RenderLinkButtons( + List blocks, + Notification notification, + string? workItemId, + SlackPluginOptions options, + bool actionsRendered) + { + var links = new List(); + if (!string.IsNullOrWhiteSpace(notification.AnswerUrl) + && Uri.TryCreate(notification.AnswerUrl, UriKind.Absolute, out _)) + { + links.Add(new Dictionary + { + ["type"] = "button", + ["action_id"] = "codeybox_answer_link", + ["text"] = new Dictionary + { + ["type"] = "plain_text", + ["text"] = actionsRendered ? "Answer here" : "Answer in CodeyBox", + ["emoji"] = true, + }, + ["url"] = notification.AnswerUrl, + }); + } + var agnesUrl = AgnesWorkItemUrl(options, workItemId); + if (agnesUrl is not null) + { + links.Add(new Dictionary + { + ["type"] = "button", + ["action_id"] = "codeybox_agnes_link", + ["text"] = new Dictionary + { + ["type"] = "plain_text", + ["text"] = "Open in Agnes", + ["emoji"] = true, + }, + ["url"] = agnesUrl, + }); + } + if (links.Count == 0) + return; + foreach (var chunk in links.Chunk(5)) + { + blocks.Add(new Dictionary + { + ["type"] = "actions", + ["block_id"] = $"codeybox_links_{blocks.Count}", + ["elements"] = chunk, + }); + } + } +} diff --git a/plugins/notifications/CodeyBox.SlackPlugin/SlackNotificationProvider.cs b/plugins/notifications/CodeyBox.SlackPlugin/SlackNotificationProvider.cs new file mode 100644 index 000000000..0d81a6472 --- /dev/null +++ b/plugins/notifications/CodeyBox.SlackPlugin/SlackNotificationProvider.cs @@ -0,0 +1,251 @@ +using CodeyBox.Core; +using CodeyBox.PluginSdk; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; + +namespace CodeyBox.SlackPlugin; + +/// +/// Slack notification provider: work-item and fleet notifications rendered +/// as native Block Kit (severity colour, fields, action buttons), follow-ups +/// threaded per work item, and decisions reflected back into the originating +/// message. +/// +/// One project, one plugin, against the bidirectional foundation: +/// outbound posts via the Slack Web API; inbound button presses arrive at the +/// host's verified /webhooks/interactions/slack endpoint (Slack +/// slack-v0 HMAC over v0:{timestamp}:{raw_body} with a replay +/// window — the host refuses anything unverified) and resolve through the +/// existing question store. No second answer path exists here. +/// +/// Off unless an operator enables it: Enabled defaults to false +/// and the bot token resolves from the environment (never config files). +/// A delivery failure never affects a work item — it is logged and +/// swallowed, per the provider contract. +/// +[CodeyBoxPlugin( + id: SlackNotificationProvider.PluginId, + displayName: "CodeyBox: Slack Notifications", + minHostApiVersion: "1.3")] +public sealed class SlackNotificationProvider : INotificationProvider, IPluginInitializer +{ + public const string PluginId = "codeybox.slack"; + + private readonly IConfiguration _configuration; + private readonly IHttpClientFactory _httpClients; + private readonly ILogger _log; + private readonly TimeProvider _clock; + private readonly SlackThreadStore _threads; + + private ILogger _pluginLog = Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance; + + public string Name => "slack"; + + /// Slack carries authenticated interactions: offered actions + /// render as native buttons resolving through the verified inbound + /// endpoint, and landed decisions update the original message. + public bool SupportsInteractions => true; + + public SlackNotificationProvider( + IConfiguration configuration, + IHttpClientFactory httpClients, + ILogger logger, + TimeProvider? clock = null) + { + _configuration = configuration; + _httpClients = httpClients; + _log = logger; + _clock = clock ?? TimeProvider.System; + _threads = new SlackThreadStore(clock: _clock); + } + + internal SlackNotificationProvider( + IConfiguration configuration, + HttpClient httpClient, + ILogger logger, + TimeProvider? clock = null, + SlackThreadStore? threads = null, + IHttpClientFactory? httpClients = null) + { + _configuration = configuration; + _httpClients = httpClients ?? new SingleClientFactory(httpClient); + _log = logger; + _clock = clock ?? TimeProvider.System; + _threads = threads ?? new SlackThreadStore(clock: _clock); + } + + public Task InitializeAsync(PluginContext context, CancellationToken ct) + { + _pluginLog = context.Logger; + var opts = BindOptions(); + if (!opts.Enabled) + { + _pluginLog.LogInformation("Slack notifications are disabled (codeybox.slack Enabled=false)."); + return Task.CompletedTask; + } + if (string.IsNullOrWhiteSpace(opts.DefaultChannel)) + _pluginLog.LogWarning("Slack notifications are enabled with no DefaultChannel; notifications without an explicit recipient channel will be skipped."); + if (string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable(opts.BotTokenEnvVar))) + _pluginLog.LogWarning("Slack notifications are enabled but env var '{EnvVar}' is not set; delivery will be skipped until the bot token is provided.", opts.BotTokenEnvVar); + else + _pluginLog.LogInformation("Slack notifications enabled."); + return Task.CompletedTask; + } + + public async Task SendAsync(Notification notification, CancellationToken ct) + { + var opts = BindOptions(); + if (!opts.Enabled) + return; + + var token = Environment.GetEnvironmentVariable(opts.BotTokenEnvVar); + if (string.IsNullOrEmpty(token)) + { + _log.LogWarning("SlackNotificationProvider: env var '{EnvVar}' is not set; skipping notification {Condition}", + opts.BotTokenEnvVar, notification.ConditionId); + return; + } + + var channel = ResolveChannel(notification, opts); + if (channel is null) + { + _log.LogWarning("SlackNotificationProvider: no channel for notification {Condition}; set a recipient or DefaultChannel", + notification.ConditionId); + return; + } + + var (payload, workItemId) = SlackBlockKit.BuildMessage(notification, opts); + + string? threadTs = null; + if (opts.ThreadByWorkItem && workItemId is not null) + threadTs = _threads.ThreadRootFor(channel, workItemId); + + var timeout = opts.PostTimeoutSeconds >= 1 + ? TimeSpan.FromSeconds(opts.PostTimeoutSeconds) + : TimeSpan.FromSeconds(15); + + SlackApiClient.PostResult result; + try + { + var client = _httpClients.CreateClient(); + var api = new SlackApiClient(client); + result = await api.PostMessageAsync(token, channel, payload, threadTs, timeout, ct); + } + catch (OperationCanceledException) + { + // Any cancellation reaching this layer is foreign: the client's + // own timeout already surfaces as a result, so rethrow + // unconditionally and let shutdown proceed. + throw; + } + catch (Exception ex) + { + _log.LogError(ex, "SlackNotificationProvider: delivery failed for condition {Condition}", + notification.ConditionId); + return; + } + + if (!result.Ok) + { + _log.LogWarning("SlackNotificationProvider: chat.postMessage failed ({Error}) for condition {Condition}", + result.Error, notification.ConditionId); + return; + } + + var postedChannel = result.Channel ?? channel; + var postedTs = result.Ts; + if (workItemId is not null && postedTs is not null) + { + if (opts.ThreadByWorkItem && threadTs is null) + _threads.RememberThreadRoot(postedChannel, workItemId, postedTs); + if (!string.IsNullOrWhiteSpace(notification.CorrelationToken)) + _threads.RememberMessage(notification.CorrelationToken, postedChannel, postedTs); + } + + _log.LogInformation("SlackNotificationProvider: posted notification {Condition} ({Severity}) to channel", + notification.ConditionId, notification.Severity); + } + + /// Reflect a landed decision back into the channel by updating + /// the message that carried the buttons. Best-effort: failures are + /// logged and swallowed so they can never affect the work item. + public async Task UpdateDecisionAsync(Notification notification, string decisionSummary, CancellationToken ct) + { + var opts = BindOptions(); + if (!opts.Enabled) + return; + if (string.IsNullOrWhiteSpace(notification.CorrelationToken)) + return; + + var identity = _threads.MessageFor(notification.CorrelationToken); + if (identity is null) + return; + + var token = Environment.GetEnvironmentVariable(opts.BotTokenEnvVar); + if (string.IsNullOrEmpty(token)) + { + _log.LogWarning("SlackNotificationProvider: env var '{EnvVar}' is not set; skipping decision update for {Condition}", + opts.BotTokenEnvVar, notification.ConditionId); + return; + } + + var blocks = SlackBlockKit.BuildDecidedBlocks(notification.Title, decisionSummary, notification.ConditionId); + var fallback = $"Decided: {decisionSummary}"; + var timeout = opts.PostTimeoutSeconds >= 1 + ? TimeSpan.FromSeconds(opts.PostTimeoutSeconds) + : TimeSpan.FromSeconds(15); + + try + { + var client = _httpClients.CreateClient(); + var api = new SlackApiClient(client); + var result = await api.UpdateMessageAsync(token, identity.Value.Channel, identity.Value.Ts, fallback, blocks, timeout, ct); + if (!result.Ok) + { + _log.LogWarning("SlackNotificationProvider: chat.update failed ({Error}) for condition {Condition}", + result.Error, notification.ConditionId); + } + } + catch (OperationCanceledException) + { + // Any cancellation reaching this layer is foreign: the client's + // own timeout already surfaces as a result, so rethrow + // unconditionally and let shutdown proceed. + throw; + } + catch (Exception ex) + { + _log.LogError(ex, "SlackNotificationProvider: decision update failed for condition {Condition}", + notification.ConditionId); + } + } + + internal SlackPluginOptions BindOptions() + { + var opts = new SlackPluginOptions(); + _configuration.GetSection($"CodeyBox:Plugins:{PluginId}").Bind(opts); + return opts; + } + + private static string? ResolveChannel(Notification notification, SlackPluginOptions opts) + { + // Slack posts to one channel per call; the first non-empty + // recipient wins. + if (notification.Recipients is { Count: > 0 }) + { + foreach (var recipient in notification.Recipients) + { + if (!string.IsNullOrWhiteSpace(recipient)) + return recipient.Trim(); + } + } + return string.IsNullOrWhiteSpace(opts.DefaultChannel) ? null : opts.DefaultChannel.Trim(); + } + + private sealed class SingleClientFactory : IHttpClientFactory + { + private readonly HttpClient _client; + public SingleClientFactory(HttpClient client) => _client = client; + public HttpClient CreateClient(string name) => _client; + } +} diff --git a/plugins/notifications/CodeyBox.SlackPlugin/SlackPluginOptions.cs b/plugins/notifications/CodeyBox.SlackPlugin/SlackPluginOptions.cs new file mode 100644 index 000000000..1a46f3cdb --- /dev/null +++ b/plugins/notifications/CodeyBox.SlackPlugin/SlackPluginOptions.cs @@ -0,0 +1,93 @@ +namespace CodeyBox.SlackPlugin; + +/// +/// Operator configuration for the Slack notification plugin, bound from +/// CodeyBox:Plugins:codeybox.slack:. Everything operational is a knob +/// here — no literals in source. Secrets are never set in config: only the +/// name of the environment variable holding the bot token is +/// configured; the token itself travels the credential chain (environment). +/// +/// +/// +/// { +/// "CodeyBox": { +/// "Plugins": { +/// "codeybox.slack": { +/// "Enabled": true, +/// "BotTokenEnvVar": "CODEYBOX_SLACK_BOT_TOKEN", +/// "DefaultChannel": "C012345", +/// "AgnesBaseUrl": "https://agnes.example.invalid" +/// } +/// } +/// } +/// } +/// +/// +/// +public sealed class SlackPluginOptions +{ + /// Master switch. Default false: the plugin is inert until an + /// operator enables it (and allowlists it per the plugin gates). + public bool Enabled { get; set; } + + /// Environment variable holding the Slack bot token + /// (xoxb-…). Never set the token directly in config. + public string BotTokenEnvVar { get; set; } = "CODEYBOX_SLACK_BOT_TOKEN"; + + /// Channel ID used when a notification carries no explicit + /// recipient. Empty means "no default" — notifications without a + /// resolvable channel are skipped with a warning. + public string DefaultChannel { get; set; } = string.Empty; + + /// Public base URL of the Agnes front end, e.g. + /// https://agnes.example.invalid. Supplies the "Open in Agnes" + /// deep link (Agnes steers; this integration only links). + public string AgnesBaseUrl { get; set; } = string.Empty; + + /// How offered actions render: Buttons posts native + /// Block Kit buttons (requires inbound exposure of + /// /webhooks/interactions/slack to Slack); Links renders + /// answer/Agnes links only, for outbound-only deployments where no + /// Request URL is reachable. Default Buttons. + public SlackActionsMode ActionsMode { get; set; } = SlackActionsMode.Buttons; + + /// Post follow-up notifications for the same work item as + /// threaded replies so a long-running item reads as one conversation. + /// Default true. + public bool ThreadByWorkItem { get; set; } = true; + + /// Per-call timeout in seconds for Slack Web API calls. + /// Must be >= 1. Default 15. + public int PostTimeoutSeconds { get; set; } = 15; + + /// Maximum characters kept from the notification body before + /// truncation. Must be >= 1. Default 3000 (Slack section text limit). + public int MaxTextChars { get; set; } = 3000; + + /// Maximum structured fields rendered per message. Must be + /// >= 0. Default 10 (Slack section field limit). + public int MaxFields { get; set; } = 10; + + /// Maximum action buttons rendered per message. Must be + /// >= 0. Default 10. Surplus actions stay answerable via the + /// answer/Agnes links. + public int MaxActions { get; set; } = 10; + + /// How long thread-root and message-identity entries live. + /// Default 24 hours. + public TimeSpan EntryLifetime { get; set; } = TimeSpan.FromHours(24); + + /// Upper bound on tracked threads/messages. Default 10 000. + public int MaxEntries { get; set; } = 10_000; +} + +/// How offered values render. +public enum SlackActionsMode +{ + /// Native Block Kit buttons resolving through the verified + /// inbound endpoint. Needs Slack to reach the host. + Buttons, + /// Link buttons to the answer URL / Agnes only. For + /// deployments with no inbound exposure. + Links, +} diff --git a/plugins/notifications/CodeyBox.SlackPlugin/SlackThreadStore.cs b/plugins/notifications/CodeyBox.SlackPlugin/SlackThreadStore.cs new file mode 100644 index 000000000..b237e99b7 --- /dev/null +++ b/plugins/notifications/CodeyBox.SlackPlugin/SlackThreadStore.cs @@ -0,0 +1,117 @@ +using System.Collections.Concurrent; + +namespace CodeyBox.SlackPlugin; + +/// +/// Slack message identity remembered by the plugin so follow-ups thread and +/// decisions land in place: +/// +/// thread root per (channel, work item) — follow-up notifications for +/// the same work item post as threaded replies, so a long-running item reads +/// as one conversation; +/// message identity per correlation token — the decision update +/// (chat.update) targets the message that carried the buttons. +/// +/// Thread-safe, bounded, and time-expired. Inject +/// in tests for deterministic expiry. +/// +internal sealed class SlackThreadStore +{ + private readonly ConcurrentDictionary _entries = new(StringComparer.Ordinal); + private readonly TimeProvider _clock; + private readonly TimeSpan _lifetime; + private readonly int _maxEntries; + + private sealed record Entry(string Channel, string Ts, DateTimeOffset StoredAt); + + public SlackThreadStore(TimeProvider? clock = null, TimeSpan? lifetime = null, int maxEntries = 10_000) + { + _clock = clock ?? TimeProvider.System; + _lifetime = lifetime ?? TimeSpan.FromHours(24); + _maxEntries = maxEntries >= 1 ? maxEntries : 10_000; + } + + private static string ThreadKey(string channel, string workItemId) => $"thread:{channel}:{workItemId}"; + + private static string MessageKey(string correlationToken) => $"msg:{correlationToken}"; + + public void RememberThreadRoot(string channel, string workItemId, string threadTs) + { + if (string.IsNullOrWhiteSpace(channel) || string.IsNullOrWhiteSpace(workItemId) || string.IsNullOrWhiteSpace(threadTs)) + return; + Store(ThreadKey(channel, workItemId), new Entry(channel, threadTs, _clock.GetUtcNow())); + } + + public string? ThreadRootFor(string channel, string workItemId) + { + if (string.IsNullOrWhiteSpace(channel) || string.IsNullOrWhiteSpace(workItemId)) + return null; + return Lookup(ThreadKey(channel, workItemId))?.Ts; + } + + public void RememberMessage(string correlationToken, string channel, string messageTs) + { + if (string.IsNullOrWhiteSpace(correlationToken) || string.IsNullOrWhiteSpace(channel) || string.IsNullOrWhiteSpace(messageTs)) + return; + Store(MessageKey(correlationToken), new Entry(channel, messageTs, _clock.GetUtcNow())); + } + + public (string Channel, string Ts)? MessageFor(string correlationToken) + { + if (string.IsNullOrWhiteSpace(correlationToken)) + return null; + var entry = Lookup(MessageKey(correlationToken)); + return entry is null ? null : (entry.Channel, entry.Ts); + } + + private void Store(string key, Entry entry) + { + EvictExpired(); + if (_entries.Count >= _maxEntries) + EvictOldest(); + _entries[key] = entry; + } + + private Entry? Lookup(string key) + { + if (!_entries.TryGetValue(key, out var entry)) + return null; + if (_clock.GetUtcNow() - entry.StoredAt >= _lifetime) + { + _entries.TryRemove(key, out _); + return null; + } + return entry; + } + + private void EvictExpired() + { + if (_entries.Count < _maxEntries) + { + var now = _clock.GetUtcNow(); + foreach (var (key, entry) in _entries) + { + if (now - entry.StoredAt >= _lifetime) + _entries.TryRemove(key, out _); + } + return; + } + foreach (var (key, entry) in _entries) + { + var now = _clock.GetUtcNow(); + if (now - entry.StoredAt >= _lifetime) + _entries.TryRemove(key, out _); + if (_entries.Count < _maxEntries) + break; + } + if (_entries.Count >= _maxEntries) + EvictOldest(); + } + + private void EvictOldest() + { + var oldest = _entries.OrderBy(kv => kv.Value.StoredAt).FirstOrDefault(); + if (oldest.Key is not null) + _entries.TryRemove(oldest.Key, out _); + } +} diff --git a/src/CodeyBox.Api/InteractionEndpoints.cs b/src/CodeyBox.Api/InteractionEndpoints.cs index 271b17c6b..159be2371 100644 --- a/src/CodeyBox.Api/InteractionEndpoints.cs +++ b/src/CodeyBox.Api/InteractionEndpoints.cs @@ -73,6 +73,7 @@ private static async Task HandleInteractionAsync( HttpRequest httpRequest, IOptionsMonitor interactions, IEnumerable verifiers, + IEnumerable renderProviders, IWorkItemStore store, IWorkItemQuestionStore? questionStore, ITaskQueue queue, @@ -142,21 +143,47 @@ private static async Task HandleInteractionAsync( return Results.Unauthorized(); } - InteractionPayload? payload; + InteractionPayload? payload = null; + string? validationError; try { payload = JsonSerializer.Deserialize(bodyBytes, PayloadJsonOpts); + validationError = payload is null + ? "malformed interaction payload" + : ValidatePayload(payload); } catch (JsonException) { - return Results.BadRequest(new { error = "malformed interaction payload" }); + validationError = "malformed interaction payload"; } - if (payload is null) - return Results.BadRequest(new { error = "malformed interaction payload" }); - var validationError = ValidatePayload(payload); + // Slack posts native `block_actions` form bodies (payload={...}) to + // the app's Request URL rather than the canonical JSON shape. When + // the canonical parse fails on a slack-v0 provider, map the native + // shape before rejecting — verification already passed either way. + if (validationError is not null && IsSlackScheme(providerOpts.Scheme)) + { + if (SlackInteractionParser.TryParse(bodyBytes, out var canonical, out _) + && canonical is not null) + { + payload = new InteractionPayload + { + InteractionId = canonical.InteractionId, + WorkItemId = canonical.WorkItemId, + QuestionId = canonical.QuestionId, + Answer = canonical.Answer, + User = new InteractionUser { UserId = canonical.UserId, Login = canonical.Login }, + ChannelId = canonical.ChannelId, + ResponseUrl = canonical.ResponseUrl, + CorrelationToken = canonical.CorrelationToken, + }; + validationError = ValidatePayload(payload); + } + } if (validationError is not null) return Results.BadRequest(new { error = validationError }); + if (payload is null) + return Results.BadRequest(new { error = "malformed interaction payload" }); // Replay guard: platforms retry, so the same interaction delivered // twice answers once. The claim happens before any state change. @@ -239,9 +266,70 @@ await QuestionAnswerPipeline.AnswerAndResumeAsync( opts.ResponseUpdateTimeoutSeconds >= 1 ? opts.ResponseUpdateTimeoutSeconds : 10); await TryUpdateOriginalMessageAsync(payload.ResponseUrl, redactedAnswer, answeredBy, httpClients, log, responseTimeout, ct); + // Provider-owned loop-close runs last so its final state wins: an + // interactive provider (e.g. Slack via chat.update) replaces the + // plain-text response_url replacement with its rich decided state. + // Best-effort likewise — the answer already landed above. + await TryProviderDecisionUpdateAsync( + providerOpts.Provider, renderProviders, payload, question.QuestionText, + redactedAnswer, answeredBy, log, ct); + return Results.Ok(new { status = "answered", questionState = "answered" }); } + private static bool IsSlackScheme(string? scheme) => + string.Equals(scheme, "slack-v0", StringComparison.OrdinalIgnoreCase); + + /// Hand a landed decision to the matching render provider when + /// it carries interactions itself. Notification-only providers (the + /// default) need nothing here — the response_url round-trip above is + /// their loop-close. Never throws: the provider contract is + /// log-and-swallow by definition. + private static async Task TryProviderDecisionUpdateAsync( + string providerName, + IEnumerable renderProviders, + InteractionPayload payload, + string? questionText, + string answer, + string answeredBy, + ILogger log, + CancellationToken ct) + { + INotificationProvider? target = null; + foreach (var candidate in renderProviders) + { + if (string.Equals(candidate.Name, providerName, StringComparison.OrdinalIgnoreCase)) + { + target = candidate; + break; + } + } + if (target is null || !target.SupportsInteractions) + return; + try + { + var notification = new Notification + { + ConditionId = "operator_question", + Title = string.IsNullOrWhiteSpace(questionText) ? $"Input needed: {payload.QuestionId}" : questionText, + Severity = NotificationSeverity.Information, + Timestamp = DateTimeOffset.UtcNow, + CorrelationToken = payload.CorrelationToken, + }; + await target.UpdateDecisionAsync(notification, $"Decided: {answer} — by {answeredBy}", ct); + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + throw; + } + catch (Exception ex) + { + log.LogWarning(ex, + "Interactions: provider '{Provider}' decision update failed; decision already recorded", + providerName); + } + } + private static string? ValidatePayload(InteractionPayload payload) { if (string.IsNullOrWhiteSpace(payload.InteractionId) || payload.InteractionId.Length > 128) diff --git a/src/CodeyBox.Core/NotificationCorrelation.cs b/src/CodeyBox.Core/NotificationCorrelation.cs new file mode 100644 index 000000000..5791776fe --- /dev/null +++ b/src/CodeyBox.Core/NotificationCorrelation.cs @@ -0,0 +1,36 @@ +namespace CodeyBox.Core; + +/// +/// Correlation token binding a notification to the work item question being +/// decided. The token is echoed back by inbound interactions so a stale +/// message cannot answer a superseded question. Format: +/// {workItemId}:{questionId} — exact, parseable, and safe to echo +/// back over the wire. +/// +/// Lives in Core (not Notifications) so out-of-process contributors — +/// notification provider plugins, which may only reference the SDK surface — +/// bind and parse the same token as the host's inbound endpoint. One source +/// of truth: do not re-implement this format elsewhere. +/// +public static class NotificationCorrelation +{ + /// Build the token for one work item question. + public static string TokenFor(string workItemId, string questionId) => + $"{workItemId}:{questionId}"; + + /// Split a token back into work item and question ids. +/// Returns false for null, empty, or malformed tokens. + public static bool TryParse(string? token, out string workItemId, out string questionId) + { + workItemId = string.Empty; + questionId = string.Empty; + if (string.IsNullOrEmpty(token)) + return false; + var split = token.IndexOf(':'); + if (split <= 0 || split == token.Length - 1) + return false; + workItemId = token[..split]; + questionId = token[(split + 1)..]; + return true; + } +} diff --git a/src/CodeyBox.Notifications/InteractionDedup.cs b/src/CodeyBox.Notifications/InteractionDedup.cs index 8929e3f29..ce0639497 100644 --- a/src/CodeyBox.Notifications/InteractionDedup.cs +++ b/src/CodeyBox.Notifications/InteractionDedup.cs @@ -73,23 +73,14 @@ private void EvictExpired(bool forceOldest = false) public static class NotificationInteractionHelper { /// Correlation token binding a notification to one question. - /// Exact, parseable, and safe to echo back over the wire. + /// Exact, parseable, and safe to echo back over the wire. Delegates to + /// (Core) so provider plugins bind + /// the same token the inbound endpoint parses. public static string CorrelationTokenFor(string workItemId, string questionId) => - $"{workItemId}:{questionId}"; + NotificationCorrelation.TokenFor(workItemId, questionId); - public static bool TryParseCorrelationToken(string? token, out string workItemId, out string questionId) - { - workItemId = string.Empty; - questionId = string.Empty; - if (string.IsNullOrEmpty(token)) - return false; - var split = token.IndexOf(':'); - if (split <= 0 || split == token.Length - 1) - return false; - workItemId = token[..split]; - questionId = token[(split + 1)..]; - return true; - } + public static bool TryParseCorrelationToken(string? token, out string workItemId, out string questionId) => + NotificationCorrelation.TryParse(token, out workItemId, out questionId); /// Builds an actionable notification for an open question. /// is the public base URL used to form diff --git a/src/CodeyBox.Notifications/SlackInteractionParser.cs b/src/CodeyBox.Notifications/SlackInteractionParser.cs new file mode 100644 index 000000000..e44cbf7f3 --- /dev/null +++ b/src/CodeyBox.Notifications/SlackInteractionParser.cs @@ -0,0 +1,248 @@ +using System.Net; +using System.Text.Json; + +namespace CodeyBox.Notifications; + +/// +/// Canonical form of one Slack block_actions interaction, mapped onto +/// the fields the generic inbound endpoint resolves through the question +/// store. Produced only from payloads that already passed +/// — verification precedes parsing, +/// never the reverse. +/// +public sealed record SlackCanonicalInteraction +{ + public required string InteractionId { get; init; } + public required string WorkItemId { get; init; } + public required string QuestionId { get; init; } + public required string Answer { get; init; } + public required string UserId { get; init; } + public string? Login { get; init; } + public string? ChannelId { get; init; } + public string? ResponseUrl { get; init; } + public required string CorrelationToken { get; init; } +} + +/// +/// Maps a real Slack interactive payload (application/x-www-form-urlencoded +/// body of the shape payload={...block_actions JSON...}, as Slack POSTs +/// to the app's Request URL) onto the canonical interaction the endpoint +/// answers exactly once via the question store. +/// +/// Button value contract (shared with the Slack provider +/// plugin): compact JSON {"w": workItemId, "q": questionId, "a": answer, +/// "c": correlationToken}, max 2000 chars. Only +/// action_id == "codeybox_answer" is honoured — anything else is not a +/// CodeyBox button and fails closed. +/// +/// The interaction id derives from Slack's trigger_id, which is +/// stable across Slack's own retries of the same press, so the endpoint's +/// idempotency claim answers each press exactly once without touching the +/// question store on replay. +/// +public static class SlackInteractionParser +{ + /// Action id the Slack provider stamps on answer buttons. + public const string AnswerActionId = "codeybox_answer"; + + /// Slack button value budget in characters. + public const int MaxButtonValueChars = 2000; + + /// Parse the raw request body into a canonical interaction. +/// Returns false with a fixed-vocabulary reason when the body is not a +/// CodeyBox Slack action. Callers must verify the signature first. + public static bool TryParse(byte[] rawBody, out SlackCanonicalInteraction? interaction, out string failureReason) + { + interaction = null; + failureReason = string.Empty; + + string bodyText; + try + { + bodyText = System.Text.Encoding.UTF8.GetString(rawBody); + } + catch (Exception) + { + failureReason = "body is not valid UTF-8"; + return false; + } + + var json = ExtractPayloadJson(bodyText); + if (json is null) + { + failureReason = "body carries no Slack payload"; + return false; + } + + JsonDocument doc; + try + { + doc = JsonDocument.Parse(json); + } + catch (JsonException) + { + failureReason = "Slack payload is not valid JSON"; + return false; + } + + using (doc) + { + return TryMap(doc.RootElement, out interaction, out failureReason); + } + } + + private static string? ExtractPayloadJson(string bodyText) + { + var text = bodyText.Trim(); + if (text.StartsWith("payload=", StringComparison.Ordinal)) + { + var encoded = text["payload=".Length..]; + var amp = encoded.IndexOf('&'); + if (amp >= 0) + encoded = encoded[..amp]; + try + { + return WebUtility.UrlDecode(encoded); + } + catch (Exception) + { + return null; + } + } + if (text.StartsWith("{", StringComparison.Ordinal)) + return text; + return null; + } + + private static bool TryMap(JsonElement root, out SlackCanonicalInteraction? interaction, out string failureReason) + { + interaction = null; + failureReason = string.Empty; + + if (root.ValueKind != JsonValueKind.Object) + { + failureReason = "Slack payload is not an object"; + return false; + } + + if (!root.TryGetProperty("type", out var typeProp) + || typeProp.ValueKind != JsonValueKind.String + || !string.Equals(typeProp.GetString(), "block_actions", StringComparison.Ordinal)) + { + failureReason = "unsupported Slack interaction type"; + return false; + } + + if (!root.TryGetProperty("trigger_id", out var triggerProp) + || triggerProp.ValueKind != JsonValueKind.String + || string.IsNullOrWhiteSpace(triggerProp.GetString())) + { + failureReason = "Slack payload has no trigger_id"; + return false; + } + var interactionId = $"slack:{triggerProp.GetString()}"; + + if (!root.TryGetProperty("actions", out var actionsProp) + || actionsProp.ValueKind != JsonValueKind.Array + || actionsProp.GetArrayLength() == 0) + { + failureReason = "Slack payload carries no actions"; + return false; + } + var action = actionsProp[0]; + if (action.ValueKind != JsonValueKind.Object + || !action.TryGetProperty("action_id", out var actionIdProp) + || actionIdProp.ValueKind != JsonValueKind.String + || !string.Equals(actionIdProp.GetString(), AnswerActionId, StringComparison.Ordinal)) + { + failureReason = "not a CodeyBox answer action"; + return false; + } + if (!action.TryGetProperty("value", out var valueProp) + || valueProp.ValueKind != JsonValueKind.String) + { + failureReason = "answer action carries no value"; + return false; + } + var value = valueProp.GetString() ?? string.Empty; + if (value.Length == 0 || value.Length > MaxButtonValueChars) + { + failureReason = "answer value is missing or oversized"; + return false; + } + if (!TryDecodeValue(value, out var workItemId, out var questionId, out var answer, out var correlationToken)) + { + failureReason = "answer value is not a CodeyBox binding"; + return false; + } + + var userId = string.Empty; + string? login = null; + if (root.TryGetProperty("user", out var userProp) && userProp.ValueKind == JsonValueKind.Object) + { + if (userProp.TryGetProperty("id", out var idProp) && idProp.ValueKind == JsonValueKind.String) + userId = idProp.GetString() ?? string.Empty; + if (userProp.TryGetProperty("username", out var nameProp) && nameProp.ValueKind == JsonValueKind.String) + login = nameProp.GetString(); + if (string.IsNullOrEmpty(login) + && userProp.TryGetProperty("name", out var altName) && altName.ValueKind == JsonValueKind.String) + login = altName.GetString(); + } + if (string.IsNullOrWhiteSpace(userId)) + { + failureReason = "Slack payload has no user"; + return false; + } + + string? channelId = null; + if (root.TryGetProperty("channel", out var channelProp) && channelProp.ValueKind == JsonValueKind.Object + && channelProp.TryGetProperty("id", out var channelIdProp) && channelIdProp.ValueKind == JsonValueKind.String) + channelId = channelIdProp.GetString(); + + string? responseUrl = null; + if (root.TryGetProperty("response_url", out var responseProp) && responseProp.ValueKind == JsonValueKind.String) + responseUrl = responseProp.GetString(); + + interaction = new SlackCanonicalInteraction + { + InteractionId = interactionId, + WorkItemId = workItemId, + QuestionId = questionId, + Answer = answer, + UserId = userId, + Login = login, + ChannelId = channelId, + ResponseUrl = responseUrl, + CorrelationToken = correlationToken, + }; + return true; + } + + private static bool TryDecodeValue(string value, out string workItemId, out string questionId, out string answer, out string correlationToken) + { + workItemId = questionId = answer = correlationToken = string.Empty; + try + { + using var doc = JsonDocument.Parse(value); + var root = doc.RootElement; + if (root.ValueKind != JsonValueKind.Object) + return false; + if (!root.TryGetProperty("w", out var w) || w.ValueKind != JsonValueKind.String + || !root.TryGetProperty("q", out var q) || q.ValueKind != JsonValueKind.String + || !root.TryGetProperty("a", out var a) || a.ValueKind != JsonValueKind.String + || !root.TryGetProperty("c", out var c) || c.ValueKind != JsonValueKind.String) + return false; + workItemId = w.GetString() ?? string.Empty; + questionId = q.GetString() ?? string.Empty; + answer = a.GetString() ?? string.Empty; + correlationToken = c.GetString() ?? string.Empty; + return !string.IsNullOrEmpty(workItemId) + && !string.IsNullOrEmpty(questionId) + && !string.IsNullOrEmpty(answer); + } + catch (JsonException) + { + return false; + } + } +} diff --git a/tests/CodeyBox.Tests/ChatNotificationProviderTests.cs b/tests/CodeyBox.Tests/ChatNotificationProviderTests.cs index c934ddd6f..101afc1ba 100644 --- a/tests/CodeyBox.Tests/ChatNotificationProviderTests.cs +++ b/tests/CodeyBox.Tests/ChatNotificationProviderTests.cs @@ -391,7 +391,7 @@ internal sealed class CapturingHttpHandler : HttpMessageHandler { private readonly Func? _responder; - public sealed record CapturedRequest(HttpMethod Method, string Url, string ContentType, string Body); + public sealed record CapturedRequest(HttpMethod Method, string Url, string ContentType, string Body, string Authorization = ""); public List Requests { get; } = new(); @@ -408,7 +408,8 @@ protected override async Task SendAsync( ? string.Empty : await request.Content.ReadAsStringAsync(cancellationToken); var contentType = request.Content?.Headers.ContentType?.ToString() ?? string.Empty; - Requests.Add(new CapturedRequest(request.Method, request.RequestUri!.ToString(), contentType, bodyText)); + var authorization = request.Headers.Authorization?.ToString() ?? string.Empty; + Requests.Add(new CapturedRequest(request.Method, request.RequestUri!.ToString(), contentType, bodyText, authorization)); return _responder is null ? new HttpResponseMessage(HttpStatusCode.OK) diff --git a/tests/CodeyBox.Tests/CodeyBox.Tests.csproj b/tests/CodeyBox.Tests/CodeyBox.Tests.csproj index 981ece5e0..21a469444 100644 --- a/tests/CodeyBox.Tests/CodeyBox.Tests.csproj +++ b/tests/CodeyBox.Tests/CodeyBox.Tests.csproj @@ -60,6 +60,7 @@ +