@sena-ai/connector-slack 1.4.1 → 1.4.3
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/__tests__/triggers.test.d.ts +2 -0
- package/dist/__tests__/triggers.test.d.ts.map +1 -0
- package/dist/__tests__/triggers.test.js +205 -0
- package/dist/__tests__/triggers.test.js.map +1 -0
- package/dist/connector.d.ts +95 -1
- package/dist/connector.d.ts.map +1 -1
- package/dist/connector.js +585 -233
- package/dist/connector.js.map +1 -1
- package/package.json +2 -2
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
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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);
|
|
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,
|
|
294
|
+
limit: 50,
|
|
139
295
|
});
|
|
140
296
|
const mentionPattern = `<@${botUserId}>`;
|
|
141
|
-
return result.messages?.some(
|
|
297
|
+
return result.messages?.some((message) => message.text?.includes(mentionPattern) || message.user === botUserId) ?? false;
|
|
142
298
|
}
|
|
143
299
|
catch (err) {
|
|
144
|
-
console.warn(
|
|
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 (
|
|
307
|
+
return Promise.all(files.map(async (file) => {
|
|
157
308
|
const base = {
|
|
158
|
-
id:
|
|
159
|
-
name:
|
|
160
|
-
mimeType:
|
|
161
|
-
url:
|
|
309
|
+
id: file.id,
|
|
310
|
+
name: file.name,
|
|
311
|
+
mimeType: file.mimetype,
|
|
312
|
+
url: file.url_private,
|
|
162
313
|
};
|
|
163
|
-
if (!
|
|
314
|
+
if (!file.url_private)
|
|
164
315
|
return base;
|
|
165
316
|
try {
|
|
166
|
-
const response = await fetch(
|
|
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 ${
|
|
321
|
+
console.warn(`[slack] file download failed for ${file.id}: ${response.status}`);
|
|
171
322
|
return base;
|
|
172
323
|
}
|
|
173
|
-
const
|
|
174
|
-
const ext =
|
|
175
|
-
const localPath = join(dir, `${
|
|
176
|
-
await writeFile(localPath,
|
|
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 ${
|
|
331
|
+
console.warn(`[slack] file download error for ${file.id}:`, err);
|
|
182
332
|
return base;
|
|
183
333
|
}
|
|
184
334
|
}));
|
|
185
335
|
}
|
|
186
|
-
async function
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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)
|
|
196
451
|
return;
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
452
|
+
const eventId = createReactionEventId(reactionEvent);
|
|
453
|
+
if (processedEvents.has(eventId) || processingEvents.has(eventId)) {
|
|
454
|
+
console.log(`[slack] skipping duplicate reaction ${eventId}`);
|
|
455
|
+
return;
|
|
456
|
+
}
|
|
457
|
+
processingEvents.add(eventId);
|
|
200
458
|
try {
|
|
201
|
-
const
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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
|
-
|
|
220
|
-
|
|
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
|
-
|
|
502
|
+
processingEvents.delete(eventId);
|
|
503
|
+
console.error('[slack] failed to process reaction event:', err);
|
|
504
|
+
return;
|
|
225
505
|
}
|
|
226
|
-
return;
|
|
227
506
|
}
|
|
228
|
-
|
|
229
|
-
if (
|
|
230
|
-
|
|
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 (
|
|
234
|
-
return;
|
|
235
|
-
|
|
236
|
-
if (event.subtype && event.subtype !== 'file_share')
|
|
513
|
+
if (messageEvent.botId)
|
|
514
|
+
return;
|
|
515
|
+
if (messageEvent.subtype && messageEvent.subtype !== 'file_share')
|
|
237
516
|
return;
|
|
238
|
-
|
|
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}`;
|
|
517
|
+
const eventId = createMessageEventId(messageEvent);
|
|
245
518
|
if (processedEvents.has(eventId)) {
|
|
246
|
-
console.log(`[slack] skipping duplicate event ${
|
|
519
|
+
console.log(`[slack] skipping duplicate event ${messageEvent.type} ${eventId}`);
|
|
247
520
|
return;
|
|
248
521
|
}
|
|
249
|
-
|
|
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
|
-
|
|
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
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
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
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
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
|
-
|
|
299
|
-
removed++;
|
|
552
|
+
}
|
|
300
553
|
}
|
|
301
554
|
}
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
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
|
-
|
|
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.
|