@sena-ai/connector-slack 1.4.0 → 1.4.2

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.
package/dist/connector.js CHANGED
@@ -2,108 +2,269 @@ import { WebClient } from '@slack/web-api';
2
2
  import { SocketModeClient } from '@slack/socket-mode';
3
3
  import { verifySignature } from './verify.js';
4
4
  import { markdownToSlack } from './mrkdwn.js';
5
- import { writeFile, mkdir } from 'node:fs/promises';
6
- import { join } from 'node:path';
5
+ import { readFile, writeFile, mkdir } from 'node:fs/promises';
6
+ import { join, resolve } from 'node:path';
7
7
  import { tmpdir } from 'node:os';
8
- export function slackConnector(options) {
9
- const { appId, botToken, thinkingMessage } = options;
10
- const slack = new WebClient(botToken);
11
- const userNameCache = new Map();
12
- // Track threads the bot has participated in (channel:thread_ts → true)
13
- const activeThreads = new Set();
14
- // Deduplicate events — Slack sends both app_mention and message for the same @mention.
15
- // Two-phase: processingEvents tracks in-flight events (before we know if they'll be handled),
16
- // processedEvents tracks events that were actually processed to completion.
17
- const processedEvents = new Set();
18
- const processingEvents = new Set();
19
- // Resolved bot user ID (lazy, set on first event)
20
- let botUserId;
21
- // Socket Mode client reference for stop() lifecycle
22
- let socketClient;
8
+ function isRecord(value) {
9
+ return typeof value === 'object' && value !== null;
10
+ }
11
+ function readString(record, key) {
12
+ const value = record[key];
13
+ return typeof value === 'string' ? value : undefined;
14
+ }
15
+ function readRecord(record, key) {
16
+ const value = record[key];
17
+ return isRecord(value) ? value : undefined;
18
+ }
19
+ function readFileList(record, key) {
20
+ const value = record[key];
21
+ if (!Array.isArray(value))
22
+ return [];
23
+ return value.flatMap((item) => {
24
+ if (!isRecord(item))
25
+ return [];
26
+ const id = readString(item, 'id');
27
+ const name = readString(item, 'name');
28
+ const mimetype = readString(item, 'mimetype');
29
+ if (!id || !name || !mimetype)
30
+ return [];
31
+ return [{
32
+ id,
33
+ name,
34
+ mimetype,
35
+ url_private: readString(item, 'url_private'),
36
+ }];
37
+ });
38
+ }
39
+ function toAttachmentMetadata(files) {
40
+ if (files.length === 0)
41
+ return undefined;
42
+ return files.map((file) => ({
43
+ id: file.id,
44
+ name: file.name,
45
+ mimeType: file.mimetype,
46
+ }));
47
+ }
48
+ function isFunction(value) {
49
+ return typeof value === 'function';
50
+ }
51
+ function isPromptRule(value) {
52
+ if (!isRecord(value))
53
+ return false;
54
+ const hasText = typeof value.text === 'string';
55
+ const hasFile = typeof value.file === 'string';
56
+ if (hasText === hasFile)
57
+ return false;
58
+ return value.filter === undefined || isFunction(value.filter);
59
+ }
60
+ function isReactionActionRule(value) {
61
+ if (!isRecord(value))
62
+ return false;
63
+ if (value.action !== 'abort')
64
+ return false;
65
+ return value.filter === undefined || isFunction(value.filter);
66
+ }
67
+ function assertMessagePromptTrigger(value, path) {
68
+ if (typeof value === 'string' || isPromptRule(value))
69
+ return;
70
+ throw new Error(`Invalid Slack trigger config at ${path}`);
71
+ }
72
+ function assertReactionRule(value, path) {
73
+ if (typeof value === 'string' || isPromptRule(value) || isReactionActionRule(value))
74
+ return;
75
+ throw new Error(`Invalid Slack reaction rule at ${path}`);
76
+ }
77
+ export function normalizeTriggerConfig(triggers) {
78
+ if (triggers === undefined) {
79
+ return {
80
+ mention: '',
81
+ thread: '',
82
+ reactions: {
83
+ x: { action: 'abort' },
84
+ },
85
+ };
86
+ }
87
+ const normalized = {
88
+ reactions: {},
89
+ };
90
+ if (triggers.mention !== undefined) {
91
+ assertMessagePromptTrigger(triggers.mention, 'triggers.mention');
92
+ normalized.mention = triggers.mention;
93
+ }
94
+ if (triggers.thread !== undefined) {
95
+ assertMessagePromptTrigger(triggers.thread, 'triggers.thread');
96
+ normalized.thread = triggers.thread;
97
+ }
98
+ if (triggers.channel !== undefined) {
99
+ assertMessagePromptTrigger(triggers.channel, 'triggers.channel');
100
+ normalized.channel = triggers.channel;
101
+ }
102
+ if (triggers.reactions !== undefined) {
103
+ for (const [emoji, rule] of Object.entries(triggers.reactions)) {
104
+ assertReactionRule(rule, `triggers.reactions.${emoji}`);
105
+ normalized.reactions[emoji] = rule;
106
+ }
107
+ }
108
+ return normalized;
109
+ }
110
+ function getMessageFilter(source) {
111
+ if (typeof source === 'string')
112
+ return undefined;
113
+ return source.filter;
114
+ }
115
+ function getReactionFilter(rule) {
116
+ if (typeof rule === 'string')
117
+ return undefined;
118
+ if (isReactionActionRule(rule))
119
+ return rule.filter;
120
+ return rule.filter;
121
+ }
122
+ function isReactionAbortRule(rule) {
123
+ return typeof rule !== 'string' && isReactionActionRule(rule);
124
+ }
125
+ export async function resolvePromptSource(source, baseDir) {
126
+ if (typeof source === 'string')
127
+ return source;
128
+ if ('text' in source)
129
+ return source.text;
130
+ return readFile(resolve(baseDir, source.file), 'utf8');
131
+ }
132
+ async function runMessageTriggerFilter(source, event) {
133
+ const filter = getMessageFilter(source);
134
+ if (!filter)
135
+ return true;
136
+ const result = await filter(event);
137
+ return result !== false;
138
+ }
139
+ async function runReactionTriggerFilter(rule, event) {
140
+ const filter = getReactionFilter(rule);
141
+ if (!filter)
142
+ return true;
143
+ const result = await filter(event);
144
+ return result !== false;
145
+ }
146
+ function containsBotMention(text, botUserId) {
147
+ if (!botUserId)
148
+ return false;
149
+ return text.includes(`<@${botUserId}>`);
150
+ }
151
+ function buildConversationId(channel, threadTs, ts) {
152
+ return `${channel}:${threadTs ?? ts}`;
153
+ }
154
+ function buildMessageInputText(prompt, messageText) {
155
+ const sections = [prompt.trim(), messageText].filter((part) => part.length > 0);
156
+ return sections.join('\n\n');
157
+ }
158
+ function buildReactionInputText(prompt, event) {
159
+ const lines = [
160
+ `reaction: :${event.reaction}:`,
161
+ `actorUserId: ${event.userId}`,
162
+ event.userName ? `actorUserName: ${event.userName}` : '',
163
+ `channelId: ${event.channelId}`,
164
+ `threadTs: ${event.threadTs}`,
165
+ `messageTs: ${event.ts}`,
166
+ event.messageUserId ? `messageUserId: ${event.messageUserId}` : '',
167
+ event.messageUserName ? `messageUserName: ${event.messageUserName}` : '',
168
+ event.messageBotId ? `messageBotId: ${event.messageBotId}` : '',
169
+ '',
170
+ 'targetMessage:',
171
+ event.text || '(empty)',
172
+ ].filter((line) => line.length > 0);
173
+ const sections = [prompt.trim(), lines.join('\n')].filter((part) => part.length > 0);
174
+ return sections.join('\n\n');
175
+ }
176
+ function parseMessageEvent(body) {
177
+ const event = readRecord(body, 'event');
178
+ if (!event)
179
+ return null;
180
+ const type = readString(event, 'type');
181
+ if (type !== 'app_mention' && type !== 'message')
182
+ return null;
183
+ const channel = readString(event, 'channel');
184
+ const ts = readString(event, 'ts');
185
+ if (!channel || !ts)
186
+ return null;
23
187
  return {
24
- name: 'slack',
25
- registerRoutes(server, engine) {
26
- // Lazily resolve bot user ID
27
- const resolveBotUserId = async () => {
28
- if (botUserId)
29
- return;
30
- try {
31
- const auth = await slack.auth.test();
32
- botUserId = auth.user_id;
33
- console.log(`[slack] resolved bot user id: ${botUserId}`);
34
- }
35
- catch (err) {
36
- console.warn('[slack] failed to resolve bot user id:', err);
37
- }
38
- };
39
- const mode = options.mode ?? 'http';
40
- if (mode === 'socket') {
41
- const { appToken } = options;
42
- socketClient = new SocketModeClient({ appToken });
43
- // @slack/socket-mode v2 emits events_api messages using the inner event type
44
- // name (e.g. 'app_mention', 'message', 'reaction_added'), NOT 'events_api'.
45
- const handleSocketEvent = async ({ body, ack }) => {
46
- // Acknowledge immediately (Socket Mode requires ack within 3 s)
47
- await ack();
48
- await resolveBotUserId();
49
- processSlackEvent(body, engine, appId, slack, botToken, userNameCache, activeThreads, processedEvents, processingEvents, botUserId);
50
- };
51
- socketClient.on('app_mention', handleSocketEvent);
52
- socketClient.on('message', handleSocketEvent);
53
- socketClient.on('reaction_added', handleSocketEvent);
54
- // Start connection (fire-and-forget; logs errors internally)
55
- socketClient.start().then(() => {
56
- console.log('[slack] socket mode connected');
57
- }).catch((err) => {
58
- console.error('[slack] socket mode connection failed:', err);
59
- });
60
- }
61
- else {
62
- // HTTP Events API mode
63
- const { signingSecret } = options;
64
- server.post('/api/slack/events', async (req, res) => {
65
- await resolveBotUserId();
66
- const body = req.body;
67
- // URL verification challenge
68
- if (body?.type === 'url_verification') {
69
- res.status(200).json({ challenge: body.challenge });
70
- return;
71
- }
72
- // Verify signature
73
- const timestamp = req.headers['x-slack-request-timestamp'];
74
- const signature = req.headers['x-slack-signature'];
75
- const rawBody = req.rawBody ?? JSON.stringify(body);
76
- if (!verifySignature(signingSecret, timestamp, rawBody, signature)) {
77
- console.warn('[slack] signature verification failed');
78
- res.status(401).send('Invalid signature');
79
- return;
80
- }
81
- // Acknowledge immediately (Slack 3s timeout)
82
- res.status(200).send();
83
- processSlackEvent(body, engine, appId, slack, botToken, userNameCache, activeThreads, processedEvents, processingEvents, botUserId);
84
- });
85
- }
86
- },
87
- createOutput(context) {
88
- // Mark thread as active when the bot creates output (i.e. responds)
89
- activeThreads.add(context.conversationId);
90
- return createSlackOutput(slack, context, thinkingMessage);
91
- },
92
- async stop() {
93
- if (socketClient) {
94
- try {
95
- socketClient.disconnect();
96
- console.log('[slack] socket mode disconnected');
97
- }
98
- catch (err) {
99
- console.warn('[slack] socket mode disconnect failed:', err);
100
- }
101
- socketClient = undefined;
102
- }
103
- },
188
+ type,
189
+ channel,
190
+ ts,
191
+ userId: readString(event, 'user') ?? '',
192
+ text: readString(event, 'text') ?? '',
193
+ threadTs: readString(event, 'thread_ts'),
194
+ files: readFileList(event, 'files'),
195
+ subtype: readString(event, 'subtype'),
196
+ botId: readString(event, 'bot_id'),
197
+ };
198
+ }
199
+ function parseReactionEvent(body) {
200
+ const event = readRecord(body, 'event');
201
+ if (!event)
202
+ return null;
203
+ if (readString(event, 'type') !== 'reaction_added')
204
+ return null;
205
+ const item = readRecord(event, 'item');
206
+ if (!item || readString(item, 'type') !== 'message')
207
+ return null;
208
+ const channel = readString(item, 'channel');
209
+ const messageTs = readString(item, 'ts');
210
+ const reaction = readString(event, 'reaction');
211
+ const userId = readString(event, 'user');
212
+ if (!channel || !messageTs || !reaction || !userId)
213
+ return null;
214
+ return {
215
+ type: 'reaction_added',
216
+ channel,
217
+ messageTs,
218
+ reaction,
219
+ userId,
220
+ itemUserId: readString(event, 'item_user'),
221
+ eventTs: readString(event, 'event_ts'),
222
+ };
223
+ }
224
+ export function selectMessageCandidates(event, triggerConfig, activeThreads, botUserId) {
225
+ const candidates = [];
226
+ const threadKey = buildConversationId(event.channel, event.threadTs, event.ts);
227
+ const mentionActive = triggerConfig.mention !== undefined
228
+ && (event.type === 'app_mention' || containsBotMention(event.text, botUserId));
229
+ if (mentionActive && triggerConfig.mention !== undefined) {
230
+ candidates.push({ kind: 'mention', source: triggerConfig.mention });
231
+ }
232
+ const threadActive = triggerConfig.thread !== undefined
233
+ && event.threadTs !== undefined
234
+ && activeThreads.has(threadKey);
235
+ if (threadActive && triggerConfig.thread !== undefined) {
236
+ candidates.push({ kind: 'thread', source: triggerConfig.thread });
237
+ }
238
+ const channelActive = triggerConfig.channel !== undefined
239
+ && event.threadTs === undefined;
240
+ if (channelActive && triggerConfig.channel !== undefined) {
241
+ candidates.push({ kind: 'channel', source: triggerConfig.channel });
242
+ }
243
+ return candidates.sort((a, b) => messageTriggerPriority(a.kind) - messageTriggerPriority(b.kind));
244
+ }
245
+ function messageTriggerPriority(kind) {
246
+ switch (kind) {
247
+ case 'mention':
248
+ return 0;
249
+ case 'thread':
250
+ return 1;
251
+ case 'channel':
252
+ return 2;
253
+ }
254
+ }
255
+ function buildMessageTriggerEvent(kind, event, userName, body) {
256
+ return {
257
+ kind,
258
+ channelId: event.channel,
259
+ userId: event.userId,
260
+ userName,
261
+ text: event.text,
262
+ ts: event.ts,
263
+ threadTs: event.threadTs,
264
+ files: toAttachmentMetadata(event.files),
265
+ raw: body,
104
266
  };
105
267
  }
106
- // ─── Shared event processing ────────────────────────────────────────────────
107
268
  async function resolveUserName(slack, userId, cache) {
108
269
  if (!userId)
109
270
  return '';
@@ -119,15 +280,10 @@ async function resolveUserName(slack, userId, cache) {
119
280
  }
120
281
  catch (err) {
121
282
  console.warn(`[slack] failed to resolve username for ${userId}:`, err);
122
- cache.set(userId, userId); // cache the fallback to avoid repeated failures
283
+ cache.set(userId, userId);
123
284
  return userId;
124
285
  }
125
286
  }
126
- /**
127
- * Check if the bot participated in the given thread — either mentioned or
128
- * posted a message. Used as a fallback when activeThreads (in-memory) doesn't
129
- * have the thread (e.g. after a restart).
130
- */
131
287
  async function wasBotInThread(slack, channel, threadTs, botUserId) {
132
288
  if (!botUserId)
133
289
  return false;
@@ -135,193 +291,389 @@ async function wasBotInThread(slack, channel, threadTs, botUserId) {
135
291
  const result = await slack.conversations.replies({
136
292
  channel,
137
293
  ts: threadTs,
138
- limit: 50, // check up to 50 messages
294
+ limit: 50,
139
295
  });
140
296
  const mentionPattern = `<@${botUserId}>`;
141
- return result.messages?.some(m => m.text?.includes(mentionPattern) || m.user === botUserId) ?? false;
297
+ return result.messages?.some((message) => message.text?.includes(mentionPattern) || message.user === botUserId) ?? false;
142
298
  }
143
299
  catch (err) {
144
- console.warn(`[slack] failed to check thread history for bot participation:`, err);
300
+ console.warn('[slack] failed to check thread history for bot participation:', err);
145
301
  return false;
146
302
  }
147
303
  }
148
- /**
149
- * Shared event processor — works identically for HTTP and Socket Mode payloads.
150
- * The outer envelope (`body`) has the same shape in both modes.
151
- */
152
- /** Download Slack files to a local temp directory and return FileAttachments with localPath. */
153
304
  async function downloadSlackFiles(files, botToken) {
154
305
  const dir = join(tmpdir(), 'slack-files');
155
306
  await mkdir(dir, { recursive: true });
156
- return Promise.all(files.map(async (f) => {
307
+ return Promise.all(files.map(async (file) => {
157
308
  const base = {
158
- id: f.id,
159
- name: f.name,
160
- mimeType: f.mimetype,
161
- url: f.url_private,
309
+ id: file.id,
310
+ name: file.name,
311
+ mimeType: file.mimetype,
312
+ url: file.url_private,
162
313
  };
163
- if (!f.url_private)
314
+ if (!file.url_private)
164
315
  return base;
165
316
  try {
166
- const response = await fetch(f.url_private, {
317
+ const response = await fetch(file.url_private, {
167
318
  headers: { Authorization: `Bearer ${botToken}` },
168
319
  });
169
320
  if (!response.ok) {
170
- console.warn(`[slack] file download failed for ${f.id}: ${response.status}`);
321
+ console.warn(`[slack] file download failed for ${file.id}: ${response.status}`);
171
322
  return base;
172
323
  }
173
- const buf = Buffer.from(await response.arrayBuffer());
174
- const ext = f.name?.includes('.') ? '' : `.${(f.mimetype ?? '').split('/')[1] ?? 'bin'}`;
175
- const localPath = join(dir, `${f.id}_${f.name ?? 'file'}${ext}`);
176
- await writeFile(localPath, buf);
177
- console.log(`[slack] downloaded file ${f.id} → ${localPath} (${buf.length} bytes)`);
324
+ const buffer = Buffer.from(await response.arrayBuffer());
325
+ const ext = file.name.includes('.') ? '' : `.${file.mimetype.split('/')[1] ?? 'bin'}`;
326
+ const localPath = join(dir, `${file.id}_${file.name}${ext}`);
327
+ await writeFile(localPath, buffer);
178
328
  return { ...base, localPath };
179
329
  }
180
330
  catch (err) {
181
- console.warn(`[slack] file download error for ${f.id}:`, err);
331
+ console.warn(`[slack] file download error for ${file.id}:`, err);
182
332
  return base;
183
333
  }
184
334
  }));
185
335
  }
186
- async function processSlackEvent(body, engine, appId, slack, botToken, userNameCache, activeThreads, processedEvents, processingEvents, botUserId) {
187
- const event = body?.event;
188
- if (!event) {
189
- console.log('[slack] no event in body, type:', body?.type);
190
- return;
336
+ async function lookupSlackMessage(slack, channel, ts, userNameCache) {
337
+ try {
338
+ const result = await slack.conversations.history({
339
+ channel,
340
+ latest: ts,
341
+ oldest: ts,
342
+ inclusive: true,
343
+ limit: 1,
344
+ });
345
+ const rawMessage = result.messages?.[0];
346
+ if (!rawMessage)
347
+ return null;
348
+ const userId = typeof rawMessage.user === 'string' ? rawMessage.user : undefined;
349
+ const userName = userId
350
+ ? await resolveUserName(slack, userId, userNameCache)
351
+ : (typeof rawMessage.username === 'string' ? rawMessage.username : undefined);
352
+ const files = Array.isArray(rawMessage.files)
353
+ ? rawMessage.files.flatMap((item) => {
354
+ if (!isRecord(item))
355
+ return [];
356
+ const id = readString(item, 'id');
357
+ const name = readString(item, 'name');
358
+ const mimetype = readString(item, 'mimetype');
359
+ if (!id || !name || !mimetype)
360
+ return [];
361
+ return [{
362
+ id,
363
+ name,
364
+ mimetype,
365
+ url_private: readString(item, 'url_private'),
366
+ }];
367
+ })
368
+ : [];
369
+ const rawRecord = rawMessage;
370
+ return {
371
+ channel,
372
+ ts: typeof rawMessage.ts === 'string' ? rawMessage.ts : ts,
373
+ threadTs: typeof rawMessage.thread_ts === 'string' ? rawMessage.thread_ts : undefined,
374
+ text: typeof rawMessage.text === 'string' ? rawMessage.text : '',
375
+ userId,
376
+ userName,
377
+ botId: readString(rawRecord, 'bot_id'),
378
+ files,
379
+ raw: rawRecord,
380
+ };
381
+ }
382
+ catch (err) {
383
+ console.warn(`[slack] failed to lookup message ${channel}:${ts}:`, err);
384
+ return null;
385
+ }
386
+ }
387
+ async function buildReactionContext(body, reaction, slack, botToken, userNameCache) {
388
+ const targetMessage = await lookupSlackMessage(slack, reaction.channel, reaction.messageTs, userNameCache);
389
+ if (!targetMessage)
390
+ return null;
391
+ const actorUserName = await resolveUserName(slack, reaction.userId, userNameCache);
392
+ const files = targetMessage.files.length > 0
393
+ ? await downloadSlackFiles(targetMessage.files, botToken)
394
+ : undefined;
395
+ const threadTs = targetMessage.threadTs ?? targetMessage.ts;
396
+ return {
397
+ conversationId: `${reaction.channel}:${threadTs}`,
398
+ files,
399
+ filterEvent: {
400
+ kind: 'reaction',
401
+ channelId: reaction.channel,
402
+ userId: reaction.userId,
403
+ userName: actorUserName,
404
+ messageUserId: targetMessage.userId,
405
+ messageUserName: targetMessage.userName,
406
+ messageBotId: targetMessage.botId,
407
+ text: targetMessage.text,
408
+ ts: targetMessage.ts,
409
+ threadTs,
410
+ reaction: reaction.reaction,
411
+ files: toAttachmentMetadata(targetMessage.files),
412
+ raw: {
413
+ body,
414
+ message: targetMessage.raw,
415
+ },
416
+ },
417
+ };
418
+ }
419
+ function createReactionEventId(reaction) {
420
+ return [
421
+ 'reaction',
422
+ reaction.channel,
423
+ reaction.messageTs,
424
+ reaction.userId,
425
+ reaction.reaction,
426
+ reaction.eventTs ?? '',
427
+ ].join(':');
428
+ }
429
+ function createMessageEventId(event) {
430
+ return `${event.channel}:${event.ts}`;
431
+ }
432
+ function commitProcessedEvent(processedEvents, processingEvents, eventId) {
433
+ processedEvents.add(eventId);
434
+ processingEvents.delete(eventId);
435
+ if (processedEvents.size > 500) {
436
+ const excess = processedEvents.size - 500;
437
+ let removed = 0;
438
+ for (const entry of processedEvents) {
439
+ if (removed >= excess)
440
+ break;
441
+ processedEvents.delete(entry);
442
+ removed++;
443
+ }
191
444
  }
192
- // Handle reaction_added: :x: emoji aborts in-flight turn
193
- if (event.type === 'reaction_added' && event.reaction === 'x') {
194
- const item = event.item;
195
- if (item?.type !== 'message')
445
+ }
446
+ export async function processSlackEvent(body, engine, slack, botToken, userNameCache, activeThreads, processedEvents, processingEvents, triggerConfig, promptBaseDir, botUserId) {
447
+ const reactionEvent = parseReactionEvent(body);
448
+ if (reactionEvent) {
449
+ const rule = triggerConfig.reactions[reactionEvent.reaction];
450
+ if (!rule)
451
+ return;
452
+ const eventId = createReactionEventId(reactionEvent);
453
+ if (processedEvents.has(eventId) || processingEvents.has(eventId)) {
454
+ console.log(`[slack] skipping duplicate reaction ${eventId}`);
196
455
  return;
197
- // Resolve the thread_ts of the reacted-to message
198
- const channel = item.channel;
199
- const messageTs = item.ts;
456
+ }
457
+ processingEvents.add(eventId);
200
458
  try {
201
- const result = await slack.conversations.replies({
202
- channel,
203
- ts: messageTs,
204
- limit: 1,
205
- inclusive: true,
206
- });
207
- const msg = result.messages?.[0];
208
- const threadTs = msg?.thread_ts ?? msg?.ts ?? messageTs;
209
- const conversationId = `${channel}:${threadTs}`;
210
- const aborted = engine.abortConversation(conversationId);
211
- if (aborted) {
212
- console.log(`[slack] :x: reaction aborted conversation ${conversationId}`);
213
- // React with :x: to confirm abort
214
- try {
215
- await slack.reactions.add({ channel, name: 'x', timestamp: messageTs });
216
- }
217
- catch { /* ignore */ }
459
+ const context = await buildReactionContext(body, reactionEvent, slack, botToken, userNameCache);
460
+ if (!context) {
461
+ processingEvents.delete(eventId);
462
+ return;
218
463
  }
219
- else {
220
- console.log(`[slack] :x: reaction on ${conversationId} — no active turn to abort`);
464
+ const passed = await runReactionTriggerFilter(rule, context.filterEvent);
465
+ if (!passed) {
466
+ commitProcessedEvent(processedEvents, processingEvents, eventId);
467
+ return;
468
+ }
469
+ if (isReactionAbortRule(rule)) {
470
+ const aborted = engine.abortConversation(context.conversationId);
471
+ if (aborted && reactionEvent.reaction === 'x') {
472
+ try {
473
+ await slack.reactions.add({
474
+ channel: reactionEvent.channel,
475
+ name: 'x',
476
+ timestamp: reactionEvent.messageTs,
477
+ });
478
+ }
479
+ catch {
480
+ // ignore confirmation reaction failures
481
+ }
482
+ }
483
+ commitProcessedEvent(processedEvents, processingEvents, eventId);
484
+ return;
221
485
  }
486
+ const prompt = await resolvePromptSource(rule, promptBaseDir);
487
+ const inbound = {
488
+ connector: 'slack',
489
+ conversationId: context.conversationId,
490
+ userId: context.filterEvent.userId,
491
+ userName: context.filterEvent.userName ?? '',
492
+ text: buildReactionInputText(prompt, context.filterEvent),
493
+ files: context.files,
494
+ raw: context.filterEvent.raw,
495
+ };
496
+ activeThreads.add(context.conversationId);
497
+ commitProcessedEvent(processedEvents, processingEvents, eventId);
498
+ await engine.submitTurn(inbound);
499
+ return;
222
500
  }
223
501
  catch (err) {
224
- console.warn('[slack] failed to handle :x: reaction:', err);
502
+ processingEvents.delete(eventId);
503
+ console.error('[slack] failed to process reaction event:', err);
504
+ return;
225
505
  }
226
- return;
227
506
  }
228
- // Only handle app_mention and message events
229
- if (event.type !== 'app_mention' && event.type !== 'message') {
230
- console.log('[slack] ignoring event type:', event.type);
507
+ const messageEvent = parseMessageEvent(body);
508
+ if (!messageEvent) {
509
+ const eventRecord = readRecord(body, 'event');
510
+ console.log('[slack] ignoring event type:', eventRecord ? readString(eventRecord, 'type') : '(none)');
231
511
  return;
232
512
  }
233
- if (event.bot_id)
234
- return; // Ignore bot messages
235
- // Ignore message subtypes (edits, deletes, etc.) but allow file_share (image/file attachments)
236
- if (event.subtype && event.subtype !== 'file_share')
513
+ if (messageEvent.botId)
237
514
  return;
238
- // Deduplicate: Slack sends both app_mention and message for the same @mention.
239
- // Two-phase approach:
240
- // 1. processingEvents claimed immediately (before await) to prevent race conditions
241
- // 2. processedEvents — committed only after we confirm the event will be handled
242
- // This prevents the bug where a `message` event claims the eventId, exits early
243
- // (no thread_ts / inactive thread), and then the subsequent `app_mention` is skipped.
244
- const eventId = `${event.channel}:${event.ts}`;
515
+ if (messageEvent.subtype && messageEvent.subtype !== 'file_share')
516
+ return;
517
+ const eventId = createMessageEventId(messageEvent);
245
518
  if (processedEvents.has(eventId)) {
246
- console.log(`[slack] skipping duplicate event ${event.type} ${eventId}`);
519
+ console.log(`[slack] skipping duplicate event ${messageEvent.type} ${eventId}`);
247
520
  return;
248
521
  }
249
- // app_mention takes priority — if a message event is currently being processed
250
- // (in-flight, not yet committed), an app_mention can steal the slot.
251
- if (event.type === 'app_mention') {
252
- // Always allow app_mention to proceed — remove any in-flight message claim
522
+ if (messageEvent.type === 'app_mention') {
253
523
  processingEvents.delete(eventId);
254
524
  }
255
525
  else if (processingEvents.has(eventId)) {
256
- // Another event (likely app_mention) is already processing this
257
- console.log(`[slack] skipping duplicate event ${event.type} ${eventId} (in-flight)`);
526
+ console.log(`[slack] skipping duplicate event ${messageEvent.type} ${eventId} (in-flight)`);
258
527
  return;
259
528
  }
260
- // Claim the slot immediately (before any await) to prevent concurrent duplicates
261
529
  processingEvents.add(eventId);
262
- const threadKey = `${event.channel}:${event.thread_ts ?? event.ts}`;
263
- // For app_mention: always process and track the thread
264
- if (event.type === 'app_mention') {
265
- activeThreads.add(threadKey);
266
- }
267
- // For message events: only process if it's a reply in an active thread
268
- if (event.type === 'message') {
269
- if (!event.thread_ts) {
270
- // Top-level channel message without mention — ignore
271
- processingEvents.delete(eventId);
272
- return;
273
- }
274
- if (!activeThreads.has(threadKey)) {
275
- // Thread not tracked in memory — check thread history as fallback (e.g. after restart)
276
- const participated = await wasBotInThread(slack, event.channel, event.thread_ts, botUserId);
277
- if (participated) {
278
- console.log(`[slack] recovered active thread from history: ${threadKey}`);
279
- activeThreads.add(threadKey);
280
- }
281
- else {
282
- console.log(`[slack] ignoring thread reply in inactive thread ${threadKey}`);
283
- processingEvents.delete(eventId);
284
- return;
285
- }
530
+ const conversationId = buildConversationId(messageEvent.channel, messageEvent.threadTs, messageEvent.ts);
531
+ if (messageEvent.threadTs && !activeThreads.has(conversationId) && triggerConfig.thread !== undefined) {
532
+ const participated = await wasBotInThread(slack, messageEvent.channel, messageEvent.threadTs, botUserId);
533
+ if (participated) {
534
+ activeThreads.add(conversationId);
535
+ console.log(`[slack] recovered active thread from history: ${conversationId}`);
286
536
  }
287
537
  }
288
- // Event will be processed commit to processedEvents and release processingEvents
289
- processedEvents.add(eventId);
290
- processingEvents.delete(eventId);
291
- // Evict oldest entries when exceeding 500 to prevent unbounded growth
292
- if (processedEvents.size > 500) {
293
- const excess = processedEvents.size - 500;
294
- let removed = 0;
295
- for (const entry of processedEvents) {
296
- if (removed >= excess)
538
+ const candidates = selectMessageCandidates(messageEvent, triggerConfig, activeThreads, botUserId);
539
+ if (candidates.length === 0) {
540
+ processingEvents.delete(eventId);
541
+ return;
542
+ }
543
+ const userName = await resolveUserName(slack, messageEvent.userId, userNameCache);
544
+ let selected = null;
545
+ try {
546
+ for (const candidate of candidates) {
547
+ const triggerEvent = buildMessageTriggerEvent(candidate.kind, messageEvent, userName, body);
548
+ const passed = await runMessageTriggerFilter(candidate.source, triggerEvent);
549
+ if (passed) {
550
+ selected = candidate;
297
551
  break;
298
- processedEvents.delete(entry);
299
- removed++;
552
+ }
300
553
  }
301
554
  }
302
- const userId = event.user ?? '';
303
- const userName = await resolveUserName(slack, userId, userNameCache);
304
- console.log(`[slack] ${event.type} from ${userName}(${userId}) in ${event.channel} [thread:${event.thread_ts ?? 'none'}]`);
305
- // Download attached files to local temp directory
306
- const files = event.files?.length
307
- ? await downloadSlackFiles(event.files, botToken)
308
- : undefined;
309
- const inbound = {
310
- connector: 'slack',
311
- conversationId: threadKey,
312
- userId,
313
- userName,
314
- text: event.text ?? '',
315
- files,
316
- raw: body,
317
- };
555
+ catch (err) {
556
+ processingEvents.delete(eventId);
557
+ console.error('[slack] message trigger filter failed:', err);
558
+ return;
559
+ }
560
+ if (!selected) {
561
+ processingEvents.delete(eventId);
562
+ return;
563
+ }
318
564
  try {
565
+ const prompt = await resolvePromptSource(selected.source, promptBaseDir);
566
+ const files = messageEvent.files.length > 0
567
+ ? await downloadSlackFiles(messageEvent.files, botToken)
568
+ : undefined;
569
+ const inbound = {
570
+ connector: 'slack',
571
+ conversationId,
572
+ userId: messageEvent.userId,
573
+ userName,
574
+ text: buildMessageInputText(prompt, messageEvent.text),
575
+ files,
576
+ raw: {
577
+ ...body,
578
+ triggerKind: selected.kind,
579
+ },
580
+ };
581
+ activeThreads.add(conversationId);
582
+ commitProcessedEvent(processedEvents, processingEvents, eventId);
319
583
  await engine.submitTurn(inbound);
320
584
  }
321
585
  catch (err) {
322
- console.error('[slack] submitTurn error:', err);
586
+ processingEvents.delete(eventId);
587
+ console.error('[slack] failed to process message event:', err);
323
588
  }
324
589
  }
590
+ export function slackConnector(options) {
591
+ const { botToken, thinkingMessage } = options;
592
+ const slack = new WebClient(botToken);
593
+ const userNameCache = new Map();
594
+ const activeThreads = new Set();
595
+ const processedEvents = new Set();
596
+ const processingEvents = new Set();
597
+ const triggerConfig = normalizeTriggerConfig(options.triggers);
598
+ let botUserId;
599
+ let socketClient;
600
+ let promptBaseDir = process.cwd();
601
+ return {
602
+ name: 'slack',
603
+ registerRoutes(server, engine, context) {
604
+ promptBaseDir = context?.promptBaseDir ?? process.cwd();
605
+ const resolveBotUserId = async () => {
606
+ if (botUserId)
607
+ return;
608
+ try {
609
+ const auth = await slack.auth.test();
610
+ botUserId = auth.user_id ?? undefined;
611
+ console.log(`[slack] resolved bot user id: ${botUserId}`);
612
+ }
613
+ catch (err) {
614
+ console.warn('[slack] failed to resolve bot user id:', err);
615
+ }
616
+ };
617
+ const mode = options.mode ?? 'http';
618
+ if (mode === 'socket') {
619
+ const { appToken } = options;
620
+ socketClient = new SocketModeClient({ appToken });
621
+ const handleSocketEvent = async ({ body, ack }) => {
622
+ await ack();
623
+ await resolveBotUserId();
624
+ await processSlackEvent(body, engine, slack, botToken, userNameCache, activeThreads, processedEvents, processingEvents, triggerConfig, promptBaseDir, botUserId);
625
+ };
626
+ socketClient.on('app_mention', handleSocketEvent);
627
+ socketClient.on('message', handleSocketEvent);
628
+ socketClient.on('reaction_added', handleSocketEvent);
629
+ socketClient.start().then(() => {
630
+ console.log('[slack] socket mode connected');
631
+ }).catch((err) => {
632
+ console.error('[slack] socket mode connection failed:', err);
633
+ });
634
+ return;
635
+ }
636
+ const { signingSecret } = options;
637
+ server.post('/api/slack/events', async (req, res) => {
638
+ const request = req;
639
+ const response = res;
640
+ const body = isRecord(request.body) ? request.body : {};
641
+ if (body.type === 'url_verification' && typeof body.challenge === 'string') {
642
+ response.status(200).json({ challenge: body.challenge });
643
+ return;
644
+ }
645
+ const headers = request.headers ?? {};
646
+ const timestamp = headers['x-slack-request-timestamp'];
647
+ const signature = headers['x-slack-signature'];
648
+ const rawBody = request.rawBody ?? JSON.stringify(body);
649
+ if (!timestamp || !signature || !verifySignature(signingSecret, timestamp, rawBody, signature)) {
650
+ console.warn('[slack] signature verification failed');
651
+ response.status(401).send('Invalid signature');
652
+ return;
653
+ }
654
+ response.status(200).send('');
655
+ await resolveBotUserId();
656
+ await processSlackEvent(body, engine, slack, botToken, userNameCache, activeThreads, processedEvents, processingEvents, triggerConfig, promptBaseDir, botUserId);
657
+ });
658
+ },
659
+ createOutput(context) {
660
+ activeThreads.add(context.conversationId);
661
+ return createSlackOutput(slack, context, thinkingMessage);
662
+ },
663
+ async stop() {
664
+ if (!socketClient)
665
+ return;
666
+ try {
667
+ socketClient.disconnect();
668
+ console.log('[slack] socket mode disconnected');
669
+ }
670
+ catch (err) {
671
+ console.warn('[slack] socket mode disconnect failed:', err);
672
+ }
673
+ socketClient = undefined;
674
+ },
675
+ };
676
+ }
325
677
  // ─── Output ─────────────────────────────────────────────────────────────────
326
678
  /**
327
679
  * Slack Output with step accumulation.
@@ -335,7 +687,7 @@ async function processSlackEvent(body, engine, appId, slack, botToken, userNameC
335
687
  * start with the previous text, it means a new assistant message has started
336
688
  * (= new step). The previous text is flushed to the completed-steps buffer.
337
689
  */
338
- function createSlackOutput(slack, context, thinkingMessage) {
690
+ export function createSlackOutput(slack, context, thinkingMessage) {
339
691
  const [channel, threadTs] = context.conversationId.split(':');
340
692
  // --- Accumulated state ---
341
693
  const completedSteps = []; // Flushed step texts
@@ -344,9 +696,11 @@ function createSlackOutput(slack, context, thinkingMessage) {
344
696
  let frozenStepCount = 0; // Steps baked into previous (frozen) messages
345
697
  let lastRenderTime = 0;
346
698
  let finalized = false; // true after sendResult/sendError
699
+ let renderPromise = null;
347
700
  const THROTTLE_MS = 1500;
348
701
  const MAX_BLOCKS = 45; // Leave headroom below Slack's 50-block limit
349
702
  const MAX_TEXT_LENGTH = 2800; // Slack text field limit ~3000 chars; leave buffer
703
+ const FINAL_CHUNK_LENGTH = 2600;
350
704
  // --- Serialize all Slack API calls to prevent race conditions ---
351
705
  let apiQueue = Promise.resolve();
352
706
  function enqueue(fn) {
@@ -397,6 +751,77 @@ function createSlackOutput(slack, context, thinkingMessage) {
397
751
  const combined = parts.join('\n');
398
752
  return markdownToSlack(combined);
399
753
  }
754
+ function createSafeLivePayload(text) {
755
+ const suffix = '\n\n_(계속 생성 중...)_';
756
+ const truncated = text.length + suffix.length > MAX_TEXT_LENGTH
757
+ ? text.slice(0, MAX_TEXT_LENGTH - suffix.length) + suffix
758
+ : text;
759
+ return { text: truncated };
760
+ }
761
+ function splitTextForSlack(text, maxLength) {
762
+ const source = text.trim();
763
+ if (!source)
764
+ return [];
765
+ const chunks = [];
766
+ let remaining = source;
767
+ while (remaining.length > maxLength) {
768
+ const newlineBreak = remaining.lastIndexOf('\n', maxLength);
769
+ const spaceBreak = remaining.lastIndexOf(' ', maxLength);
770
+ const preferredBreak = Math.max(newlineBreak, spaceBreak);
771
+ const splitAt = preferredBreak >= Math.floor(maxLength * 0.6) ? preferredBreak : maxLength;
772
+ const chunk = remaining.slice(0, splitAt).trimEnd();
773
+ if (!chunk) {
774
+ chunks.push(remaining.slice(0, maxLength));
775
+ remaining = remaining.slice(maxLength).trimStart();
776
+ continue;
777
+ }
778
+ chunks.push(chunk);
779
+ remaining = remaining.slice(splitAt).trimStart();
780
+ }
781
+ if (remaining) {
782
+ chunks.push(remaining);
783
+ }
784
+ return chunks;
785
+ }
786
+ async function updateOrCreateMessage(payload) {
787
+ if (activeTs) {
788
+ await slack.chat.update({ channel, ts: activeTs, ...payload });
789
+ return;
790
+ }
791
+ const result = await slack.chat.postMessage({ channel, thread_ts: threadTs, ...payload });
792
+ activeTs = result.ts;
793
+ }
794
+ async function renderFinalInChunks(text) {
795
+ const chunks = splitTextForSlack(text, FINAL_CHUNK_LENGTH);
796
+ if (chunks.length === 0)
797
+ return;
798
+ if (activeTs) {
799
+ await slack.chat.update({ channel, ts: activeTs, text: chunks[0] });
800
+ }
801
+ else {
802
+ const result = await slack.chat.postMessage({ channel, thread_ts: threadTs, text: chunks[0] });
803
+ activeTs = result.ts;
804
+ }
805
+ for (const chunk of chunks.slice(1)) {
806
+ await slack.chat.postMessage({ channel, thread_ts: threadTs, text: chunk });
807
+ }
808
+ }
809
+ async function queueRender(options) {
810
+ if (renderPromise) {
811
+ if (options?.final) {
812
+ await renderPromise;
813
+ return queueRender(options);
814
+ }
815
+ return renderPromise;
816
+ }
817
+ renderPromise = enqueue(async () => {
818
+ await renderMessage(options);
819
+ lastRenderTime = Date.now();
820
+ }).finally(() => {
821
+ renderPromise = null;
822
+ });
823
+ return renderPromise;
824
+ }
400
825
  /** Update or create the active Slack message. Handles overflow. */
401
826
  async function renderMessage(options) {
402
827
  // Determine which steps to render in the active message
@@ -406,9 +831,23 @@ function createSlackOutput(slack, context, thinkingMessage) {
406
831
  if (!payload.text.trim())
407
832
  return;
408
833
  const blockCount = payload.blocks?.length ?? 1;
409
- // --- Overflow check: block count OR text length ---
410
834
  const textLength = payload.text.length;
411
- if (activeTs && (blockCount > MAX_BLOCKS || textLength > MAX_TEXT_LENGTH)) {
835
+ if (options?.final && (blockCount > MAX_BLOCKS || textLength > MAX_TEXT_LENGTH)) {
836
+ await renderFinalInChunks(payload.text);
837
+ return;
838
+ }
839
+ if (!options?.final && (blockCount > MAX_BLOCKS || textLength > MAX_TEXT_LENGTH)) {
840
+ const safePayload = createSafeLivePayload(payload.text);
841
+ try {
842
+ await updateOrCreateMessage(safePayload);
843
+ }
844
+ catch (err) {
845
+ console.warn('[slack] live preview fallback failed:', err);
846
+ }
847
+ return;
848
+ }
849
+ // --- Overflow check: block count OR text length ---
850
+ if (!options?.final && activeTs && (blockCount > MAX_BLOCKS || textLength > MAX_TEXT_LENGTH)) {
412
851
  // 1. Freeze the current message with only its completed steps (no live text)
413
852
  const frozenPayload = renderSteps(completedSteps.slice(frozenStepCount));
414
853
  if (frozenPayload.text.trim()) {
@@ -437,11 +876,7 @@ function createSlackOutput(slack, context, thinkingMessage) {
437
876
  safePayload = overflowPayload;
438
877
  }
439
878
  try {
440
- const result = await slack.chat.postMessage({
441
- channel,
442
- thread_ts: threadTs,
443
- ...safePayload,
444
- });
879
+ const result = await slack.chat.postMessage({ channel, thread_ts: threadTs, ...safePayload });
445
880
  activeTs = result.ts;
446
881
  console.log(`[slack] overflow → new message ts=${result.ts}`);
447
882
  }
@@ -456,30 +891,18 @@ function createSlackOutput(slack, context, thinkingMessage) {
456
891
  await slack.chat.update({ channel, ts: activeTs, ...payload });
457
892
  }
458
893
  catch (err) {
459
- // chat.update failed — likely Slack rejected the payload as too large.
460
- // Treat this as an overflow: freeze the old message as-is and start a
461
- // new message with only recent steps (instead of duplicating everything).
462
- console.warn('[slack] chat.update failed, triggering overflow:', err);
463
- frozenStepCount = Math.max(0, completedSteps.length - 1);
464
- const overflowSteps = completedSteps.slice(frozenStepCount);
465
- const liveTextForOverflow = options?.final ? undefined : currentText;
466
- const overflowPayload = renderSteps(overflowSteps, liveTextForOverflow);
467
- if (overflowPayload.text.trim()) {
468
- // Guard: truncate if even the single-step payload is too large
469
- const obCount = overflowPayload.blocks?.length ?? 1;
470
- const otLen = overflowPayload.text.length;
471
- const safePayload = obCount > MAX_BLOCKS || otLen > MAX_TEXT_LENGTH
472
- ? { text: overflowPayload.text.slice(0, MAX_TEXT_LENGTH - 20) + '\n\n_(truncated)_' }
473
- : overflowPayload;
894
+ if (!options?.final) {
895
+ console.warn('[slack] chat.update failed, switching to live preview fallback:', err);
474
896
  try {
475
- const result = await slack.chat.postMessage({ channel, thread_ts: threadTs, ...safePayload });
476
- activeTs = result.ts;
477
- console.log(`[slack] update-fail overflow → new message ts=${result.ts}`);
897
+ await updateOrCreateMessage(createSafeLivePayload(payload.text));
478
898
  }
479
- catch (err2) {
480
- console.warn('[slack] update-fail overflow postMessage also failed:', err2);
899
+ catch (fallbackErr) {
900
+ console.warn('[slack] live preview fallback failed after update error:', fallbackErr);
481
901
  }
902
+ return;
482
903
  }
904
+ console.warn('[slack] final chat.update failed, switching to chunked fallback:', err);
905
+ await renderFinalInChunks(payload.text);
483
906
  }
484
907
  }
485
908
  else {
@@ -508,16 +931,19 @@ function createSlackOutput(slack, context, thinkingMessage) {
508
931
  const now = Date.now();
509
932
  if (now - lastRenderTime < THROTTLE_MS && activeTs)
510
933
  return;
511
- await enqueue(async () => {
512
- await renderMessage();
513
- lastRenderTime = Date.now();
514
- });
934
+ await queueRender();
515
935
  },
516
936
  async sendResult(text) {
517
937
  finalized = true;
518
938
  // Flush current progress as a completed step if it differs from the result
519
- // Use trimmed comparison to tolerate minor whitespace differences
520
- if (currentText && currentText.trim() !== text.trim()) {
939
+ // When progress text is just a growing preview of the final answer,
940
+ // the final answer should replace it rather than appear twice.
941
+ const currentTrimmed = currentText.trim();
942
+ const finalTrimmed = text.trim();
943
+ const isGrowingPreview = currentTrimmed.length > 0 &&
944
+ finalTrimmed.length > 0 &&
945
+ (finalTrimmed.startsWith(currentTrimmed) || currentTrimmed.startsWith(finalTrimmed));
946
+ if (currentText && currentTrimmed !== finalTrimmed && !isGrowingPreview) {
521
947
  completedSteps.push(currentText);
522
948
  }
523
949
  currentText = '';
@@ -535,15 +961,13 @@ function createSlackOutput(slack, context, thinkingMessage) {
535
961
  completedSteps.push(text);
536
962
  }
537
963
  console.log(`[slack] sendResult: channel=${channel}, thread_ts=${threadTs}, steps=${completedSteps.length}, text.length=${text.length}`);
538
- await enqueue(async () => {
539
- try {
540
- await renderMessage({ final: true });
541
- console.log(`[slack] sendResult ok: ts=${activeTs}`);
542
- }
543
- catch (err) {
544
- console.error(`[slack] sendResult render failed:`, err);
545
- }
546
- });
964
+ try {
965
+ await queueRender({ final: true });
966
+ console.log(`[slack] sendResult ok: ts=${activeTs}`);
967
+ }
968
+ catch (err) {
969
+ console.error(`[slack] sendResult render failed:`, err);
970
+ }
547
971
  },
548
972
  async sendError(message) {
549
973
  finalized = true;