Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CodeyBox.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@
<Project Path="plugins/work-sync/CodeyBox.ShortcutWorkSyncPlugin/CodeyBox.ShortcutWorkSyncPlugin.csproj" />
<Project Path="plugins/credentials/CodeyBox.InfisicalPlugin/CodeyBox.InfisicalPlugin.csproj" />
<Project Path="plugins/upstream/CodeyBox.GiteaUpstreamPlugin/CodeyBox.GiteaUpstreamPlugin.csproj" />
<Project Path="plugins/notifications/CodeyBox.SlackPlugin/CodeyBox.SlackPlugin.csproj" />
</Folder>
<Folder Name="/tests/">
<Project Path="tests/CodeyBox.Tests/CodeyBox.Tests.csproj" />
Expand Down
5 changes: 5 additions & 0 deletions docs/extending/interactions.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,11 @@ Supported schemes (`Scheme` per provider entry):
| `hmac-sha256` | `X-CodeyBox-Signature: sha256=<hex HMAC over raw body>`, `X-CodeyBox-Timestamp` (unix seconds) | env var named by `SigningSecretEnvVar` |
| `slack-v0` | `X-Slack-Signature: v0=<hex HMAC over "v0:{ts}:{body}">`, `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
Expand Down
161 changes: 161 additions & 0 deletions docs/extending/slack-notifications.md
Original file line number Diff line number Diff line change
@@ -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://<your-host>/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: <answer> — by slack:<userId> (<login>)`, 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.
1 change: 1 addition & 0 deletions plugins/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`) |
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>

<ItemGroup>
<InternalsVisibleTo Include="CodeyBox.Tests" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\..\..\src\CodeyBox.Core\CodeyBox.Core.csproj" />
<ProjectReference Include="..\..\..\src\CodeyBox.PluginSdk\CodeyBox.PluginSdk.csproj" />
</ItemGroup>

<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.7" />
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.7" />
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.7" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.7" />
</ItemGroup>

</Project>
165 changes: 165 additions & 0 deletions plugins/notifications/CodeyBox.SlackPlugin/SlackApiClient.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;

namespace CodeyBox.SlackPlugin;

/// <summary>
/// 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 <see cref="SlackApiClient.PostResult"/>
/// values; only a dead transport throws, so the provider can log it as an
/// error while still swallowing it per the notification contract.
/// </summary>
internal sealed class SlackApiException : Exception
{
public string ErrorCode { get; }

public SlackApiException(string errorCode, string message, Exception? inner = null)
: base(message, inner)
{
ErrorCode = errorCode;
}
}

/// <summary>
/// Thin transport over the Slack Web API (<c>chat.postMessage</c> /
/// <c>chat.update</c>). The bot token travels per call and is never stored
/// or logged; routine failures surface as <see cref="SlackApiClient.PostResult"/>
/// values (the provider logs and swallows, per the notification contract),
/// a dead transport throws <see cref="SlackApiException"/>, and cancellation
/// propagates.
/// </summary>
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<PostResult> PostMessageAsync(
string botToken,
string channel,
Dictionary<string, object?> 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<PostResult> UpdateMessageAsync(
string botToken,
string channel,
string messageTs,
string fallbackText,
List<object?> blocks,
TimeSpan timeout,
CancellationToken ct)
{
var payload = new Dictionary<string, object?>
{
["channel"] = channel,
["ts"] = messageTs,
["text"] = fallbackText,
["blocks"] = blocks,
};
return await SendAsync(botToken, "https://slack.com/api/chat.update", payload, timeout, ct);
}

private async Task<PostResult> SendAsync(
string botToken,
string url,
Dictionary<string, object?> 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");
}
}
}
}
Loading
Loading