capture-slack-adapter.mjs
150 lines 4.9 KB
Raw
sha256:65ccb454656ea5acdea0a10e559b78bcde1eb6ff753ecc2911bc99d1c3d7cadd feat(calendar): enforce agent context tiers in retrieval AP… Human minor ⚠ breaking 2 days ago
1 #!/usr/bin/env node
2 /**
3 * Slack Events API adapter. Receives Slack event payloads and forwards message events
4 * to the Knowtation capture endpoint (Hub or standalone webhook).
5 *
6 * Usage:
7 * node scripts/capture-slack-adapter.mjs [--port 3132]
8 * CAPTURE_URL=http://localhost:3333/api/v1/capture node scripts/capture-slack-adapter.mjs
9 *
10 * Env:
11 * CAPTURE_URL — Where to POST the normalized payload (default http://localhost:3333/api/v1/capture)
12 * CAPTURE_WEBHOOK_SECRET — If set, sent as X-Webhook-Secret header
13 * SLACK_SIGNING_SECRET — If set, verify Slack request signature (X-Slack-Signature)
14 *
15 * In Slack: App → Event Subscriptions → Enable → Request URL: https://your-host:3132/
16 * Subscribe to bot events: message.channels, message.groups, message.im (or as needed).
17 */
18
19 import http from 'http';
20 import path from 'path';
21 import { fileURLToPath } from 'url';
22 import crypto from 'crypto';
23
24 const __dirname = path.dirname(fileURLToPath(import.meta.url));
25 const projectRoot = path.resolve(__dirname, '..');
26
27 const DEFAULT_CAPTURE_URL = 'http://localhost:3333/api/v1/capture';
28 const CAPTURE_URL = process.env.CAPTURE_URL || DEFAULT_CAPTURE_URL;
29
30 function parseArgs() {
31 const args = process.argv.slice(2);
32 let port = parseInt(process.env.PORT || '3132', 10);
33 for (let i = 0; i < args.length; i++) {
34 if (args[i] === '--port' && args[i + 1]) port = parseInt(args[++i], 10);
35 }
36 return port;
37 }
38
39 function parseJsonBody(req) {
40 return new Promise((resolve, reject) => {
41 let data = '';
42 req.on('data', (chunk) => { data += chunk; });
43 req.on('end', () => {
44 try {
45 resolve(data ? JSON.parse(data) : {});
46 } catch (e) {
47 reject(new Error('Invalid JSON'));
48 }
49 });
50 req.on('error', reject);
51 });
52 }
53
54 function verifySlackSignature(body, signature) {
55 const secret = process.env.SLACK_SIGNING_SECRET;
56 if (!secret || !signature) return true;
57 const sigBasename = signature.startsWith('v0=') ? signature.slice(3) : signature;
58 const computed = 'v0=' + crypto.createHmac('sha256', secret).update('v0:' + body).digest('hex');
59 return crypto.timingSafeEqual(Buffer.from(computed, 'utf8'), Buffer.from(signature, 'utf8'));
60 }
61
62 async function postToCapture(payload) {
63 const headers = { 'Content-Type': 'application/json' };
64 if (process.env.CAPTURE_WEBHOOK_SECRET) {
65 headers['X-Webhook-Secret'] = process.env.CAPTURE_WEBHOOK_SECRET;
66 }
67 const res = await fetch(CAPTURE_URL, {
68 method: 'POST',
69 headers,
70 body: JSON.stringify(payload),
71 });
72 const text = await res.text();
73 if (!res.ok) throw new Error(`Capture returned ${res.status}: ${text}`);
74 return text;
75 }
76
77 function main() {
78 const port = parseArgs();
79 const server = http.createServer(async (req, res) => {
80 if (req.method !== 'POST' || (req.url !== '/' && req.url !== '')) {
81 res.writeHead(404, { 'Content-Type': 'application/json' });
82 res.end(JSON.stringify({ error: 'Not found. POST / for Slack events.' }));
83 return;
84 }
85
86 let rawBody = '';
87 req.on('data', (chunk) => { rawBody += chunk; });
88 await new Promise((resolve) => req.on('end', resolve));
89
90 const signature = req.headers['x-slack-signature'];
91 if (!verifySlackSignature(rawBody, signature)) {
92 res.writeHead(401, { 'Content-Type': 'application/json' });
93 res.end(JSON.stringify({ error: 'Invalid signature' }));
94 return;
95 }
96
97 let payload;
98 try {
99 payload = JSON.parse(rawBody);
100 } catch (e) {
101 res.writeHead(400, { 'Content-Type': 'application/json' });
102 res.end(JSON.stringify({ error: 'Invalid JSON' }));
103 return;
104 }
105
106 res.setHeader('Content-Type', 'application/json');
107
108 if (payload.type === 'url_verification') {
109 res.writeHead(200);
110 res.end(JSON.stringify({ challenge: payload.challenge }));
111 return;
112 }
113
114 if (payload.type === 'event_callback') {
115 const event = payload.event || {};
116 if (event.type === 'message' && event.text != null && event.text !== '') {
117 const sourceId = event.ts || event.client_msg_id || `${event.channel}_${Date.now()}`;
118 const capturePayload = {
119 body: event.text,
120 source: 'slack',
121 source_id: sourceId,
122 project: event.channel ? undefined : undefined,
123 tags: event.channel ? `channel:${event.channel}` : undefined,
124 };
125 try {
126 await postToCapture(capturePayload);
127 } catch (e) {
128 console.error('capture-slack-adapter: capture error', e.message);
129 res.writeHead(502);
130 res.end(JSON.stringify({ error: e.message }));
131 return;
132 }
133 }
134 res.writeHead(200);
135 res.end('');
136 return;
137 }
138
139 res.writeHead(200);
140 res.end('');
141 });
142
143 server.listen(port, () => {
144 console.log(`capture-slack-adapter listening on http://localhost:${port}`);
145 console.log(' CAPTURE_URL:', CAPTURE_URL);
146 console.log(' Configure Slack Event Subscriptions to this URL');
147 });
148 }
149
150 main();
File History 2 commits
sha256:65ccb454656ea5acdea0a10e559b78bcde1eb6ff753ecc2911bc99d1c3d7cadd feat(calendar): enforce agent context tiers in retrieval AP… Human minor 2 days ago
sha256:9103f98c89257ed2b01c237cea895dabb3e85ea337dccb1161c175e4422355b6 docs: accept Calendar Events v0 spec with Phase 0 security … Human 2 days ago