@chat-adapter/slack 4.0.0
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/index.d.ts +215 -0
- package/dist/index.js +1075 -0
- package/dist/index.js.map +1 -0
- package/package.json +47 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1075 @@
|
|
|
1
|
+
// src/index.ts
|
|
2
|
+
import { createHmac, timingSafeEqual } from "crypto";
|
|
3
|
+
import { WebClient } from "@slack/web-api";
|
|
4
|
+
import {
|
|
5
|
+
convertEmojiPlaceholders as convertEmojiPlaceholders2,
|
|
6
|
+
defaultEmojiResolver,
|
|
7
|
+
isCardElement,
|
|
8
|
+
RateLimitError
|
|
9
|
+
} from "chat";
|
|
10
|
+
|
|
11
|
+
// src/cards.ts
|
|
12
|
+
import {
|
|
13
|
+
convertEmojiPlaceholders
|
|
14
|
+
} from "chat";
|
|
15
|
+
function convertEmoji(text) {
|
|
16
|
+
return convertEmojiPlaceholders(text, "slack");
|
|
17
|
+
}
|
|
18
|
+
function cardToBlockKit(card) {
|
|
19
|
+
const blocks = [];
|
|
20
|
+
if (card.title) {
|
|
21
|
+
blocks.push({
|
|
22
|
+
type: "header",
|
|
23
|
+
text: {
|
|
24
|
+
type: "plain_text",
|
|
25
|
+
text: convertEmoji(card.title),
|
|
26
|
+
emoji: true
|
|
27
|
+
}
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
if (card.subtitle) {
|
|
31
|
+
blocks.push({
|
|
32
|
+
type: "context",
|
|
33
|
+
elements: [
|
|
34
|
+
{
|
|
35
|
+
type: "mrkdwn",
|
|
36
|
+
text: convertEmoji(card.subtitle)
|
|
37
|
+
}
|
|
38
|
+
]
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
if (card.imageUrl) {
|
|
42
|
+
blocks.push({
|
|
43
|
+
type: "image",
|
|
44
|
+
image_url: card.imageUrl,
|
|
45
|
+
alt_text: card.title || "Card image"
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
for (const child of card.children) {
|
|
49
|
+
const childBlocks = convertChildToBlocks(child);
|
|
50
|
+
blocks.push(...childBlocks);
|
|
51
|
+
}
|
|
52
|
+
return blocks;
|
|
53
|
+
}
|
|
54
|
+
function convertChildToBlocks(child) {
|
|
55
|
+
switch (child.type) {
|
|
56
|
+
case "text":
|
|
57
|
+
return [convertTextToBlock(child)];
|
|
58
|
+
case "image":
|
|
59
|
+
return [convertImageToBlock(child)];
|
|
60
|
+
case "divider":
|
|
61
|
+
return [convertDividerToBlock(child)];
|
|
62
|
+
case "actions":
|
|
63
|
+
return [convertActionsToBlock(child)];
|
|
64
|
+
case "section":
|
|
65
|
+
return convertSectionToBlocks(child);
|
|
66
|
+
case "fields":
|
|
67
|
+
return [convertFieldsToBlock(child)];
|
|
68
|
+
default:
|
|
69
|
+
return [];
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
function convertTextToBlock(element) {
|
|
73
|
+
const text = convertEmoji(element.content);
|
|
74
|
+
let formattedText = text;
|
|
75
|
+
if (element.style === "bold") {
|
|
76
|
+
formattedText = `*${text}*`;
|
|
77
|
+
} else if (element.style === "muted") {
|
|
78
|
+
return {
|
|
79
|
+
type: "context",
|
|
80
|
+
elements: [{ type: "mrkdwn", text }]
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
return {
|
|
84
|
+
type: "section",
|
|
85
|
+
text: {
|
|
86
|
+
type: "mrkdwn",
|
|
87
|
+
text: formattedText
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
function convertImageToBlock(element) {
|
|
92
|
+
return {
|
|
93
|
+
type: "image",
|
|
94
|
+
image_url: element.url,
|
|
95
|
+
alt_text: element.alt || "Image"
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
function convertDividerToBlock(_element) {
|
|
99
|
+
return { type: "divider" };
|
|
100
|
+
}
|
|
101
|
+
function convertActionsToBlock(element) {
|
|
102
|
+
const elements = element.children.map(
|
|
103
|
+
(button) => convertButtonToElement(button)
|
|
104
|
+
);
|
|
105
|
+
return {
|
|
106
|
+
type: "actions",
|
|
107
|
+
elements
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
function convertButtonToElement(button) {
|
|
111
|
+
const element = {
|
|
112
|
+
type: "button",
|
|
113
|
+
text: {
|
|
114
|
+
type: "plain_text",
|
|
115
|
+
text: convertEmoji(button.label),
|
|
116
|
+
emoji: true
|
|
117
|
+
},
|
|
118
|
+
action_id: button.id
|
|
119
|
+
};
|
|
120
|
+
if (button.value) {
|
|
121
|
+
element.value = button.value;
|
|
122
|
+
}
|
|
123
|
+
if (button.style === "primary") {
|
|
124
|
+
element.style = "primary";
|
|
125
|
+
} else if (button.style === "danger") {
|
|
126
|
+
element.style = "danger";
|
|
127
|
+
}
|
|
128
|
+
return element;
|
|
129
|
+
}
|
|
130
|
+
function convertSectionToBlocks(element) {
|
|
131
|
+
const blocks = [];
|
|
132
|
+
for (const child of element.children) {
|
|
133
|
+
blocks.push(...convertChildToBlocks(child));
|
|
134
|
+
}
|
|
135
|
+
return blocks;
|
|
136
|
+
}
|
|
137
|
+
function convertFieldsToBlock(element) {
|
|
138
|
+
const fields = [];
|
|
139
|
+
for (const field of element.children) {
|
|
140
|
+
fields.push({
|
|
141
|
+
type: "mrkdwn",
|
|
142
|
+
text: `*${convertEmoji(field.label)}*
|
|
143
|
+
${convertEmoji(field.value)}`
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
return {
|
|
147
|
+
type: "section",
|
|
148
|
+
fields
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
function cardToFallbackText(card) {
|
|
152
|
+
const parts = [];
|
|
153
|
+
if (card.title) {
|
|
154
|
+
parts.push(`*${convertEmoji(card.title)}*`);
|
|
155
|
+
}
|
|
156
|
+
if (card.subtitle) {
|
|
157
|
+
parts.push(convertEmoji(card.subtitle));
|
|
158
|
+
}
|
|
159
|
+
for (const child of card.children) {
|
|
160
|
+
const text = childToFallbackText(child);
|
|
161
|
+
if (text) {
|
|
162
|
+
parts.push(text);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
return parts.join("\n");
|
|
166
|
+
}
|
|
167
|
+
function childToFallbackText(child) {
|
|
168
|
+
switch (child.type) {
|
|
169
|
+
case "text":
|
|
170
|
+
return convertEmoji(child.content);
|
|
171
|
+
case "fields":
|
|
172
|
+
return child.children.map((f) => `${convertEmoji(f.label)}: ${convertEmoji(f.value)}`).join("\n");
|
|
173
|
+
case "actions":
|
|
174
|
+
return `[${child.children.map((b) => convertEmoji(b.label)).join("] [")}]`;
|
|
175
|
+
case "section":
|
|
176
|
+
return child.children.map((c) => childToFallbackText(c)).filter(Boolean).join("\n");
|
|
177
|
+
default:
|
|
178
|
+
return null;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// src/markdown.ts
|
|
183
|
+
import {
|
|
184
|
+
BaseFormatConverter,
|
|
185
|
+
parseMarkdown
|
|
186
|
+
} from "chat";
|
|
187
|
+
var SlackFormatConverter = class extends BaseFormatConverter {
|
|
188
|
+
/**
|
|
189
|
+
* Convert @mentions to Slack format in plain text.
|
|
190
|
+
* @name → <@name>
|
|
191
|
+
*/
|
|
192
|
+
convertMentionsToSlack(text) {
|
|
193
|
+
return text.replace(/@(\w+)/g, "<@$1>");
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* Override renderPostable to convert @mentions in plain strings.
|
|
197
|
+
*/
|
|
198
|
+
renderPostable(message) {
|
|
199
|
+
if (typeof message === "string") {
|
|
200
|
+
return this.convertMentionsToSlack(message);
|
|
201
|
+
}
|
|
202
|
+
if ("raw" in message) {
|
|
203
|
+
return this.convertMentionsToSlack(message.raw);
|
|
204
|
+
}
|
|
205
|
+
if ("markdown" in message) {
|
|
206
|
+
return this.fromAst(parseMarkdown(message.markdown));
|
|
207
|
+
}
|
|
208
|
+
if ("ast" in message) {
|
|
209
|
+
return this.fromAst(message.ast);
|
|
210
|
+
}
|
|
211
|
+
return "";
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* Render an AST to Slack mrkdwn format.
|
|
215
|
+
*/
|
|
216
|
+
fromAst(ast) {
|
|
217
|
+
const parts = [];
|
|
218
|
+
for (const node of ast.children) {
|
|
219
|
+
parts.push(this.nodeToMrkdwn(node));
|
|
220
|
+
}
|
|
221
|
+
return parts.join("\n\n");
|
|
222
|
+
}
|
|
223
|
+
/**
|
|
224
|
+
* Parse Slack mrkdwn into an AST.
|
|
225
|
+
*/
|
|
226
|
+
toAst(mrkdwn) {
|
|
227
|
+
let markdown = mrkdwn;
|
|
228
|
+
markdown = markdown.replace(/<@([^|>]+)\|([^>]+)>/g, "@$2");
|
|
229
|
+
markdown = markdown.replace(/<@([^>]+)>/g, "@$1");
|
|
230
|
+
markdown = markdown.replace(/<#[^|>]+\|([^>]+)>/g, "#$1");
|
|
231
|
+
markdown = markdown.replace(/<#([^>]+)>/g, "#$1");
|
|
232
|
+
markdown = markdown.replace(/<(https?:\/\/[^|>]+)\|([^>]+)>/g, "[$2]($1)");
|
|
233
|
+
markdown = markdown.replace(/<(https?:\/\/[^>]+)>/g, "$1");
|
|
234
|
+
markdown = markdown.replace(/(?<![_*\\])\*([^*\n]+)\*(?![_*])/g, "**$1**");
|
|
235
|
+
markdown = markdown.replace(/(?<!~)~([^~\n]+)~(?!~)/g, "~~$1~~");
|
|
236
|
+
return parseMarkdown(markdown);
|
|
237
|
+
}
|
|
238
|
+
nodeToMrkdwn(node) {
|
|
239
|
+
switch (node.type) {
|
|
240
|
+
case "paragraph":
|
|
241
|
+
return node.children.map((child) => this.nodeToMrkdwn(child)).join("");
|
|
242
|
+
case "text": {
|
|
243
|
+
const textValue = node.value;
|
|
244
|
+
return textValue.replace(/@(\w+)/g, "<@$1>");
|
|
245
|
+
}
|
|
246
|
+
case "strong":
|
|
247
|
+
return `*${node.children.map((child) => this.nodeToMrkdwn(child)).join("")}*`;
|
|
248
|
+
case "emphasis":
|
|
249
|
+
return `_${node.children.map((child) => this.nodeToMrkdwn(child)).join("")}_`;
|
|
250
|
+
case "delete":
|
|
251
|
+
return `~${node.children.map((child) => this.nodeToMrkdwn(child)).join("")}~`;
|
|
252
|
+
case "inlineCode":
|
|
253
|
+
return `\`${node.value}\``;
|
|
254
|
+
case "code": {
|
|
255
|
+
const codeNode = node;
|
|
256
|
+
return `\`\`\`${codeNode.lang || ""}
|
|
257
|
+
${codeNode.value}
|
|
258
|
+
\`\`\``;
|
|
259
|
+
}
|
|
260
|
+
case "link": {
|
|
261
|
+
const linkNode = node;
|
|
262
|
+
const linkText = linkNode.children.map((child) => this.nodeToMrkdwn(child)).join("");
|
|
263
|
+
return `<${linkNode.url}|${linkText}>`;
|
|
264
|
+
}
|
|
265
|
+
case "blockquote":
|
|
266
|
+
return node.children.map((child) => `> ${this.nodeToMrkdwn(child)}`).join("\n");
|
|
267
|
+
case "list":
|
|
268
|
+
return node.children.map((item, i) => {
|
|
269
|
+
const prefix = node.ordered ? `${i + 1}.` : "\u2022";
|
|
270
|
+
const content = item.children.map((child) => this.nodeToMrkdwn(child)).join("");
|
|
271
|
+
return `${prefix} ${content}`;
|
|
272
|
+
}).join("\n");
|
|
273
|
+
case "listItem":
|
|
274
|
+
return node.children.map((child) => this.nodeToMrkdwn(child)).join("");
|
|
275
|
+
case "break":
|
|
276
|
+
return "\n";
|
|
277
|
+
case "thematicBreak":
|
|
278
|
+
return "---";
|
|
279
|
+
default:
|
|
280
|
+
if ("children" in node && Array.isArray(node.children)) {
|
|
281
|
+
return node.children.map((child) => this.nodeToMrkdwn(child)).join("");
|
|
282
|
+
}
|
|
283
|
+
if ("value" in node) {
|
|
284
|
+
return String(node.value);
|
|
285
|
+
}
|
|
286
|
+
return "";
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
};
|
|
290
|
+
|
|
291
|
+
// src/index.ts
|
|
292
|
+
var SlackAdapter = class _SlackAdapter {
|
|
293
|
+
name = "slack";
|
|
294
|
+
userName;
|
|
295
|
+
client;
|
|
296
|
+
signingSecret;
|
|
297
|
+
botToken;
|
|
298
|
+
chat = null;
|
|
299
|
+
logger = null;
|
|
300
|
+
_botUserId = null;
|
|
301
|
+
_botId = null;
|
|
302
|
+
// Bot app ID (B_xxx) - different from user ID
|
|
303
|
+
formatConverter = new SlackFormatConverter();
|
|
304
|
+
static USER_CACHE_TTL_MS = 60 * 60 * 1e3;
|
|
305
|
+
// 1 hour
|
|
306
|
+
/** Bot user ID (e.g., U_BOT_123) used for mention detection */
|
|
307
|
+
get botUserId() {
|
|
308
|
+
return this._botUserId || void 0;
|
|
309
|
+
}
|
|
310
|
+
constructor(config) {
|
|
311
|
+
this.client = new WebClient(config.botToken);
|
|
312
|
+
this.signingSecret = config.signingSecret;
|
|
313
|
+
this.botToken = config.botToken;
|
|
314
|
+
this.userName = config.userName || "bot";
|
|
315
|
+
this._botUserId = config.botUserId || null;
|
|
316
|
+
}
|
|
317
|
+
async initialize(chat) {
|
|
318
|
+
this.chat = chat;
|
|
319
|
+
this.logger = chat.getLogger(this.name);
|
|
320
|
+
if (!this._botUserId) {
|
|
321
|
+
try {
|
|
322
|
+
const authResult = await this.client.auth.test();
|
|
323
|
+
this._botUserId = authResult.user_id;
|
|
324
|
+
this._botId = authResult.bot_id || null;
|
|
325
|
+
if (authResult.user) {
|
|
326
|
+
this.userName = authResult.user;
|
|
327
|
+
}
|
|
328
|
+
this.logger.info("Slack auth completed", {
|
|
329
|
+
botUserId: this._botUserId,
|
|
330
|
+
botId: this._botId
|
|
331
|
+
});
|
|
332
|
+
} catch (error) {
|
|
333
|
+
this.logger.warn("Could not fetch bot user ID", { error });
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
/**
|
|
338
|
+
* Look up user info from Slack API with caching via state adapter.
|
|
339
|
+
* Returns display name and real name, or falls back to user ID.
|
|
340
|
+
*/
|
|
341
|
+
async lookupUser(userId) {
|
|
342
|
+
const cacheKey = `slack:user:${userId}`;
|
|
343
|
+
if (this.chat) {
|
|
344
|
+
const cached = await this.chat.getState().get(cacheKey);
|
|
345
|
+
if (cached) {
|
|
346
|
+
return { displayName: cached.displayName, realName: cached.realName };
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
try {
|
|
350
|
+
const result = await this.client.users.info({ user: userId });
|
|
351
|
+
const user = result.user;
|
|
352
|
+
const displayName = user?.profile?.display_name || user?.profile?.real_name || user?.real_name || user?.name || userId;
|
|
353
|
+
const realName = user?.real_name || user?.profile?.real_name || displayName;
|
|
354
|
+
if (this.chat) {
|
|
355
|
+
await this.chat.getState().set(
|
|
356
|
+
cacheKey,
|
|
357
|
+
{ displayName, realName },
|
|
358
|
+
_SlackAdapter.USER_CACHE_TTL_MS
|
|
359
|
+
);
|
|
360
|
+
}
|
|
361
|
+
this.logger?.debug("Fetched user info", {
|
|
362
|
+
userId,
|
|
363
|
+
displayName,
|
|
364
|
+
realName
|
|
365
|
+
});
|
|
366
|
+
return { displayName, realName };
|
|
367
|
+
} catch (error) {
|
|
368
|
+
this.logger?.warn("Could not fetch user info", { userId, error });
|
|
369
|
+
return { displayName: userId, realName: userId };
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
async handleWebhook(request, options) {
|
|
373
|
+
const body = await request.text();
|
|
374
|
+
this.logger?.debug("Slack webhook raw body", { body });
|
|
375
|
+
const timestamp = request.headers.get("x-slack-request-timestamp");
|
|
376
|
+
const signature = request.headers.get("x-slack-signature");
|
|
377
|
+
if (!this.verifySignature(body, timestamp, signature)) {
|
|
378
|
+
return new Response("Invalid signature", { status: 401 });
|
|
379
|
+
}
|
|
380
|
+
const contentType = request.headers.get("content-type") || "";
|
|
381
|
+
if (contentType.includes("application/x-www-form-urlencoded")) {
|
|
382
|
+
return this.handleInteractivePayload(body, options);
|
|
383
|
+
}
|
|
384
|
+
let payload;
|
|
385
|
+
try {
|
|
386
|
+
payload = JSON.parse(body);
|
|
387
|
+
} catch {
|
|
388
|
+
return new Response("Invalid JSON", { status: 400 });
|
|
389
|
+
}
|
|
390
|
+
if (payload.type === "url_verification" && payload.challenge) {
|
|
391
|
+
return new Response(JSON.stringify({ challenge: payload.challenge }), {
|
|
392
|
+
headers: { "Content-Type": "application/json" }
|
|
393
|
+
});
|
|
394
|
+
}
|
|
395
|
+
if (payload.type === "event_callback" && payload.event) {
|
|
396
|
+
const event = payload.event;
|
|
397
|
+
if (event.type === "message" || event.type === "app_mention") {
|
|
398
|
+
this.handleMessageEvent(event, options);
|
|
399
|
+
} else if (event.type === "reaction_added" || event.type === "reaction_removed") {
|
|
400
|
+
this.handleReactionEvent(event, options);
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
return new Response("ok", { status: 200 });
|
|
404
|
+
}
|
|
405
|
+
/**
|
|
406
|
+
* Handle Slack interactive payloads (button clicks, etc.).
|
|
407
|
+
* These are sent as form-urlencoded with a `payload` JSON field.
|
|
408
|
+
*/
|
|
409
|
+
handleInteractivePayload(body, options) {
|
|
410
|
+
const params = new URLSearchParams(body);
|
|
411
|
+
const payloadStr = params.get("payload");
|
|
412
|
+
if (!payloadStr) {
|
|
413
|
+
return new Response("Missing payload", { status: 400 });
|
|
414
|
+
}
|
|
415
|
+
let payload;
|
|
416
|
+
try {
|
|
417
|
+
payload = JSON.parse(payloadStr);
|
|
418
|
+
} catch {
|
|
419
|
+
return new Response("Invalid payload JSON", { status: 400 });
|
|
420
|
+
}
|
|
421
|
+
if (payload.type === "block_actions") {
|
|
422
|
+
this.handleBlockActions(payload, options);
|
|
423
|
+
}
|
|
424
|
+
return new Response("", { status: 200 });
|
|
425
|
+
}
|
|
426
|
+
/**
|
|
427
|
+
* Handle block_actions payload (button clicks in Block Kit).
|
|
428
|
+
*/
|
|
429
|
+
handleBlockActions(payload, options) {
|
|
430
|
+
if (!this.chat) {
|
|
431
|
+
this.logger?.warn("Chat instance not initialized, ignoring action");
|
|
432
|
+
return;
|
|
433
|
+
}
|
|
434
|
+
const channel = payload.channel?.id || payload.container?.channel_id;
|
|
435
|
+
const messageTs = payload.message?.ts || payload.container?.message_ts;
|
|
436
|
+
const threadTs = payload.message?.thread_ts || messageTs;
|
|
437
|
+
if (!channel || !messageTs) {
|
|
438
|
+
this.logger?.warn("Missing channel or message_ts in block_actions", {
|
|
439
|
+
channel,
|
|
440
|
+
messageTs
|
|
441
|
+
});
|
|
442
|
+
return;
|
|
443
|
+
}
|
|
444
|
+
const threadId = this.encodeThreadId({
|
|
445
|
+
channel,
|
|
446
|
+
threadTs: threadTs || messageTs
|
|
447
|
+
});
|
|
448
|
+
for (const action of payload.actions) {
|
|
449
|
+
const actionEvent = {
|
|
450
|
+
actionId: action.action_id,
|
|
451
|
+
value: action.value,
|
|
452
|
+
user: {
|
|
453
|
+
userId: payload.user.id,
|
|
454
|
+
userName: payload.user.username || payload.user.name || "unknown",
|
|
455
|
+
fullName: payload.user.name || payload.user.username || "unknown",
|
|
456
|
+
isBot: false,
|
|
457
|
+
isMe: false
|
|
458
|
+
},
|
|
459
|
+
messageId: messageTs,
|
|
460
|
+
threadId,
|
|
461
|
+
adapter: this,
|
|
462
|
+
raw: payload
|
|
463
|
+
};
|
|
464
|
+
this.logger?.debug("Processing Slack block action", {
|
|
465
|
+
actionId: action.action_id,
|
|
466
|
+
value: action.value,
|
|
467
|
+
messageId: messageTs,
|
|
468
|
+
threadId
|
|
469
|
+
});
|
|
470
|
+
this.chat.processAction(actionEvent, options);
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
verifySignature(body, timestamp, signature) {
|
|
474
|
+
if (!timestamp || !signature) {
|
|
475
|
+
return false;
|
|
476
|
+
}
|
|
477
|
+
const now = Math.floor(Date.now() / 1e3);
|
|
478
|
+
if (Math.abs(now - parseInt(timestamp, 10)) > 300) {
|
|
479
|
+
return false;
|
|
480
|
+
}
|
|
481
|
+
const sigBasestring = `v0:${timestamp}:${body}`;
|
|
482
|
+
const expectedSignature = "v0=" + createHmac("sha256", this.signingSecret).update(sigBasestring).digest("hex");
|
|
483
|
+
try {
|
|
484
|
+
return timingSafeEqual(
|
|
485
|
+
Buffer.from(signature),
|
|
486
|
+
Buffer.from(expectedSignature)
|
|
487
|
+
);
|
|
488
|
+
} catch {
|
|
489
|
+
return false;
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
/**
|
|
493
|
+
* Handle message events from Slack.
|
|
494
|
+
* Bot message filtering (isMe) is handled centrally by the Chat class.
|
|
495
|
+
*/
|
|
496
|
+
handleMessageEvent(event, options) {
|
|
497
|
+
if (!this.chat) {
|
|
498
|
+
this.logger?.warn("Chat instance not initialized, ignoring event");
|
|
499
|
+
return;
|
|
500
|
+
}
|
|
501
|
+
if (event.subtype && event.subtype !== "bot_message") {
|
|
502
|
+
this.logger?.debug("Ignoring message subtype", {
|
|
503
|
+
subtype: event.subtype
|
|
504
|
+
});
|
|
505
|
+
return;
|
|
506
|
+
}
|
|
507
|
+
if (!event.channel || !event.ts) {
|
|
508
|
+
this.logger?.debug("Ignoring event without channel or ts", {
|
|
509
|
+
channel: event.channel,
|
|
510
|
+
ts: event.ts
|
|
511
|
+
});
|
|
512
|
+
return;
|
|
513
|
+
}
|
|
514
|
+
const isDM = event.channel_type === "im";
|
|
515
|
+
const threadTs = isDM ? "" : event.thread_ts || event.ts;
|
|
516
|
+
const threadId = this.encodeThreadId({
|
|
517
|
+
channel: event.channel,
|
|
518
|
+
threadTs
|
|
519
|
+
});
|
|
520
|
+
this.chat.processMessage(
|
|
521
|
+
this,
|
|
522
|
+
threadId,
|
|
523
|
+
() => this.parseSlackMessage(event, threadId),
|
|
524
|
+
options
|
|
525
|
+
);
|
|
526
|
+
}
|
|
527
|
+
/**
|
|
528
|
+
* Handle reaction events from Slack (reaction_added, reaction_removed).
|
|
529
|
+
*/
|
|
530
|
+
handleReactionEvent(event, options) {
|
|
531
|
+
if (!this.chat) {
|
|
532
|
+
this.logger?.warn("Chat instance not initialized, ignoring reaction");
|
|
533
|
+
return;
|
|
534
|
+
}
|
|
535
|
+
if (event.item.type !== "message") {
|
|
536
|
+
this.logger?.debug("Ignoring reaction to non-message item", {
|
|
537
|
+
itemType: event.item.type
|
|
538
|
+
});
|
|
539
|
+
return;
|
|
540
|
+
}
|
|
541
|
+
const threadId = this.encodeThreadId({
|
|
542
|
+
channel: event.item.channel,
|
|
543
|
+
threadTs: event.item.ts
|
|
544
|
+
});
|
|
545
|
+
const messageId = event.item.ts;
|
|
546
|
+
const rawEmoji = event.reaction;
|
|
547
|
+
const normalizedEmoji = defaultEmojiResolver.fromSlack(rawEmoji);
|
|
548
|
+
const isMe = this._botUserId !== null && event.user === this._botUserId || this._botId !== null && event.user === this._botId;
|
|
549
|
+
const reactionEvent = {
|
|
550
|
+
emoji: normalizedEmoji,
|
|
551
|
+
rawEmoji,
|
|
552
|
+
added: event.type === "reaction_added",
|
|
553
|
+
user: {
|
|
554
|
+
userId: event.user,
|
|
555
|
+
userName: event.user,
|
|
556
|
+
// Will be resolved below if possible
|
|
557
|
+
fullName: event.user,
|
|
558
|
+
isBot: false,
|
|
559
|
+
// Users add reactions, not bots typically
|
|
560
|
+
isMe
|
|
561
|
+
},
|
|
562
|
+
messageId,
|
|
563
|
+
threadId,
|
|
564
|
+
raw: event
|
|
565
|
+
};
|
|
566
|
+
this.chat.processReaction({ ...reactionEvent, adapter: this }, options);
|
|
567
|
+
}
|
|
568
|
+
async parseSlackMessage(event, threadId) {
|
|
569
|
+
const isMe = this.isMessageFromSelf(event);
|
|
570
|
+
const text = event.text || "";
|
|
571
|
+
let userName = event.username || "unknown";
|
|
572
|
+
let fullName = event.username || "unknown";
|
|
573
|
+
if (event.user && !event.username) {
|
|
574
|
+
const userInfo = await this.lookupUser(event.user);
|
|
575
|
+
userName = userInfo.displayName;
|
|
576
|
+
fullName = userInfo.realName;
|
|
577
|
+
}
|
|
578
|
+
return {
|
|
579
|
+
id: event.ts || "",
|
|
580
|
+
threadId,
|
|
581
|
+
text: this.formatConverter.extractPlainText(text),
|
|
582
|
+
formatted: this.formatConverter.toAst(text),
|
|
583
|
+
raw: event,
|
|
584
|
+
author: {
|
|
585
|
+
userId: event.user || event.bot_id || "unknown",
|
|
586
|
+
userName,
|
|
587
|
+
fullName,
|
|
588
|
+
isBot: !!event.bot_id,
|
|
589
|
+
isMe
|
|
590
|
+
},
|
|
591
|
+
metadata: {
|
|
592
|
+
dateSent: new Date(parseFloat(event.ts || "0") * 1e3),
|
|
593
|
+
edited: !!event.edited,
|
|
594
|
+
editedAt: event.edited ? new Date(parseFloat(event.edited.ts) * 1e3) : void 0
|
|
595
|
+
},
|
|
596
|
+
attachments: (event.files || []).map(
|
|
597
|
+
(file) => this.createAttachment(file)
|
|
598
|
+
)
|
|
599
|
+
};
|
|
600
|
+
}
|
|
601
|
+
/**
|
|
602
|
+
* Create an Attachment object from a Slack file.
|
|
603
|
+
* Includes a fetchData method that uses the bot token for auth.
|
|
604
|
+
*/
|
|
605
|
+
createAttachment(file) {
|
|
606
|
+
const url = file.url_private;
|
|
607
|
+
const botToken = this.botToken;
|
|
608
|
+
let type = "file";
|
|
609
|
+
if (file.mimetype?.startsWith("image/")) {
|
|
610
|
+
type = "image";
|
|
611
|
+
} else if (file.mimetype?.startsWith("video/")) {
|
|
612
|
+
type = "video";
|
|
613
|
+
} else if (file.mimetype?.startsWith("audio/")) {
|
|
614
|
+
type = "audio";
|
|
615
|
+
}
|
|
616
|
+
return {
|
|
617
|
+
type,
|
|
618
|
+
url,
|
|
619
|
+
name: file.name,
|
|
620
|
+
mimeType: file.mimetype,
|
|
621
|
+
size: file.size,
|
|
622
|
+
width: file.original_w,
|
|
623
|
+
height: file.original_h,
|
|
624
|
+
fetchData: url ? async () => {
|
|
625
|
+
const response = await fetch(url, {
|
|
626
|
+
headers: {
|
|
627
|
+
Authorization: `Bearer ${botToken}`
|
|
628
|
+
}
|
|
629
|
+
});
|
|
630
|
+
if (!response.ok) {
|
|
631
|
+
throw new Error(
|
|
632
|
+
`Failed to fetch file: ${response.status} ${response.statusText}`
|
|
633
|
+
);
|
|
634
|
+
}
|
|
635
|
+
const arrayBuffer = await response.arrayBuffer();
|
|
636
|
+
return Buffer.from(arrayBuffer);
|
|
637
|
+
} : void 0
|
|
638
|
+
};
|
|
639
|
+
}
|
|
640
|
+
async postMessage(threadId, message) {
|
|
641
|
+
const { channel, threadTs } = this.decodeThreadId(threadId);
|
|
642
|
+
try {
|
|
643
|
+
const files = this.extractFiles(message);
|
|
644
|
+
if (files.length > 0) {
|
|
645
|
+
await this.uploadFiles(files, channel, threadTs || void 0);
|
|
646
|
+
const hasText = typeof message === "string" || typeof message === "object" && message !== null && ("raw" in message || "markdown" in message || "ast" in message);
|
|
647
|
+
const card2 = this.extractCard(message);
|
|
648
|
+
if (!hasText && !card2) {
|
|
649
|
+
return {
|
|
650
|
+
id: `file-${Date.now()}`,
|
|
651
|
+
threadId,
|
|
652
|
+
raw: { files }
|
|
653
|
+
};
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
const card = this.extractCard(message);
|
|
657
|
+
if (card) {
|
|
658
|
+
const blocks = cardToBlockKit(card);
|
|
659
|
+
const fallbackText = cardToFallbackText(card);
|
|
660
|
+
this.logger?.debug("Slack API: chat.postMessage (blocks)", {
|
|
661
|
+
channel,
|
|
662
|
+
threadTs,
|
|
663
|
+
blockCount: blocks.length
|
|
664
|
+
});
|
|
665
|
+
const result2 = await this.client.chat.postMessage({
|
|
666
|
+
channel,
|
|
667
|
+
thread_ts: threadTs,
|
|
668
|
+
text: fallbackText,
|
|
669
|
+
// Fallback for notifications
|
|
670
|
+
blocks,
|
|
671
|
+
unfurl_links: false,
|
|
672
|
+
unfurl_media: false
|
|
673
|
+
});
|
|
674
|
+
this.logger?.debug("Slack API: chat.postMessage response", {
|
|
675
|
+
messageId: result2.ts,
|
|
676
|
+
ok: result2.ok
|
|
677
|
+
});
|
|
678
|
+
return {
|
|
679
|
+
id: result2.ts,
|
|
680
|
+
threadId,
|
|
681
|
+
raw: result2
|
|
682
|
+
};
|
|
683
|
+
}
|
|
684
|
+
const text = convertEmojiPlaceholders2(
|
|
685
|
+
this.formatConverter.renderPostable(message),
|
|
686
|
+
"slack"
|
|
687
|
+
);
|
|
688
|
+
this.logger?.debug("Slack API: chat.postMessage", {
|
|
689
|
+
channel,
|
|
690
|
+
threadTs,
|
|
691
|
+
textLength: text.length
|
|
692
|
+
});
|
|
693
|
+
const result = await this.client.chat.postMessage({
|
|
694
|
+
channel,
|
|
695
|
+
thread_ts: threadTs,
|
|
696
|
+
text,
|
|
697
|
+
unfurl_links: false,
|
|
698
|
+
unfurl_media: false
|
|
699
|
+
});
|
|
700
|
+
this.logger?.debug("Slack API: chat.postMessage response", {
|
|
701
|
+
messageId: result.ts,
|
|
702
|
+
ok: result.ok
|
|
703
|
+
});
|
|
704
|
+
return {
|
|
705
|
+
id: result.ts,
|
|
706
|
+
threadId,
|
|
707
|
+
raw: result
|
|
708
|
+
};
|
|
709
|
+
} catch (error) {
|
|
710
|
+
this.handleSlackError(error);
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
/**
|
|
714
|
+
* Extract card element from a PostableMessage if present.
|
|
715
|
+
*/
|
|
716
|
+
extractCard(message) {
|
|
717
|
+
if (isCardElement(message)) {
|
|
718
|
+
return message;
|
|
719
|
+
}
|
|
720
|
+
if (typeof message === "object" && message !== null && "card" in message) {
|
|
721
|
+
return message.card;
|
|
722
|
+
}
|
|
723
|
+
return null;
|
|
724
|
+
}
|
|
725
|
+
/**
|
|
726
|
+
* Extract files from a PostableMessage if present.
|
|
727
|
+
*/
|
|
728
|
+
extractFiles(message) {
|
|
729
|
+
if (typeof message === "object" && message !== null && "files" in message) {
|
|
730
|
+
return message.files ?? [];
|
|
731
|
+
}
|
|
732
|
+
return [];
|
|
733
|
+
}
|
|
734
|
+
/**
|
|
735
|
+
* Upload files to Slack and share them to a channel.
|
|
736
|
+
* Returns the file IDs of uploaded files.
|
|
737
|
+
*/
|
|
738
|
+
async uploadFiles(files, channel, threadTs) {
|
|
739
|
+
const fileIds = [];
|
|
740
|
+
for (const file of files) {
|
|
741
|
+
try {
|
|
742
|
+
let fileBuffer;
|
|
743
|
+
if (Buffer.isBuffer(file.data)) {
|
|
744
|
+
fileBuffer = file.data;
|
|
745
|
+
} else if (file.data instanceof ArrayBuffer) {
|
|
746
|
+
fileBuffer = Buffer.from(file.data);
|
|
747
|
+
} else if (file.data instanceof Blob) {
|
|
748
|
+
const arrayBuffer = await file.data.arrayBuffer();
|
|
749
|
+
fileBuffer = Buffer.from(arrayBuffer);
|
|
750
|
+
} else {
|
|
751
|
+
throw new Error("Unsupported file data type");
|
|
752
|
+
}
|
|
753
|
+
this.logger?.debug("Slack API: files.uploadV2", {
|
|
754
|
+
filename: file.filename,
|
|
755
|
+
size: fileBuffer.length,
|
|
756
|
+
mimeType: file.mimeType
|
|
757
|
+
});
|
|
758
|
+
const uploadArgs = {
|
|
759
|
+
channel_id: channel,
|
|
760
|
+
filename: file.filename,
|
|
761
|
+
file: fileBuffer
|
|
762
|
+
};
|
|
763
|
+
if (threadTs) {
|
|
764
|
+
uploadArgs.thread_ts = threadTs;
|
|
765
|
+
}
|
|
766
|
+
const result = await this.client.files.uploadV2(uploadArgs);
|
|
767
|
+
this.logger?.debug("Slack API: files.uploadV2 response", {
|
|
768
|
+
ok: result.ok
|
|
769
|
+
});
|
|
770
|
+
if (result.files && Array.isArray(result.files)) {
|
|
771
|
+
for (const uploadedFile of result.files) {
|
|
772
|
+
if (uploadedFile.id) {
|
|
773
|
+
fileIds.push(uploadedFile.id);
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
} catch (error) {
|
|
778
|
+
this.logger?.error("Failed to upload file", {
|
|
779
|
+
filename: file.filename,
|
|
780
|
+
error
|
|
781
|
+
});
|
|
782
|
+
throw error;
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
return fileIds;
|
|
786
|
+
}
|
|
787
|
+
async editMessage(threadId, messageId, message) {
|
|
788
|
+
const { channel } = this.decodeThreadId(threadId);
|
|
789
|
+
try {
|
|
790
|
+
const card = this.extractCard(message);
|
|
791
|
+
if (card) {
|
|
792
|
+
const blocks = cardToBlockKit(card);
|
|
793
|
+
const fallbackText = cardToFallbackText(card);
|
|
794
|
+
this.logger?.debug("Slack API: chat.update (blocks)", {
|
|
795
|
+
channel,
|
|
796
|
+
messageId,
|
|
797
|
+
blockCount: blocks.length
|
|
798
|
+
});
|
|
799
|
+
const result2 = await this.client.chat.update({
|
|
800
|
+
channel,
|
|
801
|
+
ts: messageId,
|
|
802
|
+
text: fallbackText,
|
|
803
|
+
blocks
|
|
804
|
+
});
|
|
805
|
+
this.logger?.debug("Slack API: chat.update response", {
|
|
806
|
+
messageId: result2.ts,
|
|
807
|
+
ok: result2.ok
|
|
808
|
+
});
|
|
809
|
+
return {
|
|
810
|
+
id: result2.ts,
|
|
811
|
+
threadId,
|
|
812
|
+
raw: result2
|
|
813
|
+
};
|
|
814
|
+
}
|
|
815
|
+
const text = convertEmojiPlaceholders2(
|
|
816
|
+
this.formatConverter.renderPostable(message),
|
|
817
|
+
"slack"
|
|
818
|
+
);
|
|
819
|
+
this.logger?.debug("Slack API: chat.update", {
|
|
820
|
+
channel,
|
|
821
|
+
messageId,
|
|
822
|
+
textLength: text.length
|
|
823
|
+
});
|
|
824
|
+
const result = await this.client.chat.update({
|
|
825
|
+
channel,
|
|
826
|
+
ts: messageId,
|
|
827
|
+
text
|
|
828
|
+
});
|
|
829
|
+
this.logger?.debug("Slack API: chat.update response", {
|
|
830
|
+
messageId: result.ts,
|
|
831
|
+
ok: result.ok
|
|
832
|
+
});
|
|
833
|
+
return {
|
|
834
|
+
id: result.ts,
|
|
835
|
+
threadId,
|
|
836
|
+
raw: result
|
|
837
|
+
};
|
|
838
|
+
} catch (error) {
|
|
839
|
+
this.handleSlackError(error);
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
async deleteMessage(threadId, messageId) {
|
|
843
|
+
const { channel } = this.decodeThreadId(threadId);
|
|
844
|
+
try {
|
|
845
|
+
this.logger?.debug("Slack API: chat.delete", { channel, messageId });
|
|
846
|
+
await this.client.chat.delete({
|
|
847
|
+
channel,
|
|
848
|
+
ts: messageId
|
|
849
|
+
});
|
|
850
|
+
this.logger?.debug("Slack API: chat.delete response", { ok: true });
|
|
851
|
+
} catch (error) {
|
|
852
|
+
this.handleSlackError(error);
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
async addReaction(threadId, messageId, emoji) {
|
|
856
|
+
const { channel } = this.decodeThreadId(threadId);
|
|
857
|
+
const slackEmoji = defaultEmojiResolver.toSlack(emoji);
|
|
858
|
+
const name = slackEmoji.replace(/:/g, "");
|
|
859
|
+
try {
|
|
860
|
+
this.logger?.debug("Slack API: reactions.add", {
|
|
861
|
+
channel,
|
|
862
|
+
messageId,
|
|
863
|
+
emoji: name
|
|
864
|
+
});
|
|
865
|
+
await this.client.reactions.add({
|
|
866
|
+
channel,
|
|
867
|
+
timestamp: messageId,
|
|
868
|
+
name
|
|
869
|
+
});
|
|
870
|
+
this.logger?.debug("Slack API: reactions.add response", { ok: true });
|
|
871
|
+
} catch (error) {
|
|
872
|
+
this.handleSlackError(error);
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
async removeReaction(threadId, messageId, emoji) {
|
|
876
|
+
const { channel } = this.decodeThreadId(threadId);
|
|
877
|
+
const slackEmoji = defaultEmojiResolver.toSlack(emoji);
|
|
878
|
+
const name = slackEmoji.replace(/:/g, "");
|
|
879
|
+
try {
|
|
880
|
+
this.logger?.debug("Slack API: reactions.remove", {
|
|
881
|
+
channel,
|
|
882
|
+
messageId,
|
|
883
|
+
emoji: name
|
|
884
|
+
});
|
|
885
|
+
await this.client.reactions.remove({
|
|
886
|
+
channel,
|
|
887
|
+
timestamp: messageId,
|
|
888
|
+
name
|
|
889
|
+
});
|
|
890
|
+
this.logger?.debug("Slack API: reactions.remove response", { ok: true });
|
|
891
|
+
} catch (error) {
|
|
892
|
+
this.handleSlackError(error);
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
async startTyping(_threadId) {
|
|
896
|
+
}
|
|
897
|
+
/**
|
|
898
|
+
* Open a direct message conversation with a user.
|
|
899
|
+
* Returns a thread ID that can be used to post messages.
|
|
900
|
+
*/
|
|
901
|
+
async openDM(userId) {
|
|
902
|
+
try {
|
|
903
|
+
this.logger?.debug("Slack API: conversations.open", { userId });
|
|
904
|
+
const result = await this.client.conversations.open({ users: userId });
|
|
905
|
+
if (!result.channel?.id) {
|
|
906
|
+
throw new Error("Failed to open DM - no channel returned");
|
|
907
|
+
}
|
|
908
|
+
const channelId = result.channel.id;
|
|
909
|
+
this.logger?.debug("Slack API: conversations.open response", {
|
|
910
|
+
channelId,
|
|
911
|
+
ok: result.ok
|
|
912
|
+
});
|
|
913
|
+
return this.encodeThreadId({
|
|
914
|
+
channel: channelId,
|
|
915
|
+
threadTs: ""
|
|
916
|
+
// Empty threadTs indicates top-level channel messages
|
|
917
|
+
});
|
|
918
|
+
} catch (error) {
|
|
919
|
+
this.handleSlackError(error);
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
async fetchMessages(threadId, options = {}) {
|
|
923
|
+
const { channel, threadTs } = this.decodeThreadId(threadId);
|
|
924
|
+
try {
|
|
925
|
+
this.logger?.debug("Slack API: conversations.replies", {
|
|
926
|
+
channel,
|
|
927
|
+
threadTs,
|
|
928
|
+
limit: options.limit || 100
|
|
929
|
+
});
|
|
930
|
+
const result = await this.client.conversations.replies({
|
|
931
|
+
channel,
|
|
932
|
+
ts: threadTs,
|
|
933
|
+
limit: options.limit || 100,
|
|
934
|
+
cursor: options.before
|
|
935
|
+
});
|
|
936
|
+
const messages = result.messages || [];
|
|
937
|
+
this.logger?.debug("Slack API: conversations.replies response", {
|
|
938
|
+
messageCount: messages.length,
|
|
939
|
+
ok: result.ok
|
|
940
|
+
});
|
|
941
|
+
return messages.map((msg) => this.parseSlackMessageSync(msg, threadId));
|
|
942
|
+
} catch (error) {
|
|
943
|
+
this.handleSlackError(error);
|
|
944
|
+
}
|
|
945
|
+
}
|
|
946
|
+
async fetchThread(threadId) {
|
|
947
|
+
const { channel, threadTs } = this.decodeThreadId(threadId);
|
|
948
|
+
try {
|
|
949
|
+
this.logger?.debug("Slack API: conversations.info", { channel });
|
|
950
|
+
const result = await this.client.conversations.info({ channel });
|
|
951
|
+
const channelInfo = result.channel;
|
|
952
|
+
this.logger?.debug("Slack API: conversations.info response", {
|
|
953
|
+
channelName: channelInfo?.name,
|
|
954
|
+
ok: result.ok
|
|
955
|
+
});
|
|
956
|
+
return {
|
|
957
|
+
id: threadId,
|
|
958
|
+
channelId: channel,
|
|
959
|
+
channelName: channelInfo?.name,
|
|
960
|
+
metadata: {
|
|
961
|
+
threadTs,
|
|
962
|
+
channel: result.channel
|
|
963
|
+
}
|
|
964
|
+
};
|
|
965
|
+
} catch (error) {
|
|
966
|
+
this.handleSlackError(error);
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
encodeThreadId(platformData) {
|
|
970
|
+
return `slack:${platformData.channel}:${platformData.threadTs}`;
|
|
971
|
+
}
|
|
972
|
+
/**
|
|
973
|
+
* Check if a thread is a direct message conversation.
|
|
974
|
+
* Slack DM channel IDs start with 'D'.
|
|
975
|
+
*/
|
|
976
|
+
isDM(threadId) {
|
|
977
|
+
const { channel } = this.decodeThreadId(threadId);
|
|
978
|
+
return channel.startsWith("D");
|
|
979
|
+
}
|
|
980
|
+
decodeThreadId(threadId) {
|
|
981
|
+
const parts = threadId.split(":");
|
|
982
|
+
if (parts.length !== 3 || parts[0] !== "slack") {
|
|
983
|
+
throw new Error(`Invalid Slack thread ID: ${threadId}`);
|
|
984
|
+
}
|
|
985
|
+
return {
|
|
986
|
+
channel: parts[1],
|
|
987
|
+
threadTs: parts[2]
|
|
988
|
+
};
|
|
989
|
+
}
|
|
990
|
+
parseMessage(raw) {
|
|
991
|
+
const event = raw;
|
|
992
|
+
const threadTs = event.thread_ts || event.ts || "";
|
|
993
|
+
const threadId = this.encodeThreadId({
|
|
994
|
+
channel: event.channel || "",
|
|
995
|
+
threadTs
|
|
996
|
+
});
|
|
997
|
+
return this.parseSlackMessageSync(event, threadId);
|
|
998
|
+
}
|
|
999
|
+
/**
|
|
1000
|
+
* Synchronous message parsing without user lookup.
|
|
1001
|
+
* Used for parseMessage interface - falls back to user ID for username.
|
|
1002
|
+
*/
|
|
1003
|
+
parseSlackMessageSync(event, threadId) {
|
|
1004
|
+
const isMe = this.isMessageFromSelf(event);
|
|
1005
|
+
const text = event.text || "";
|
|
1006
|
+
const userName = event.username || event.user || "unknown";
|
|
1007
|
+
const fullName = event.username || event.user || "unknown";
|
|
1008
|
+
return {
|
|
1009
|
+
id: event.ts || "",
|
|
1010
|
+
threadId,
|
|
1011
|
+
text: this.formatConverter.extractPlainText(text),
|
|
1012
|
+
formatted: this.formatConverter.toAst(text),
|
|
1013
|
+
raw: event,
|
|
1014
|
+
author: {
|
|
1015
|
+
userId: event.user || event.bot_id || "unknown",
|
|
1016
|
+
userName,
|
|
1017
|
+
fullName,
|
|
1018
|
+
isBot: !!event.bot_id,
|
|
1019
|
+
isMe
|
|
1020
|
+
},
|
|
1021
|
+
metadata: {
|
|
1022
|
+
dateSent: new Date(parseFloat(event.ts || "0") * 1e3),
|
|
1023
|
+
edited: !!event.edited,
|
|
1024
|
+
editedAt: event.edited ? new Date(parseFloat(event.edited.ts) * 1e3) : void 0
|
|
1025
|
+
},
|
|
1026
|
+
attachments: (event.files || []).map(
|
|
1027
|
+
(file) => this.createAttachment(file)
|
|
1028
|
+
)
|
|
1029
|
+
};
|
|
1030
|
+
}
|
|
1031
|
+
renderFormatted(content) {
|
|
1032
|
+
return this.formatConverter.fromAst(content);
|
|
1033
|
+
}
|
|
1034
|
+
/**
|
|
1035
|
+
* Check if a Slack event is from this bot.
|
|
1036
|
+
*
|
|
1037
|
+
* Slack messages can come from:
|
|
1038
|
+
* - User messages: have `user` field (U_xxx format)
|
|
1039
|
+
* - Bot messages: have `bot_id` field (B_xxx format)
|
|
1040
|
+
*
|
|
1041
|
+
* We check both because:
|
|
1042
|
+
* - _botUserId is the user ID (U_xxx) - matches event.user
|
|
1043
|
+
* - _botId is the bot ID (B_xxx) - matches event.bot_id
|
|
1044
|
+
*/
|
|
1045
|
+
isMessageFromSelf(event) {
|
|
1046
|
+
if (this._botUserId && event.user === this._botUserId) {
|
|
1047
|
+
return true;
|
|
1048
|
+
}
|
|
1049
|
+
if (this._botId && event.bot_id === this._botId) {
|
|
1050
|
+
return true;
|
|
1051
|
+
}
|
|
1052
|
+
return false;
|
|
1053
|
+
}
|
|
1054
|
+
handleSlackError(error) {
|
|
1055
|
+
const slackError = error;
|
|
1056
|
+
if (slackError.code === "slack_webapi_platform_error") {
|
|
1057
|
+
if (slackError.data?.error === "ratelimited") {
|
|
1058
|
+
throw new RateLimitError("Slack rate limit exceeded", void 0, error);
|
|
1059
|
+
}
|
|
1060
|
+
}
|
|
1061
|
+
throw error;
|
|
1062
|
+
}
|
|
1063
|
+
};
|
|
1064
|
+
function createSlackAdapter(config) {
|
|
1065
|
+
return new SlackAdapter(config);
|
|
1066
|
+
}
|
|
1067
|
+
export {
|
|
1068
|
+
SlackAdapter,
|
|
1069
|
+
SlackFormatConverter,
|
|
1070
|
+
SlackFormatConverter as SlackMarkdownConverter,
|
|
1071
|
+
cardToBlockKit,
|
|
1072
|
+
cardToFallbackText,
|
|
1073
|
+
createSlackAdapter
|
|
1074
|
+
};
|
|
1075
|
+
//# sourceMappingURL=index.js.map
|