@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/__tests__/output.test.d.ts +2 -0
- package/dist/__tests__/output.test.d.ts.map +1 -0
- package/dist/__tests__/output.test.js +84 -0
- package/dist/__tests__/output.test.js.map +1 -0
- 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 +117 -1
- package/dist/connector.d.ts.map +1 -1
- package/dist/connector.js +700 -276
- 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)
|
|
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
|
-
|
|
198
|
-
|
|
199
|
-
const messageTs = item.ts;
|
|
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; // 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
|
-
|
|
239
|
-
|
|
240
|
-
|
|
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 ${
|
|
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.
|
|
@@ -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 (
|
|
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
|
-
|
|
460
|
-
|
|
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
|
-
|
|
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 (
|
|
480
|
-
console.warn('[slack]
|
|
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
|
|
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
|
-
//
|
|
520
|
-
|
|
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
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
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;
|