lazyclaw 3.99.28 → 4.2.1

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,465 @@
1
+ // Slack channel adapter.
2
+ //
3
+ // Reads three secrets from the environment ONLY (never from goal files,
4
+ // never logged):
5
+ // SLACK_BOT_TOKEN xoxb-… — used to call chat.postMessage etc.
6
+ // SLACK_APP_TOKEN xapp-… — required for Socket Mode (inbound)
7
+ // SLACK_SIGNING_SECRET … — used to verify webhook payloads when
8
+ // we add Events API mode (not yet)
9
+ //
10
+ // Outbound (`send(threadId, text)`) only needs the bot token. Inbound
11
+ // arrives via Socket Mode — `_connectSocketMode()` opens a WebSocket to
12
+ // Slack's gateway (negotiated by `apps.connections.open`) and dispatches
13
+ // every `events_api` envelope through `_simulateInbound(text, threadId)`.
14
+ // `start()` only validates env so unit tests can drive `_simulateInbound`
15
+ // directly without bringing up a WebSocket; the CLI's `slack listen`
16
+ // subcommand calls `_connectSocketMode()` explicitly after `start()`.
17
+ //
18
+ // SLACK_API_BASE (test-only) overrides the Slack Web API base URL so the
19
+ // Phase 8 spec can point the adapter at a local mock HTTP server.
20
+
21
+ import { Channel, ChannelGated } from './base.mjs';
22
+
23
+ const DEFAULT_API_BASE = 'https://slack.com/api';
24
+
25
+ export class SlackError extends Error {
26
+ constructor(message, code, missing) {
27
+ super(message);
28
+ this.name = 'SlackError';
29
+ this.code = code || 'SLACK_ERR';
30
+ if (Array.isArray(missing)) this.missing = missing;
31
+ }
32
+ }
33
+
34
+ // Decide whether a Socket Mode event should drive a handler call.
35
+ // Pulled out of dispatchEvent so we can unit-test the filter without
36
+ // standing up a WebSocket. Returns false (= skip) for:
37
+ // - non-objects / null
38
+ // - the bot's own message in any of the four wire shapes Slack uses
39
+ // (legacy bot_id, legacy bot_message subtype, modern bot_profile.id,
40
+ // or the message.user field matching our cached auth.test user_id —
41
+ // this last one is the chat:write.customize trap that caused the
42
+ // v4.2.0 listener-loop)
43
+ // - non-message-shaped events (we only handle `message` and
44
+ // `app_mention`)
45
+ // - empty / whitespace-only text bodies (defense in depth so a
46
+ // stray blocks-only post can't loop us back into "(empty message)"
47
+ // fallback)
48
+ export function shouldDispatchEvent(event, { selfUserId = null, selfBotId = null } = {}) {
49
+ if (!event || typeof event !== 'object') return false;
50
+ if (event.bot_id || event.subtype === 'bot_message') return false;
51
+ if (selfUserId && event.user === selfUserId) return false;
52
+ if (selfBotId && event.bot_id === selfBotId) return false;
53
+ if (selfBotId && event.bot_profile && event.bot_profile.id === selfBotId) return false;
54
+ if (event.type !== 'app_mention' && event.type !== 'message') return false;
55
+ const text = typeof event.text === 'string' ? event.text : '';
56
+ if (text.trim() === '') return false;
57
+ return true;
58
+ }
59
+
60
+ export function readSlackEnv(env = process.env) {
61
+ const out = {
62
+ botToken: env.SLACK_BOT_TOKEN || null,
63
+ appToken: env.SLACK_APP_TOKEN || null,
64
+ signingSecret: env.SLACK_SIGNING_SECRET || null,
65
+ apiBase: env.SLACK_API_BASE || DEFAULT_API_BASE,
66
+ };
67
+ return out;
68
+ }
69
+
70
+ function validateEnv(env, { requireInbound = false } = {}) {
71
+ const missing = [];
72
+ if (!env.botToken) missing.push('SLACK_BOT_TOKEN');
73
+ else if (!env.botToken.startsWith('xoxb-')) {
74
+ throw new SlackError('SLACK_BOT_TOKEN must start with "xoxb-"', 'SLACK_BAD_TOKEN', ['SLACK_BOT_TOKEN']);
75
+ }
76
+ if (requireInbound) {
77
+ if (!env.appToken) missing.push('SLACK_APP_TOKEN');
78
+ else if (!env.appToken.startsWith('xapp-')) {
79
+ throw new SlackError('SLACK_APP_TOKEN must start with "xapp-"', 'SLACK_BAD_TOKEN', ['SLACK_APP_TOKEN']);
80
+ }
81
+ if (!env.signingSecret) missing.push('SLACK_SIGNING_SECRET');
82
+ }
83
+ if (missing.length) {
84
+ throw new SlackError(`missing Slack env vars: ${missing.join(', ')}`, 'SLACK_MISSING_ENV', missing);
85
+ }
86
+ }
87
+
88
+ export class SlackChannel extends Channel {
89
+ constructor(opts = {}) {
90
+ super('slack');
91
+ this._env = { ...readSlackEnv(), ...opts };
92
+ this._requireInbound = opts.requireInbound !== false; // default true
93
+ this._socketHandle = null; // populated when Socket Mode connects
94
+ }
95
+
96
+ async start(handler, opts = {}) {
97
+ // Validate up-front so a missing-token daemon fails loudly at boot
98
+ // (the Phase 8 spec test asserts this).
99
+ validateEnv(this._env, { requireInbound: this._requireInbound });
100
+ await super.start(handler, opts);
101
+ // Socket Mode connect is intentionally deferred — we keep the
102
+ // adapter pure for the test surface; the production wiring imports
103
+ // @slack/socket-mode or implements the WS handshake directly and
104
+ // funnels every inbound event through _simulateInbound.
105
+ return this;
106
+ }
107
+
108
+ // Called by Socket Mode wiring (or tests) for every inbound message
109
+ // routed to this app. The handler returns the bot's reply; the
110
+ // adapter posts it back to Slack in the same thread. A null /
111
+ // empty-string reply skips the send entirely (Phase 19.2) so a
112
+ // handler that decided to stay silent — e.g. the listener dropping
113
+ // an empty-after-mention-strip inbound — doesn't leak a "(empty
114
+ // reply)" placeholder into the channel.
115
+ async _simulateInbound(text, threadId) {
116
+ let reply;
117
+ try {
118
+ reply = await this._processInbound({ threadId, text, gateInput: {} });
119
+ } catch (err) {
120
+ if (err instanceof ChannelGated || err?.code === 'CHANNEL_GATED') {
121
+ await this.send(threadId, `(gated: ${err.message})`);
122
+ return;
123
+ }
124
+ await this.send(threadId, `(error: ${err?.message || err})`);
125
+ return;
126
+ }
127
+ if (reply == null || (typeof reply === 'string' && reply.trim() === '')) return;
128
+ await this.send(threadId, reply);
129
+ }
130
+
131
+ // Translate a target spec like `slack:#deploys` or `slack:U012345` into
132
+ // a Slack `channel` string. Threads are addressed by a `threadId` of
133
+ // shape `<channel>:<thread_ts>` or plain channel/user id.
134
+ //
135
+ // opts (Phase 16):
136
+ // username: string — overrides the bot's display name for this
137
+ // single message (requires chat:write.customize
138
+ // scope on the bot token). Silently no-op when
139
+ // the scope is missing.
140
+ // icon_emoji: string — e.g. ":rocket:" — same scope.
141
+ async send(threadId, text, opts = {}) {
142
+ if (!this._env.botToken) throw new SlackError('cannot send without SLACK_BOT_TOKEN', 'SLACK_NO_TOKEN');
143
+ let channel = threadId, thread_ts;
144
+ if (typeof threadId === 'string' && threadId.includes(':')) {
145
+ const ix = threadId.indexOf(':');
146
+ // Allow the test-style "slack:#chan" prefix to flow through.
147
+ if (threadId.slice(0, ix) === 'slack') {
148
+ channel = threadId.slice(ix + 1);
149
+ } else {
150
+ channel = threadId.slice(0, ix);
151
+ thread_ts = threadId.slice(ix + 1);
152
+ }
153
+ }
154
+ const url = `${this._env.apiBase.replace(/\/$/, '')}/chat.postMessage`;
155
+ const body = {
156
+ channel,
157
+ text: String(text),
158
+ ...(thread_ts ? { thread_ts } : {}),
159
+ ...(opts && opts.username ? { username: String(opts.username) } : {}),
160
+ ...(opts && opts.icon_emoji ? { icon_emoji: String(opts.icon_emoji) } : {}),
161
+ };
162
+ const res = await fetch(url, {
163
+ method: 'POST',
164
+ headers: {
165
+ 'Authorization': `Bearer ${this._env.botToken}`,
166
+ 'Content-Type': 'application/json; charset=utf-8',
167
+ },
168
+ body: JSON.stringify(body),
169
+ });
170
+ if (!res.ok) {
171
+ throw new SlackError(`slack send failed: HTTP ${res.status}`, 'SLACK_HTTP_FAIL');
172
+ }
173
+ const json = await res.json().catch(() => ({}));
174
+ if (!json.ok) {
175
+ throw new SlackError(`slack send failed: ${json.error || 'unknown'}`, 'SLACK_API_FAIL');
176
+ }
177
+ return json;
178
+ }
179
+
180
+ // Open a Socket Mode WebSocket and route every inbound event through
181
+ // `_simulateInbound`. Returns when the listener is connected; the
182
+ // returned object exposes `.close()` for graceful shutdown.
183
+ //
184
+ // opts.logger?: (line: string) => void — diagnostic sink (stderr in
185
+ // the CLI, no-op in tests).
186
+ // opts.maxReconnects?: number — cap reconnect attempts (default ∞).
187
+ async _connectSocketMode({ logger = () => {}, maxReconnects = Infinity } = {}) {
188
+ validateEnv(this._env, { requireInbound: true });
189
+ if (typeof globalThis.WebSocket !== 'function') {
190
+ throw new SlackError(
191
+ 'global WebSocket is not available — Node 22+ required for Socket Mode',
192
+ 'SLACK_NO_WS'
193
+ );
194
+ }
195
+ const apiBase = this._env.apiBase.replace(/\/$/, '');
196
+ const appToken = this._env.appToken;
197
+ const seenEnvelopes = new Set();
198
+ let closed = false;
199
+ let ws = null;
200
+ let attempts = 0;
201
+
202
+ const openConnection = async () => {
203
+ const url = `${apiBase}/apps.connections.open`;
204
+ const res = await fetch(url, {
205
+ method: 'POST',
206
+ headers: {
207
+ 'Authorization': `Bearer ${appToken}`,
208
+ 'Content-Type': 'application/x-www-form-urlencoded',
209
+ },
210
+ });
211
+ if (!res.ok) {
212
+ throw new SlackError(`apps.connections.open HTTP ${res.status}`, 'SLACK_OPEN_HTTP');
213
+ }
214
+ const json = await res.json().catch(() => ({}));
215
+ if (!json.ok || !json.url) {
216
+ throw new SlackError(`apps.connections.open failed: ${json.error || 'no url'}`, 'SLACK_OPEN_FAIL');
217
+ }
218
+ return json.url;
219
+ };
220
+
221
+ // Resolve our own identity so dispatchEvent can refuse loops where
222
+ // the router posted a message with chat:write.customize (Slack
223
+ // strips bot_id / subtype from those events, so the original
224
+ // filter missed them and we replied to ourselves). Best-effort —
225
+ // a failed auth.test leaves the filter relying on the legacy
226
+ // bot_id / subtype check; better than refusing to start.
227
+ let selfUserId = null;
228
+ let selfBotId = null;
229
+ try {
230
+ const authUrl = `${apiBase}/auth.test`;
231
+ const r = await fetch(authUrl, {
232
+ method: 'POST',
233
+ headers: {
234
+ 'Authorization': `Bearer ${this._env.botToken}`,
235
+ 'Content-Type': 'application/x-www-form-urlencoded',
236
+ },
237
+ });
238
+ if (r.ok) {
239
+ const j = await r.json().catch(() => ({}));
240
+ if (j && j.ok) {
241
+ selfUserId = j.user_id || null;
242
+ selfBotId = j.bot_id || null;
243
+ logger(`[slack] auth.test OK — self user=${selfUserId} bot=${selfBotId}\n`);
244
+ }
245
+ }
246
+ } catch (err) {
247
+ logger(`[slack] auth.test failed: ${err?.message || err}\n`);
248
+ }
249
+ this._selfUserId = selfUserId;
250
+ this._selfBotId = selfBotId;
251
+
252
+ // (channel, ts) dedupe — a single user message can fire both
253
+ // `message` and `app_mention` events in the same channel. Both
254
+ // arrive as separate Socket Mode envelopes (different envelope_id),
255
+ // so the envelope_id-level dedupe upstream doesn't catch them. We
256
+ // claim the pair on first dispatch and reject the second.
257
+ const seenMessages = new Map(); // key → expiresAt(ms)
258
+ const MSG_TTL_MS = 60_000;
259
+ const claimMessage = (channel, ts) => {
260
+ const key = `${channel}:${ts}`;
261
+ const now = Date.now();
262
+ // Sweep expired entries opportunistically so the map doesn't grow
263
+ // unbounded over a long-running session.
264
+ if (seenMessages.size > 256) {
265
+ for (const [k, exp] of seenMessages) if (exp < now) seenMessages.delete(k);
266
+ }
267
+ const exp = seenMessages.get(key);
268
+ if (exp && exp >= now) return false;
269
+ seenMessages.set(key, now + MSG_TTL_MS);
270
+ return true;
271
+ };
272
+
273
+ const dispatchEvent = async (event) => {
274
+ if (!shouldDispatchEvent(event, { selfUserId: this._selfUserId, selfBotId: this._selfBotId })) {
275
+ // shouldDispatchEvent already encodes the "skip the bot's own
276
+ // chat:write.customize message" trap that caused the v4.2.0
277
+ // listener loop. Bail before the reaction / handler call.
278
+ if (event && (event.type === 'message' || event.type === 'app_mention')) {
279
+ logger(`[slack] skipping ${event.type} from ${event.channel || '?'}:${event.ts || '?'}\n`);
280
+ }
281
+ return;
282
+ }
283
+ // For DMs (`im`) channel_type is 'im'; for channel mentions we only
284
+ // get app_mention events. Either way we have channel + ts.
285
+ const text = typeof event.text === 'string' ? event.text : '';
286
+ const channel = event.channel;
287
+ const sourceTs = event.ts; // the message we react to
288
+ const replyTs = event.thread_ts || event.ts; // the thread root for replies
289
+ if (!channel || !sourceTs) return;
290
+ if (!claimMessage(channel, sourceTs)) {
291
+ logger(`[slack] duplicate ${event.type} for ${channel}:${sourceTs} — skipping\n`);
292
+ return;
293
+ }
294
+ const threadId = `${channel}:${replyTs}`;
295
+ logger(`[slack] inbound ${event.type} from ${channel} (${text.length} chars)\n`);
296
+
297
+ // Immediate acknowledgement. _ackInbound is silent when
298
+ // reactions:write is missing (Phase 19.2 — no more text-ack
299
+ // spam).
300
+ const eyesOk = await this._ackInbound(channel, sourceTs, logger);
301
+
302
+ try {
303
+ await this._simulateInbound(text, threadId);
304
+ if (eyesOk) {
305
+ // Swap the "working" reaction for a "done" one so the user can
306
+ // tell at a glance which messages have been answered.
307
+ await this._reaction('remove', channel, sourceTs, 'eyes');
308
+ await this._reaction('add', channel, sourceTs, 'white_check_mark');
309
+ }
310
+ } catch (err) {
311
+ logger(`[slack] handler error: ${err?.message || err}\n`);
312
+ if (eyesOk) {
313
+ await this._reaction('remove', channel, sourceTs, 'eyes');
314
+ await this._reaction('add', channel, sourceTs, 'x');
315
+ }
316
+ }
317
+ };
318
+
319
+ const connectOnce = () => new Promise((resolve, reject) => {
320
+ let wsUrl;
321
+ openConnection()
322
+ .then((u) => { wsUrl = u; })
323
+ .catch(reject)
324
+ .then(() => {
325
+ if (!wsUrl) return;
326
+ logger(`[slack] socket-mode dialing wss gateway\n`);
327
+ ws = new WebSocket(wsUrl);
328
+ ws.addEventListener('open', () => {
329
+ attempts = 0;
330
+ logger(`[slack] socket-mode connected\n`);
331
+ resolve();
332
+ });
333
+ ws.addEventListener('message', async (ev) => {
334
+ let frame;
335
+ try { frame = JSON.parse(typeof ev.data === 'string' ? ev.data : ev.data.toString()); }
336
+ catch { return; }
337
+ if (frame.type === 'hello') {
338
+ logger(`[slack] hello (num_connections=${frame.num_connections || '?'})\n`);
339
+ return;
340
+ }
341
+ if (frame.type === 'disconnect') {
342
+ logger(`[slack] disconnect requested (reason=${frame.reason || '?'})\n`);
343
+ try { ws.close(1000); } catch { /* best-effort */ }
344
+ return;
345
+ }
346
+ if (frame.type === 'events_api') {
347
+ if (frame.envelope_id) {
348
+ if (seenEnvelopes.has(frame.envelope_id)) return;
349
+ seenEnvelopes.add(frame.envelope_id);
350
+ // Bound the dedupe set so it doesn't grow forever.
351
+ if (seenEnvelopes.size > 1024) {
352
+ const trimTo = 512;
353
+ const it = seenEnvelopes.values();
354
+ while (seenEnvelopes.size > trimTo) seenEnvelopes.delete(it.next().value);
355
+ }
356
+ try { ws.send(JSON.stringify({ envelope_id: frame.envelope_id })); }
357
+ catch (err) { logger(`[slack] ack send failed: ${err?.message || err}\n`); }
358
+ }
359
+ const event = frame.payload?.event;
360
+ await dispatchEvent(event);
361
+ }
362
+ });
363
+ ws.addEventListener('close', () => {
364
+ logger(`[slack] socket closed\n`);
365
+ if (closed) return;
366
+ attempts++;
367
+ if (attempts > maxReconnects) {
368
+ logger(`[slack] giving up after ${attempts} reconnect attempts\n`);
369
+ return;
370
+ }
371
+ const backoff = Math.min(30000, 1000 * Math.pow(2, Math.min(attempts, 5)));
372
+ logger(`[slack] reconnecting in ${backoff}ms (attempt ${attempts})\n`);
373
+ setTimeout(() => { if (!closed) connectOnce().catch((e) => logger(`[slack] reconnect failed: ${e?.message || e}\n`)); }, backoff);
374
+ });
375
+ ws.addEventListener('error', (ev) => {
376
+ // The 'error' event fires before 'close'; we let 'close' drive
377
+ // the reconnect so we don't reconnect twice for one failure.
378
+ logger(`[slack] socket error: ${ev?.message || 'unknown'}\n`);
379
+ });
380
+ });
381
+ });
382
+
383
+ await connectOnce();
384
+ this._socketHandle = {
385
+ disconnect: async () => {
386
+ closed = true;
387
+ try { ws?.close(1000); } catch { /* best-effort */ }
388
+ },
389
+ };
390
+ return this._socketHandle;
391
+ }
392
+
393
+ // Mark an inbound message as "being worked on" without spamming the
394
+ // channel. Tries the :eyes: reaction first (silent UX). When
395
+ // reactions:write is missing we used to fall back to a text post
396
+ // ("확인해보겠습니다…") which doubled the noise per turn; Phase 19.2
397
+ // dropped that fallback so the channel stays clean when the scope
398
+ // is unavailable. The operator can flip reactions:write on at any
399
+ // time to bring the visible signal back.
400
+ //
401
+ // Exposed as a method (not closure-private in dispatchEvent) so the
402
+ // listener-noise unit tests can drive it directly.
403
+ async _ackInbound(channel, sourceTs, logger = () => {}) {
404
+ const eyesOk = await this._reaction('add', channel, sourceTs, 'eyes');
405
+ if (!eyesOk) {
406
+ logger('[slack] reactions:write missing — silent ack only (no text fallback)\n');
407
+ }
408
+ return eyesOk;
409
+ }
410
+
411
+ // Best-effort chat.delete — used by typing-indicator workflows where
412
+ // we post a placeholder and want to clean it up. Returns true on
413
+ // success, silent false otherwise.
414
+ async deleteMessage(channel, ts) {
415
+ if (!this._env.botToken || !channel || !ts) return false;
416
+ const url = `${this._env.apiBase.replace(/\/$/, '')}/chat.delete`;
417
+ try {
418
+ const res = await fetch(url, {
419
+ method: 'POST',
420
+ headers: {
421
+ 'Authorization': `Bearer ${this._env.botToken}`,
422
+ 'Content-Type': 'application/json; charset=utf-8',
423
+ },
424
+ body: JSON.stringify({ channel, ts }),
425
+ });
426
+ if (!res.ok) return false;
427
+ const json = await res.json().catch(() => ({}));
428
+ return !!json.ok;
429
+ } catch {
430
+ return false;
431
+ }
432
+ }
433
+
434
+ // Best-effort reaction add / remove. Returns true on success. Silent
435
+ // false on any failure (missing reactions:write scope, transport
436
+ // error, …) so callers can chain without noise.
437
+ async _reaction(action, channel, ts, name) {
438
+ if (!this._env.botToken || !channel || !ts) return false;
439
+ const endpoint = action === 'remove' ? 'reactions.remove' : 'reactions.add';
440
+ const url = `${this._env.apiBase.replace(/\/$/, '')}/${endpoint}`;
441
+ try {
442
+ const res = await fetch(url, {
443
+ method: 'POST',
444
+ headers: {
445
+ 'Authorization': `Bearer ${this._env.botToken}`,
446
+ 'Content-Type': 'application/json; charset=utf-8',
447
+ },
448
+ body: JSON.stringify({ channel, timestamp: ts, name }),
449
+ });
450
+ if (!res.ok) return false;
451
+ const json = await res.json().catch(() => ({}));
452
+ return !!json.ok;
453
+ } catch {
454
+ return false;
455
+ }
456
+ }
457
+
458
+ async stop() {
459
+ if (this._socketHandle && typeof this._socketHandle.disconnect === 'function') {
460
+ try { await this._socketHandle.disconnect(); } catch { /* best-effort */ }
461
+ }
462
+ this._socketHandle = null;
463
+ await super.stop();
464
+ }
465
+ }
@@ -0,0 +1,52 @@
1
+ // In-memory channel used for tests and for any caller that wants to
2
+ // drive the daemon without a real transport. `inbox.push({ threadId,
3
+ // text, token? })` triggers the handler; replies land in `outbox`.
4
+ //
5
+ // The stub respects the same gate object the HTTP channel uses so
6
+ // auth-token + rate-limit assertions on the daemon's middleware chain
7
+ // can be exercised without round-tripping through TCP.
8
+
9
+ import { Channel } from './base.mjs';
10
+
11
+ export class StubChannel extends Channel {
12
+ constructor() {
13
+ super('stub');
14
+ /** @type {Array<{ threadId: string, text: string, token?: string|null, key?: string|null }>} */
15
+ this.inbox = [];
16
+ /** @type {Array<{ threadId: string, text: string, error?: string }>} */
17
+ this.outbox = [];
18
+ this._pump = null;
19
+ }
20
+
21
+ async start(handler, opts = {}) {
22
+ await super.start(handler, opts);
23
+ this._pump = setInterval(() => this._drain(), 5);
24
+ // unref so a hanging interval doesn't keep the process alive
25
+ if (typeof this._pump.unref === 'function') this._pump.unref();
26
+ }
27
+
28
+ async _drain() {
29
+ while (this.inbox.length) {
30
+ const item = this.inbox.shift();
31
+ try {
32
+ const reply = await this._processInbound({
33
+ threadId: item.threadId,
34
+ text: item.text,
35
+ gateInput: { token: item.token, key: item.key },
36
+ });
37
+ this.outbox.push({ threadId: item.threadId, text: String(reply) });
38
+ } catch (err) {
39
+ this.outbox.push({ threadId: item.threadId, text: '', error: err?.message || String(err), code: err?.code });
40
+ }
41
+ }
42
+ }
43
+
44
+ async send(threadId, text) {
45
+ this.outbox.push({ threadId, text: String(text) });
46
+ }
47
+
48
+ async stop() {
49
+ if (this._pump) { clearInterval(this._pump); this._pump = null; }
50
+ await super.stop();
51
+ }
52
+ }