@crewhaus/channel-adapter-slack 0.1.4 → 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,105 @@
1
+ /**
2
+ * @crewhaus/channel-adapter-slack — Slack channel adapter for the channel
3
+ * target (Section 12).
4
+ *
5
+ * Implements the `ChannelAdapter` contract:
6
+ * verify(req) — HMAC-SHA256 of `v0:${ts}:${body}` against
7
+ * X-Slack-Signature, with a ±5 min replay window
8
+ * parseInbound(req) — normalises Slack `event_callback` envelopes into
9
+ * a flat `InboundEvent`. Returns null for
10
+ * url_verification, bot self-mentions, and types
11
+ * outside `app_mention` / `message`.
12
+ * sendReply(args) — POSTs `chat.postMessage` with a Bearer token; the
13
+ * api base URL is overridable via constructor opt
14
+ * (or `SLACK_API_BASE_URL` env) so tests can mock it.
15
+ * setTyping(args) — no-op placeholder (Slack has no public typing API).
16
+ *
17
+ * The adapter is idempotency-key-aware (Section 12 design review): it
18
+ * surfaces the inbound `event_id` so the channel-generic gateway can dedup
19
+ * Slack retries without coupling the gateway to Slack-specific terminology.
20
+ *
21
+ * No `@slack/*` SDK dependency — Slack's HTTP API is plain JSON, and Node's
22
+ * built-in `crypto` covers HMAC. Keeps the bundle slim and the trust
23
+ * surface minimal.
24
+ */
25
+ import { CrewhausError } from "@crewhaus/errors";
26
+ export { signSlackBody, verifySlackSignature } from "./verify.js";
27
+ export declare class SlackAdapterError extends CrewhausError {
28
+ readonly name = "SlackAdapterError";
29
+ constructor(message: string, cause?: unknown);
30
+ }
31
+ export type RawRequest = {
32
+ readonly headers: Headers;
33
+ readonly body: string;
34
+ };
35
+ /**
36
+ * Channel-generic inbound event. The adapter normalises every channel's
37
+ * native payload (Slack `event_callback`, future Telegram update, etc.)
38
+ * into this flat shape. The gateway and session-router never see the raw
39
+ * payload — they only see this.
40
+ */
41
+ export type InboundEvent = {
42
+ readonly idempotencyKey: string;
43
+ readonly workspaceId: string;
44
+ readonly channelId: string;
45
+ readonly userId: string;
46
+ readonly threadTs?: string;
47
+ readonly ts: string;
48
+ readonly text: string;
49
+ readonly subtype: "app_mention" | "message";
50
+ };
51
+ /**
52
+ * The result of `parseInbound`. Three shapes:
53
+ * - { kind: "event", event } — a real inbound message to route
54
+ * - { kind: "challenge", challenge } — Slack URL-verification handshake;
55
+ * gateway responds with the challenge string in plaintext
56
+ * - { kind: "skip" } — known-but-uninteresting payload (bot self-mention,
57
+ * non-message event types, etc.); gateway responds 200 and moves on
58
+ */
59
+ export type ParsedInbound = {
60
+ readonly kind: "event";
61
+ readonly event: InboundEvent;
62
+ } | {
63
+ readonly kind: "challenge";
64
+ readonly challenge: string;
65
+ } | {
66
+ readonly kind: "skip";
67
+ };
68
+ export interface ChannelAdapter {
69
+ readonly id: string;
70
+ verify(req: RawRequest): boolean;
71
+ parseInbound(req: RawRequest): ParsedInbound;
72
+ sendReply(args: {
73
+ event: InboundEvent;
74
+ text: string;
75
+ }): Promise<void>;
76
+ setTyping(args: {
77
+ event: InboundEvent;
78
+ }): Promise<void>;
79
+ /**
80
+ * Phase 3 §3.2 — add an emoji reaction to an inbound message as a
81
+ * lightweight status acknowledgement. Conventionally:
82
+ * - "eyes" (👀) on pre-tool / start
83
+ * - "white_check_mark" (✅) on post-tool / success
84
+ * - "warning" (⚠️) on need-approval / error
85
+ * Optional — adapters that don't support reactions (or haven't
86
+ * implemented yet) can leave this undefined and the runtime will
87
+ * skip the hook silently.
88
+ */
89
+ react?(args: {
90
+ event: InboundEvent;
91
+ emoji: string;
92
+ }): Promise<void>;
93
+ }
94
+ export type SlackAdapterConfig = {
95
+ readonly botToken: string;
96
+ readonly signingSecret: string;
97
+ readonly appToken?: string;
98
+ };
99
+ export type SlackAdapterOptions = {
100
+ readonly apiBaseUrl?: string;
101
+ readonly fetch?: typeof fetch;
102
+ readonly now?: () => number;
103
+ readonly selfBotId?: string;
104
+ };
105
+ export declare function createSlackAdapter(config: SlackAdapterConfig, opts?: SlackAdapterOptions): ChannelAdapter;
package/dist/index.js ADDED
@@ -0,0 +1,164 @@
1
+ /**
2
+ * @crewhaus/channel-adapter-slack — Slack channel adapter for the channel
3
+ * target (Section 12).
4
+ *
5
+ * Implements the `ChannelAdapter` contract:
6
+ * verify(req) — HMAC-SHA256 of `v0:${ts}:${body}` against
7
+ * X-Slack-Signature, with a ±5 min replay window
8
+ * parseInbound(req) — normalises Slack `event_callback` envelopes into
9
+ * a flat `InboundEvent`. Returns null for
10
+ * url_verification, bot self-mentions, and types
11
+ * outside `app_mention` / `message`.
12
+ * sendReply(args) — POSTs `chat.postMessage` with a Bearer token; the
13
+ * api base URL is overridable via constructor opt
14
+ * (or `SLACK_API_BASE_URL` env) so tests can mock it.
15
+ * setTyping(args) — no-op placeholder (Slack has no public typing API).
16
+ *
17
+ * The adapter is idempotency-key-aware (Section 12 design review): it
18
+ * surfaces the inbound `event_id` so the channel-generic gateway can dedup
19
+ * Slack retries without coupling the gateway to Slack-specific terminology.
20
+ *
21
+ * No `@slack/*` SDK dependency — Slack's HTTP API is plain JSON, and Node's
22
+ * built-in `crypto` covers HMAC. Keeps the bundle slim and the trust
23
+ * surface minimal.
24
+ */
25
+ import { CrewhausError } from "@crewhaus/errors";
26
+ import { signSlackBody, verifySlackSignature } from "./verify.js";
27
+ export { signSlackBody, verifySlackSignature } from "./verify.js";
28
+ export class SlackAdapterError extends CrewhausError {
29
+ name = "SlackAdapterError";
30
+ constructor(message, cause) {
31
+ super("channel", message, cause);
32
+ }
33
+ }
34
+ const DEFAULT_API_BASE_URL = "https://slack.com/api";
35
+ export function createSlackAdapter(config, opts = {}) {
36
+ const apiBaseUrl = opts.apiBaseUrl ?? process.env["SLACK_API_BASE_URL"] ?? DEFAULT_API_BASE_URL;
37
+ const doFetch = opts.fetch ?? fetch;
38
+ return {
39
+ id: "slack",
40
+ verify(req) {
41
+ return verifySlackSignature({ headers: req.headers, body: req.body, signingSecret: config.signingSecret }, opts.now !== undefined ? { now: opts.now } : {});
42
+ },
43
+ parseInbound(req) {
44
+ let payload;
45
+ try {
46
+ payload = JSON.parse(req.body);
47
+ }
48
+ catch {
49
+ return { kind: "skip" };
50
+ }
51
+ if (typeof payload !== "object" || payload === null)
52
+ return { kind: "skip" };
53
+ const p = payload;
54
+ // URL-verification handshake (Slack sends this once when you point an
55
+ // app's Event Subscription at a new URL).
56
+ if (p["type"] === "url_verification" && typeof p["challenge"] === "string") {
57
+ return { kind: "challenge", challenge: p["challenge"] };
58
+ }
59
+ if (p["type"] !== "event_callback")
60
+ return { kind: "skip" };
61
+ const ev = p["event"];
62
+ if (typeof ev !== "object" || ev === null)
63
+ return { kind: "skip" };
64
+ const e = ev;
65
+ const evType = e["type"];
66
+ if (evType !== "app_mention" && evType !== "message")
67
+ return { kind: "skip" };
68
+ // Skip self/bot loops. `bot_id` is present on Slack's bot-authored
69
+ // messages; `subtype: bot_message` is the older convention.
70
+ if (typeof e["bot_id"] === "string") {
71
+ if (opts.selfBotId === undefined || e["bot_id"] === opts.selfBotId) {
72
+ return { kind: "skip" };
73
+ }
74
+ }
75
+ if (e["subtype"] === "bot_message")
76
+ return { kind: "skip" };
77
+ const idempotencyKey = typeof p["event_id"] === "string" ? p["event_id"] : undefined;
78
+ const workspaceId = typeof p["team_id"] === "string" ? p["team_id"] : undefined;
79
+ const channelId = typeof e["channel"] === "string" ? e["channel"] : undefined;
80
+ const userId = typeof e["user"] === "string" ? e["user"] : undefined;
81
+ const ts = typeof e["ts"] === "string" ? e["ts"] : undefined;
82
+ const text = typeof e["text"] === "string" ? e["text"] : "";
83
+ const threadTs = typeof e["thread_ts"] === "string" ? e["thread_ts"] : undefined;
84
+ if (!idempotencyKey || !workspaceId || !channelId || !userId || !ts) {
85
+ return { kind: "skip" };
86
+ }
87
+ const event = {
88
+ idempotencyKey,
89
+ workspaceId,
90
+ channelId,
91
+ userId,
92
+ ...(threadTs !== undefined ? { threadTs } : {}),
93
+ ts,
94
+ text,
95
+ subtype: evType,
96
+ };
97
+ return { kind: "event", event };
98
+ },
99
+ async sendReply(args) {
100
+ const url = `${apiBaseUrl}/chat.postMessage`;
101
+ const threadKey = args.event.threadTs ?? args.event.ts;
102
+ const res = await doFetch(url, {
103
+ method: "POST",
104
+ headers: {
105
+ "Content-Type": "application/json; charset=utf-8",
106
+ Authorization: `Bearer ${config.botToken}`,
107
+ },
108
+ body: JSON.stringify({
109
+ channel: args.event.channelId,
110
+ thread_ts: threadKey,
111
+ text: args.text,
112
+ }),
113
+ });
114
+ if (!res.ok) {
115
+ throw new SlackAdapterError(`chat.postMessage failed: ${res.status} ${res.statusText}`);
116
+ }
117
+ // Slack returns { ok: false } at HTTP 200 on logical errors; surface that.
118
+ const ct = res.headers.get("content-type") ?? "";
119
+ if (ct.includes("application/json")) {
120
+ const body = (await res.json());
121
+ if (body.ok === false) {
122
+ throw new SlackAdapterError(`chat.postMessage error: ${body.error ?? "unknown"}`);
123
+ }
124
+ }
125
+ },
126
+ async setTyping(_args) {
127
+ // Slack has no public typing-indicator API for bots. No-op for v0;
128
+ // future versions may use the `assistant.threads.setStatus` API
129
+ // (Slack AI Assistant beta) for an approximation.
130
+ },
131
+ // Phase 3 §3.2 — emoji reactions via reactions.add.
132
+ // Slack expects emoji names without colons ("eyes", not ":eyes:").
133
+ async react(args) {
134
+ const emoji = args.emoji.replace(/^:|:$/g, "");
135
+ const url = `${apiBaseUrl}/reactions.add`;
136
+ const res = await doFetch(url, {
137
+ method: "POST",
138
+ headers: {
139
+ "Content-Type": "application/json; charset=utf-8",
140
+ Authorization: `Bearer ${config.botToken}`,
141
+ },
142
+ body: JSON.stringify({
143
+ channel: args.event.channelId,
144
+ timestamp: args.event.ts,
145
+ name: emoji,
146
+ }),
147
+ });
148
+ if (!res.ok) {
149
+ // Reactions are best-effort — log via thrown error but the
150
+ // session-router catches and continues so a flaky API doesn't
151
+ // abort message processing.
152
+ throw new SlackAdapterError(`reactions.add failed: ${res.status} ${res.statusText}`);
153
+ }
154
+ const ct = res.headers.get("content-type") ?? "";
155
+ if (ct.includes("application/json")) {
156
+ const body = (await res.json());
157
+ // "already_reacted" is a benign no-op; don't surface as error.
158
+ if (body.ok === false && body.error !== "already_reacted") {
159
+ throw new SlackAdapterError(`reactions.add error: ${body.error ?? "unknown"}`);
160
+ }
161
+ }
162
+ },
163
+ };
164
+ }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Verify a Slack webhook signature per
3
+ * https://api.slack.com/authentication/verifying-requests-from-slack.
4
+ *
5
+ * Signing-base string is `v0:${timestamp}:${body}`. HMAC-SHA256 with the
6
+ * signing secret, hex-encoded, with `v0=` prefix. Compared via
7
+ * `timingSafeEqual` (constant-time, prevents nibble-leak side channels).
8
+ *
9
+ * The 5-minute timestamp tolerance is the Slack-recommended replay-window
10
+ * cap; without it a captured signed payload could be re-played indefinitely.
11
+ *
12
+ * Returns false on missing headers, malformed timestamp, mismatched
13
+ * signature, or stale/future timestamp.
14
+ */
15
+ export type VerifyArgs = {
16
+ readonly headers: Headers;
17
+ readonly body: string;
18
+ readonly signingSecret: string;
19
+ };
20
+ export type VerifyOptions = {
21
+ readonly now?: () => number;
22
+ readonly toleranceMs?: number;
23
+ };
24
+ export declare function verifySlackSignature(args: VerifyArgs, opts?: VerifyOptions): boolean;
25
+ /**
26
+ * Compute a valid `X-Slack-Signature` for a body — used by smoke tests and
27
+ * the in-repo integration test to construct fixtures the daemon will accept.
28
+ * Production code should NEVER call this — Slack signs requests, not us.
29
+ */
30
+ export declare function signSlackBody(args: {
31
+ body: string;
32
+ timestamp: number;
33
+ signingSecret: string;
34
+ }): string;
package/dist/verify.js ADDED
@@ -0,0 +1,31 @@
1
+ import { createHmac, timingSafeEqual } from "node:crypto";
2
+ const DEFAULT_TOLERANCE_MS = 5 * 60 * 1000;
3
+ export function verifySlackSignature(args, opts = {}) {
4
+ const now = opts.now ?? (() => Date.now());
5
+ const tolerance = opts.toleranceMs ?? DEFAULT_TOLERANCE_MS;
6
+ const timestamp = args.headers.get("x-slack-request-timestamp");
7
+ const signature = args.headers.get("x-slack-signature");
8
+ if (!timestamp || !signature)
9
+ return false;
10
+ const tsNum = Number.parseInt(timestamp, 10);
11
+ if (!Number.isFinite(tsNum))
12
+ return false;
13
+ if (Math.abs(now() - tsNum * 1000) > tolerance)
14
+ return false;
15
+ const base = `v0:${timestamp}:${args.body}`;
16
+ const expected = `v0=${createHmac("sha256", args.signingSecret).update(base).digest("hex")}`;
17
+ const expectedBuf = Buffer.from(expected, "utf8");
18
+ const actualBuf = Buffer.from(signature, "utf8");
19
+ if (expectedBuf.length !== actualBuf.length)
20
+ return false;
21
+ return timingSafeEqual(expectedBuf, actualBuf);
22
+ }
23
+ /**
24
+ * Compute a valid `X-Slack-Signature` for a body — used by smoke tests and
25
+ * the in-repo integration test to construct fixtures the daemon will accept.
26
+ * Production code should NEVER call this — Slack signs requests, not us.
27
+ */
28
+ export function signSlackBody(args) {
29
+ const base = `v0:${args.timestamp}:${args.body}`;
30
+ return `v0=${createHmac("sha256", args.signingSecret).update(base).digest("hex")}`;
31
+ }
package/package.json CHANGED
@@ -1,18 +1,21 @@
1
1
  {
2
2
  "name": "@crewhaus/channel-adapter-slack",
3
- "version": "0.1.4",
3
+ "version": "0.1.5",
4
4
  "type": "module",
5
5
  "description": "Slack channel adapter: webhook signature verification, event parsing, reply-to-thread (Section 12)",
6
- "main": "src/index.ts",
7
- "types": "src/index.ts",
6
+ "main": "dist/index.js",
7
+ "types": "dist/index.d.ts",
8
8
  "exports": {
9
- ".": "./src/index.ts"
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.js"
12
+ }
10
13
  },
