scripts/slack-socket-bridge.mjs
Rama main · réplica de lectura del repositorio original.
// slack-socket-bridge.mjs — DEV-ONLY live-test bridge: Slack Socket Mode ->
// the local HTTP events endpoint. Run via `npm run slack:bridge`.
//
// WHEN TO USE: you want to drive the locally running server with REAL Slack
// events but don't want (or can't run) a public tunnel. Not a production
// transport: single consumer, immediate acks (so
// Slack-side retry semantics are not exercised — test those over HTTP), and it
// requires Socket Mode to be enabled on the app, which stops Slack from
// delivering events to the HTTP Request URL while enabled.
//
// SETUP:
// 1. In the Slack app console, enable Socket Mode.
// 2. Create an app-level token with the `connections:write` scope; it starts
// with `xapp-` and is separate from the bot's `xoxb-` token.
// 3. Put that token and the app's signing secret in `.env.slack.local` as
// SLACK_APP_TOKEN and SLACK_SIGNING_SECRET (or pass `--env <path>`).
// 4. Start the local Chickpea server, then run `npm run slack:bridge`.
// Disable Socket Mode again before testing or deploying the normal HTTP Events
// API path.
//
// HOW IT WORKS: the app's endpoint (POST /channels/slack/events) is what a
// public Slack Events API subscription would hit. This script opens a Socket
// Mode WebSocket to Slack (using an xapp- app-level token), receives the same
// Events API envelopes over that socket, re-signs each one with the app's
// signing secret exactly as Slack's HTTP delivery would, and POSTs it to the
// locally-running server. The server can't tell the difference: it verifies
// the v0 HMAC signature and processes a genuine event_callback envelope.
//
// IMPORTANT: while this is connected it consumes events for the app. Do not run
// it at the same time as another Socket Mode consumer (e.g. a real test run) —
// they would steal events from each other.
//
// Requires Node 24.x (see .nvmrc).
//
// Env (from the shell, or from an env file):
// SLACK_APP_TOKEN xapp- app-level token with connections:write (required)
// SLACK_SIGNING_SECRET must match the local server's (required)
// PORT local server port to forward to (default 3583)
// Env file: `--env <path>` (default `.env.slack.local`, repo root); shell
// values win over file values.
import { createHmac } from 'node:crypto';
import { readFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { SocketModeClient } from '@slack/socket-mode';
// --- Load the env file ---------------------------------------------------
// Minimal .env parser (no dotenv dependency). Reads KEY=VALUE lines, skips
// blanks and `#` comments, strips one layer of surrounding quotes, and only
// sets a key that isn't already present in process.env (so a value exported in
// the shell still wins). Missing file is non-fatal — we validate required keys
// below.
function loadEnvFile(path, { required = false } = {}) {
let contents;
try {
contents = readFileSync(path, 'utf8');
} catch {
if (required) {
// An explicitly requested env file that cannot be read is an operator
// error (typo'd path) — starting anyway would silently use whatever
// tokens the ambient shell holds, possibly for the wrong Slack app.
console.error(`bridge: cannot read env file ${path} (from --env)`);
process.exit(1);
}
return; // default file absent: rely on the ambient environment
}
for (const rawLine of contents.split(/\r?\n/)) {
let line = rawLine.trim();
if (!line || line.startsWith('#')) continue;
// Accept shell-sourceable files: `export KEY=value` lines work too.
if (line.startsWith('export ')) line = line.slice('export '.length).trim();
const eq = line.indexOf('=');
if (eq === -1) continue;
const key = line.slice(0, eq).trim();
if (!key || key in process.env) continue;
let value = line.slice(eq + 1).trim();
// Strip a single pair of matching surrounding quotes if present.
if (value.length >= 2 && (value[0] === '"' || value[0] === "'") && value.at(-1) === value[0]) {
value = value.slice(1, -1);
}
process.env[key] = value;
}
}
const scriptDir = dirname(fileURLToPath(import.meta.url));
const envFlagIndex = process.argv.indexOf('--env');
let envFile = resolve(scriptDir, '..', '.env.slack.local');
let envExplicit = false;
if (envFlagIndex !== -1) {
const value = process.argv[envFlagIndex + 1];
if (!value || value.startsWith('--')) {
console.error('bridge: --env requires a file path');
process.exit(1);
}
envFile = resolve(process.cwd(), value);
envExplicit = true;
}
loadEnvFile(envFile, { required: envExplicit });
const APP_TOKEN = process.env.SLACK_APP_TOKEN;
const SIGNING_SECRET = process.env.SLACK_SIGNING_SECRET;
const PORT = Number(process.env.PORT) || 3583;
const TARGET_URL = `http://127.0.0.1:${PORT}/channels/slack/events`;
if (!APP_TOKEN || !APP_TOKEN.startsWith('xapp-')) {
console.error('bridge: SLACK_APP_TOKEN is missing or not an xapp- app-level token');
process.exit(1);
}
if (!SIGNING_SECRET) {
console.error('bridge: SLACK_SIGNING_SECRET is missing');
process.exit(1);
}
// --- Signing -----------------------------------------------------------------
// Reproduce Slack's v0 request signature so the local endpoint's HMAC check
// passes. The base string is `v0:{timestamp}:{rawBody}` and the header value is
// `v0=` + lowercase hex HMAC-SHA256 keyed by the signing secret. The rawBody
// used here MUST be byte-identical to the body we POST, so we stringify once
// and reuse that exact string for both signing and the request body.
function slackSignature(timestamp, rawBody) {
const hmac = createHmac('sha256', SIGNING_SECRET);
hmac.update(`v0:${timestamp}:${rawBody}`);
return `v0=${hmac.digest('hex')}`;
}
// --- Forward one Events API envelope to the local endpoint -------------------
// `eventsApiPayload` is the Events-API-shaped wrapper Slack would POST over
// HTTP: { token, team_id, api_app_id, event, type:'event_callback', event_id,
// event_time, authorizations }. Over Socket Mode this arrives as
// `envelope.payload` — i.e. the socket framing (envelope_id / retry_* /
// accepts_response_payload) has already been peeled off for us, so we forward
// the payload as-is.
async function forwardToEndpoint(eventsApiPayload) {
const rawBody = JSON.stringify(eventsApiPayload);
const timestamp = Math.floor(Date.now() / 1000);
const signature = slackSignature(timestamp, rawBody);
const eventType = eventsApiPayload?.event?.type ?? 'unknown';
// channel lives at event.channel for most events; DM/lifecycle events may
// carry it under event.channel or event.item.channel — best-effort for logs.
const channel =
eventsApiPayload?.event?.channel ?? eventsApiPayload?.event?.item?.channel ?? 'n/a';
try {
const res = await fetch(TARGET_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Slack-Signature': signature,
'X-Slack-Request-Timestamp': String(timestamp),
},
body: rawBody,
});
console.log(`bridge: forwarded event=${eventType} channel=${channel} -> HTTP ${res.status}`);
} catch (err) {
// A connection refused here almost always means the local server isn't up.
console.error(
`bridge: forward failed event=${eventType} channel=${channel}: ${err?.message ?? err}`,
);
}
}
// --- Socket Mode client ------------------------------------------------------
const client = new SocketModeClient({ appToken: APP_TOKEN });
// Generic path: every inbound socket message is emitted as 'slack_event'. The
// arg shape is { ack, envelope_id, type, body, retry_num, retry_reason,
// accepts_response_payload }, where `type` is the socket message type
// ('events_api', 'slash_commands', 'interactive', ...) and `body` is the
// unwrapped payload. We only forward 'events_api' messages — those are the ones
// whose body is a genuine Events API event_callback wrapper the HTTP endpoint
// understands. Everything else (slash commands, interactivity) is acked and
// ignored, since the events endpoint doesn't handle them.
client.on('slack_event', async ({ ack, type, body }) => {
// (a) Ack immediately so Slack doesn't retry — do this before any forwarding
// work, and unconditionally, so even ignored message types are acked.
try {
await ack();
} catch (err) {
console.error(`bridge: ack failed: ${err?.message ?? err}`);
}
if (type !== 'events_api') return;
await forwardToEndpoint(body);
});
// Lifecycle logging. 'authenticated' fires once the app-level token is accepted;
// 'connected' fires when Slack completes the handshake (its `hello`). Reconnects
// are handled automatically by the client after .start(); we just log them.
let announcedReady = false;
function announceReady() {
if (announcedReady) return;
announcedReady = true;
console.log(`bridge: connected, forwarding events to ${TARGET_URL}`);
}
client.on('authenticated', announceReady);
client.on('connected', announceReady);
client.on('disconnected', () => console.log('bridge: disconnected (will auto-reconnect)'));
client.on('reconnecting', () => console.log('bridge: reconnecting…'));
console.log('bridge: connecting to Slack socket mode…');
client.start().catch((err) => {
console.error(`bridge: failed to start: ${err?.message ?? err}`);
process.exit(1);
});