@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.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