11
14
  "scripts": {
12
15
  "test": "bun test src"
13
16
  },
14
17
  "dependencies": {
15
- "@crewhaus/errors": "0.1.4"
18
+ "@crewhaus/errors": "0.1.5"
16
19
  },
17
20
  "license": "Apache-2.0",
18
21
  "author": {
@@ -32,5 +35,5 @@
32
35
  "publishConfig": {
33
36
  "access": "public"
34
37
  },
35
- "files": ["src", "README.md", "LICENSE", "NOTICE"]
38
+ "files": ["dist", "README.md", "LICENSE", "NOTICE"]
36
39
  }
@@ -1,18 +0,0 @@
1
- {
2
- "token": "Vt9PuruQ8XWLn8eK",
3
- "team_id": "T12345WRK",
4
- "api_app_id": "A0KRDEXAMPLE",
5
- "event": {
6
- "type": "app_mention",
7
- "user": "U07USER01",
8
- "text": "<@U0BOT> what time is it?",
9
- "ts": "1700000000.000100",
10
- "channel": "C0CHAN01",
11
- "event_ts": "1700000000.000100",
12
- "thread_ts": "1700000000.000100"
13
- },
14
- "type": "event_callback",
15
- "event_id": "Ev0EVENTONE",
16
- "event_time": 1700000000,
17
- "authorizations": []
18
- }
@@ -1,17 +0,0 @@
1
- {
2
- "token": "Vt9PuruQ8XWLn8eK",
3
- "team_id": "T12345WRK",
4
- "api_app_id": "A0KRDEXAMPLE",
5
- "event": {
6
- "type": "message",
7
- "subtype": "bot_message",
8
- "bot_id": "B0BOT01",
9
- "text": "hello",
10
- "ts": "1700000002.000300",
11
- "channel": "C0CHAN01"
12
- },
13
- "type": "event_callback",
14
- "event_id": "Ev0EVENTTHREE",
15
- "event_time": 1700000002,
16
- "authorizations": []
17
- }
@@ -1,17 +0,0 @@
1
- {
2
- "token": "Vt9PuruQ8XWLn8eK",
3
- "team_id": "T12345WRK",
4
- "api_app_id": "A0KRDEXAMPLE",
5
- "event": {
6
- "type": "message",
7
- "user": "U07USER01",
8
- "text": "ping",
9
- "ts": "1700000001.000200",
10
- "channel": "C0CHAN01",
11
- "event_ts": "1700000001.000200"
12
- },
13
- "type": "event_callback",
14
- "event_id": "Ev0EVENTTWO",
15
- "event_time": 1700000001,
16
- "authorizations": []
17
- }
@@ -1,5 +0,0 @@
1
- {
2
- "token": "Jhj5dZrVaK7ZwHHjRyZWjbDl",
3
- "challenge": "3eZbrw1aBm2rZgRNFdxV2595E9CY3gmdALWMmHkvFXO7tYXAYM8P",
4
- "type": "url_verification"
5
- }