@chat-adapter/slack 4.10.1 → 4.12.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 +45 -1
- package/dist/index.js +444 -21
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { CardElement, BaseFormatConverter, AdapterPostableMessage, Root, Logger, Adapter, ChatInstance, WebhookOptions, RawMessage, EphemeralMessage, ModalElement, EmojiValue, StreamOptions, FetchOptions, FetchResult, ThreadInfo, Message, FormattedContent } from 'chat';
|
|
1
|
+
import { CardElement, BaseFormatConverter, AdapterPostableMessage, Root, Logger, Adapter, ChatInstance, WebhookOptions, RawMessage, EphemeralMessage, ModalElement, EmojiValue, StreamOptions, FetchOptions, FetchResult, ThreadInfo, Message, ListThreadsOptions, ListThreadsResult, ChannelInfo, FormattedContent } from 'chat';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* Slack Block Kit converter for cross-platform cards.
|
|
@@ -121,6 +121,10 @@ interface SlackEvent {
|
|
|
121
121
|
}>;
|
|
122
122
|
team?: string;
|
|
123
123
|
team_id?: string;
|
|
124
|
+
/** Number of replies in the thread (present on thread parent messages) */
|
|
125
|
+
reply_count?: number;
|
|
126
|
+
/** Timestamp of the latest reply (present on thread parent messages) */
|
|
127
|
+
latest_reply?: string;
|
|
124
128
|
}
|
|
125
129
|
/** Slack reaction event payload */
|
|
126
130
|
interface SlackReactionEvent {
|
|
@@ -214,6 +218,11 @@ declare class SlackAdapter implements Adapter<SlackThreadId, unknown> {
|
|
|
214
218
|
* These are sent as form-urlencoded with a `payload` JSON field.
|
|
215
219
|
*/
|
|
216
220
|
private handleInteractivePayload;
|
|
221
|
+
/**
|
|
222
|
+
* Handle Slack slash command payloads.
|
|
223
|
+
* Slash commands are sent as form-urlencoded with command, text, user_id, channel_id, etc.
|
|
224
|
+
*/
|
|
225
|
+
private handleSlashCommand;
|
|
217
226
|
/**
|
|
218
227
|
* Handle block_actions payload (button clicks in Block Kit).
|
|
219
228
|
*/
|
|
@@ -232,6 +241,17 @@ declare class SlackAdapter implements Adapter<SlackThreadId, unknown> {
|
|
|
232
241
|
* Handle reaction events from Slack (reaction_added, reaction_removed).
|
|
233
242
|
*/
|
|
234
243
|
private handleReactionEvent;
|
|
244
|
+
/**
|
|
245
|
+
* Resolve inline user mentions in Slack mrkdwn text.
|
|
246
|
+
* Converts <@U123> to <@U123|displayName> so that toAst/extractPlainText
|
|
247
|
+
* renders them as @displayName instead of @U123.
|
|
248
|
+
*
|
|
249
|
+
* @param skipSelfMention - When true, skips the bot's own user ID so that
|
|
250
|
+
* mention detection (which looks for @botUserId in the text) continues to
|
|
251
|
+
* work. Set to false when parsing historical/channel messages where mention
|
|
252
|
+
* detection doesn't apply.
|
|
253
|
+
*/
|
|
254
|
+
private resolveInlineMentions;
|
|
235
255
|
private parseSlackMessage;
|
|
236
256
|
/**
|
|
237
257
|
* Create an Attachment object from a Slack file.
|
|
@@ -304,6 +324,30 @@ declare class SlackAdapter implements Adapter<SlackThreadId, unknown> {
|
|
|
304
324
|
* Used for parseMessage interface - falls back to user ID for username.
|
|
305
325
|
*/
|
|
306
326
|
private parseSlackMessageSync;
|
|
327
|
+
/**
|
|
328
|
+
* Derive channel ID from a Slack thread ID.
|
|
329
|
+
* Slack thread IDs are "slack:CHANNEL:THREAD_TS", channel ID is "slack:CHANNEL".
|
|
330
|
+
*/
|
|
331
|
+
channelIdFromThreadId(threadId: string): string;
|
|
332
|
+
/**
|
|
333
|
+
* Fetch channel-level messages (conversations.history, not thread replies).
|
|
334
|
+
*/
|
|
335
|
+
fetchChannelMessages(channelId: string, options?: FetchOptions): Promise<FetchResult<unknown>>;
|
|
336
|
+
private fetchChannelMessagesForward;
|
|
337
|
+
private fetchChannelMessagesBackward;
|
|
338
|
+
/**
|
|
339
|
+
* List threads in a Slack channel.
|
|
340
|
+
* Fetches channel history and filters for messages with replies.
|
|
341
|
+
*/
|
|
342
|
+
listThreads(channelId: string, options?: ListThreadsOptions): Promise<ListThreadsResult<unknown>>;
|
|
343
|
+
/**
|
|
344
|
+
* Fetch Slack channel info/metadata.
|
|
345
|
+
*/
|
|
346
|
+
fetchChannelInfo(channelId: string): Promise<ChannelInfo>;
|
|
347
|
+
/**
|
|
348
|
+
* Post a top-level message to a channel (not in a thread).
|
|
349
|
+
*/
|
|
350
|
+
postChannelMessage(channelId: string, message: AdapterPostableMessage): Promise<RawMessage<unknown>>;
|
|
307
351
|
renderFormatted(content: FormattedContent): string;
|
|
308
352
|
/**
|
|
309
353
|
* Check if a Slack event is from this bot.
|
package/dist/index.js
CHANGED
|
@@ -110,11 +110,17 @@ function convertDividerToBlock(_element) {
|
|
|
110
110
|
return { type: "divider" };
|
|
111
111
|
}
|
|
112
112
|
function convertActionsToBlock(element) {
|
|
113
|
-
const elements = element.children.map((
|
|
114
|
-
if (
|
|
115
|
-
return convertLinkButtonToElement(
|
|
113
|
+
const elements = element.children.map((child) => {
|
|
114
|
+
if (child.type === "link-button") {
|
|
115
|
+
return convertLinkButtonToElement(child);
|
|
116
116
|
}
|
|
117
|
-
|
|
117
|
+
if (child.type === "select") {
|
|
118
|
+
return convertSelectToElement(child);
|
|
119
|
+
}
|
|
120
|
+
if (child.type === "radio_select") {
|
|
121
|
+
return convertRadioSelectToElement(child);
|
|
122
|
+
}
|
|
123
|
+
return convertButtonToElement(child);
|
|
118
124
|
});
|
|
119
125
|
return {
|
|
120
126
|
type: "actions",
|
|
@@ -157,6 +163,69 @@ function convertLinkButtonToElement(button) {
|
|
|
157
163
|
}
|
|
158
164
|
return element;
|
|
159
165
|
}
|
|
166
|
+
function convertSelectToElement(select) {
|
|
167
|
+
const options = select.options.map((opt) => {
|
|
168
|
+
const option = {
|
|
169
|
+
text: { type: "plain_text", text: convertEmoji(opt.label) },
|
|
170
|
+
value: opt.value
|
|
171
|
+
};
|
|
172
|
+
if (opt.description) {
|
|
173
|
+
option.description = {
|
|
174
|
+
type: "plain_text",
|
|
175
|
+
text: convertEmoji(opt.description)
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
return option;
|
|
179
|
+
});
|
|
180
|
+
const element = {
|
|
181
|
+
type: "static_select",
|
|
182
|
+
action_id: select.id,
|
|
183
|
+
options
|
|
184
|
+
};
|
|
185
|
+
if (select.placeholder) {
|
|
186
|
+
element.placeholder = {
|
|
187
|
+
type: "plain_text",
|
|
188
|
+
text: convertEmoji(select.placeholder)
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
if (select.initialOption) {
|
|
192
|
+
const initialOpt = options.find((o) => o.value === select.initialOption);
|
|
193
|
+
if (initialOpt) {
|
|
194
|
+
element.initial_option = initialOpt;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
return element;
|
|
198
|
+
}
|
|
199
|
+
function convertRadioSelectToElement(radioSelect) {
|
|
200
|
+
const limitedOptions = radioSelect.options.slice(0, 10);
|
|
201
|
+
const options = limitedOptions.map((opt) => {
|
|
202
|
+
const option = {
|
|
203
|
+
text: { type: "mrkdwn", text: convertEmoji(opt.label) },
|
|
204
|
+
value: opt.value
|
|
205
|
+
};
|
|
206
|
+
if (opt.description) {
|
|
207
|
+
option.description = {
|
|
208
|
+
type: "mrkdwn",
|
|
209
|
+
text: convertEmoji(opt.description)
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
return option;
|
|
213
|
+
});
|
|
214
|
+
const element = {
|
|
215
|
+
type: "radio_buttons",
|
|
216
|
+
action_id: radioSelect.id,
|
|
217
|
+
options
|
|
218
|
+
};
|
|
219
|
+
if (radioSelect.initialOption) {
|
|
220
|
+
const initialOpt = options.find(
|
|
221
|
+
(o) => o.value === radioSelect.initialOption
|
|
222
|
+
);
|
|
223
|
+
if (initialOpt) {
|
|
224
|
+
element.initial_option = initialOpt;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
return element;
|
|
228
|
+
}
|
|
160
229
|
function convertSectionToBlocks(element) {
|
|
161
230
|
const blocks = [];
|
|
162
231
|
for (const child of element.children) {
|
|
@@ -400,6 +469,8 @@ function modalChildToBlock(child) {
|
|
|
400
469
|
return textInputToBlock(child);
|
|
401
470
|
case "select":
|
|
402
471
|
return selectToBlock(child);
|
|
472
|
+
case "radio_select":
|
|
473
|
+
return radioSelectToBlock(child);
|
|
403
474
|
case "text":
|
|
404
475
|
return convertTextToBlock(child);
|
|
405
476
|
case "fields":
|
|
@@ -430,10 +501,16 @@ function textInputToBlock(input) {
|
|
|
430
501
|
};
|
|
431
502
|
}
|
|
432
503
|
function selectToBlock(select) {
|
|
433
|
-
const options = select.options.map((opt) =>
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
504
|
+
const options = select.options.map((opt) => {
|
|
505
|
+
const option = {
|
|
506
|
+
text: { type: "plain_text", text: opt.label },
|
|
507
|
+
value: opt.value
|
|
508
|
+
};
|
|
509
|
+
if (opt.description) {
|
|
510
|
+
option.description = { type: "plain_text", text: opt.description };
|
|
511
|
+
}
|
|
512
|
+
return option;
|
|
513
|
+
});
|
|
437
514
|
const element = {
|
|
438
515
|
type: "static_select",
|
|
439
516
|
action_id: select.id,
|
|
@@ -443,7 +520,9 @@ function selectToBlock(select) {
|
|
|
443
520
|
element.placeholder = { type: "plain_text", text: select.placeholder };
|
|
444
521
|
}
|
|
445
522
|
if (select.initialOption) {
|
|
446
|
-
const initialOpt = options.find(
|
|
523
|
+
const initialOpt = options.find(
|
|
524
|
+
(o) => o.value === select.initialOption
|
|
525
|
+
);
|
|
447
526
|
if (initialOpt) {
|
|
448
527
|
element.initial_option = initialOpt;
|
|
449
528
|
}
|
|
@@ -456,6 +535,39 @@ function selectToBlock(select) {
|
|
|
456
535
|
element
|
|
457
536
|
};
|
|
458
537
|
}
|
|
538
|
+
function radioSelectToBlock(radioSelect) {
|
|
539
|
+
const limitedOptions = radioSelect.options.slice(0, 10);
|
|
540
|
+
const options = limitedOptions.map((opt) => {
|
|
541
|
+
const option = {
|
|
542
|
+
text: { type: "mrkdwn", text: opt.label },
|
|
543
|
+
value: opt.value
|
|
544
|
+
};
|
|
545
|
+
if (opt.description) {
|
|
546
|
+
option.description = { type: "mrkdwn", text: opt.description };
|
|
547
|
+
}
|
|
548
|
+
return option;
|
|
549
|
+
});
|
|
550
|
+
const element = {
|
|
551
|
+
type: "radio_buttons",
|
|
552
|
+
action_id: radioSelect.id,
|
|
553
|
+
options
|
|
554
|
+
};
|
|
555
|
+
if (radioSelect.initialOption) {
|
|
556
|
+
const initialOpt = options.find(
|
|
557
|
+
(o) => o.value === radioSelect.initialOption
|
|
558
|
+
);
|
|
559
|
+
if (initialOpt) {
|
|
560
|
+
element.initial_option = initialOpt;
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
return {
|
|
564
|
+
type: "input",
|
|
565
|
+
block_id: radioSelect.id,
|
|
566
|
+
optional: radioSelect.optional ?? false,
|
|
567
|
+
label: { type: "plain_text", text: radioSelect.label },
|
|
568
|
+
element
|
|
569
|
+
};
|
|
570
|
+
}
|
|
459
571
|
|
|
460
572
|
// src/index.ts
|
|
461
573
|
var SlackAdapter = class _SlackAdapter {
|
|
@@ -742,6 +854,21 @@ var SlackAdapter = class _SlackAdapter {
|
|
|
742
854
|
}
|
|
743
855
|
const contentType = request.headers.get("content-type") || "";
|
|
744
856
|
if (contentType.includes("application/x-www-form-urlencoded")) {
|
|
857
|
+
const params = new URLSearchParams(body);
|
|
858
|
+
if (params.has("command") && !params.has("payload")) {
|
|
859
|
+
const teamId = params.get("team_id");
|
|
860
|
+
if (!this.defaultBotToken && teamId) {
|
|
861
|
+
const ctx = await this.resolveTokenForTeam(teamId);
|
|
862
|
+
if (ctx) {
|
|
863
|
+
return this.requestContext.run(
|
|
864
|
+
ctx,
|
|
865
|
+
() => this.handleSlashCommand(params, options)
|
|
866
|
+
);
|
|
867
|
+
}
|
|
868
|
+
this.logger.warn("Could not resolve token for slash command");
|
|
869
|
+
}
|
|
870
|
+
return this.handleSlashCommand(params, options);
|
|
871
|
+
}
|
|
745
872
|
if (!this.defaultBotToken) {
|
|
746
873
|
const teamId = this.extractTeamIdFromInteractive(body);
|
|
747
874
|
if (teamId) {
|
|
@@ -829,6 +956,46 @@ var SlackAdapter = class _SlackAdapter {
|
|
|
829
956
|
return new Response("", { status: 200 });
|
|
830
957
|
}
|
|
831
958
|
}
|
|
959
|
+
/**
|
|
960
|
+
* Handle Slack slash command payloads.
|
|
961
|
+
* Slash commands are sent as form-urlencoded with command, text, user_id, channel_id, etc.
|
|
962
|
+
*/
|
|
963
|
+
async handleSlashCommand(params, options) {
|
|
964
|
+
if (!this.chat) {
|
|
965
|
+
this.logger.warn("Chat instance not initialized, ignoring slash command");
|
|
966
|
+
return new Response("", { status: 200 });
|
|
967
|
+
}
|
|
968
|
+
const command = params.get("command") || "";
|
|
969
|
+
const text = params.get("text") || "";
|
|
970
|
+
const userId = params.get("user_id") || "";
|
|
971
|
+
const channelId = params.get("channel_id") || "";
|
|
972
|
+
const triggerId = params.get("trigger_id") || void 0;
|
|
973
|
+
this.logger.debug("Processing Slack slash command", {
|
|
974
|
+
command,
|
|
975
|
+
text,
|
|
976
|
+
userId,
|
|
977
|
+
channelId,
|
|
978
|
+
triggerId
|
|
979
|
+
});
|
|
980
|
+
const userInfo = await this.lookupUser(userId);
|
|
981
|
+
const event = {
|
|
982
|
+
command,
|
|
983
|
+
text,
|
|
984
|
+
user: {
|
|
985
|
+
userId,
|
|
986
|
+
userName: userInfo.displayName,
|
|
987
|
+
fullName: userInfo.realName,
|
|
988
|
+
isBot: false,
|
|
989
|
+
isMe: false
|
|
990
|
+
},
|
|
991
|
+
adapter: this,
|
|
992
|
+
raw: Object.fromEntries(params),
|
|
993
|
+
triggerId,
|
|
994
|
+
channelId: channelId ? `slack:${channelId}` : ""
|
|
995
|
+
};
|
|
996
|
+
this.chat.processSlashCommand(event, options);
|
|
997
|
+
return new Response("", { status: 200 });
|
|
998
|
+
}
|
|
832
999
|
/**
|
|
833
1000
|
* Handle block_actions payload (button clicks in Block Kit).
|
|
834
1001
|
*/
|
|
@@ -855,9 +1022,10 @@ var SlackAdapter = class _SlackAdapter {
|
|
|
855
1022
|
const responseUrl = payload.response_url;
|
|
856
1023
|
const messageId = isEphemeral && responseUrl ? this.encodeEphemeralMessageId(messageTs, responseUrl, payload.user.id) : messageTs;
|
|
857
1024
|
for (const action of payload.actions) {
|
|
1025
|
+
const actionValue = action.selected_option?.value ?? action.value;
|
|
858
1026
|
const actionEvent = {
|
|
859
1027
|
actionId: action.action_id,
|
|
860
|
-
value:
|
|
1028
|
+
value: actionValue,
|
|
861
1029
|
user: {
|
|
862
1030
|
userId: payload.user.id,
|
|
863
1031
|
userName: payload.user.username || payload.user.name || "unknown",
|
|
@@ -1036,17 +1204,20 @@ var SlackAdapter = class _SlackAdapter {
|
|
|
1036
1204
|
return;
|
|
1037
1205
|
}
|
|
1038
1206
|
const isDM = event.channel_type === "im";
|
|
1039
|
-
const threadTs = isDM ? "" : event.thread_ts || event.ts;
|
|
1207
|
+
const threadTs = isDM ? event.thread_ts || "" : event.thread_ts || event.ts;
|
|
1040
1208
|
const threadId = this.encodeThreadId({
|
|
1041
1209
|
channel: event.channel,
|
|
1042
1210
|
threadTs
|
|
1043
1211
|
});
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
threadId
|
|
1047
|
-
()
|
|
1048
|
-
|
|
1049
|
-
|
|
1212
|
+
const isMention = isDM || event.type === "app_mention";
|
|
1213
|
+
const factory = async () => {
|
|
1214
|
+
const msg = await this.parseSlackMessage(event, threadId);
|
|
1215
|
+
if (isMention) {
|
|
1216
|
+
msg.isMention = true;
|
|
1217
|
+
}
|
|
1218
|
+
return msg;
|
|
1219
|
+
};
|
|
1220
|
+
this.chat.processMessage(this, threadId, factory, options);
|
|
1050
1221
|
}
|
|
1051
1222
|
/**
|
|
1052
1223
|
* Handle reaction events from Slack (reaction_added, reaction_removed).
|
|
@@ -1090,9 +1261,45 @@ var SlackAdapter = class _SlackAdapter {
|
|
|
1090
1261
|
};
|
|
1091
1262
|
this.chat.processReaction({ ...reactionEvent, adapter: this }, options);
|
|
1092
1263
|
}
|
|
1093
|
-
|
|
1264
|
+
/**
|
|
1265
|
+
* Resolve inline user mentions in Slack mrkdwn text.
|
|
1266
|
+
* Converts <@U123> to <@U123|displayName> so that toAst/extractPlainText
|
|
1267
|
+
* renders them as @displayName instead of @U123.
|
|
1268
|
+
*
|
|
1269
|
+
* @param skipSelfMention - When true, skips the bot's own user ID so that
|
|
1270
|
+
* mention detection (which looks for @botUserId in the text) continues to
|
|
1271
|
+
* work. Set to false when parsing historical/channel messages where mention
|
|
1272
|
+
* detection doesn't apply.
|
|
1273
|
+
*/
|
|
1274
|
+
async resolveInlineMentions(text, skipSelfMention) {
|
|
1275
|
+
const mentionPattern = /<@([A-Z0-9]+)(?:\|[^>]*)?>/g;
|
|
1276
|
+
const userIds = /* @__PURE__ */ new Set();
|
|
1277
|
+
let match = mentionPattern.exec(text);
|
|
1278
|
+
while (match) {
|
|
1279
|
+
userIds.add(match[1]);
|
|
1280
|
+
match = mentionPattern.exec(text);
|
|
1281
|
+
}
|
|
1282
|
+
if (userIds.size === 0) return text;
|
|
1283
|
+
if (skipSelfMention && this._botUserId) {
|
|
1284
|
+
userIds.delete(this._botUserId);
|
|
1285
|
+
}
|
|
1286
|
+
if (userIds.size === 0) return text;
|
|
1287
|
+
const lookups = await Promise.all(
|
|
1288
|
+
[...userIds].map(async (uid) => {
|
|
1289
|
+
const info = await this.lookupUser(uid);
|
|
1290
|
+
return [uid, info.displayName];
|
|
1291
|
+
})
|
|
1292
|
+
);
|
|
1293
|
+
const nameMap = new Map(lookups);
|
|
1294
|
+
return text.replace(/<@([A-Z0-9]+)(?:\|[^>]*)?>/g, (_m, uid) => {
|
|
1295
|
+
const name = nameMap.get(uid);
|
|
1296
|
+
return name ? `<@${uid}|${name}>` : `<@${uid}>`;
|
|
1297
|
+
});
|
|
1298
|
+
}
|
|
1299
|
+
async parseSlackMessage(event, threadId, options) {
|
|
1094
1300
|
const isMe = this.isMessageFromSelf(event);
|
|
1095
|
-
const
|
|
1301
|
+
const skipSelfMention = options?.skipSelfMention ?? true;
|
|
1302
|
+
const rawText = event.text || "";
|
|
1096
1303
|
let userName = event.username || "unknown";
|
|
1097
1304
|
let fullName = event.username || "unknown";
|
|
1098
1305
|
if (event.user && !event.username) {
|
|
@@ -1100,6 +1307,7 @@ var SlackAdapter = class _SlackAdapter {
|
|
|
1100
1307
|
userName = userInfo.displayName;
|
|
1101
1308
|
fullName = userInfo.realName;
|
|
1102
1309
|
}
|
|
1310
|
+
const text = await this.resolveInlineMentions(rawText, skipSelfMention);
|
|
1103
1311
|
return new Message({
|
|
1104
1312
|
id: event.ts || "",
|
|
1105
1313
|
threadId,
|
|
@@ -1785,7 +1993,7 @@ var SlackAdapter = class _SlackAdapter {
|
|
|
1785
1993
|
}
|
|
1786
1994
|
decodeThreadId(threadId) {
|
|
1787
1995
|
const parts = threadId.split(":");
|
|
1788
|
-
if (parts.length
|
|
1996
|
+
if (parts.length < 2 || parts.length > 3 || parts[0] !== "slack") {
|
|
1789
1997
|
throw new ValidationError(
|
|
1790
1998
|
"slack",
|
|
1791
1999
|
`Invalid Slack thread ID: ${threadId}`
|
|
@@ -1793,7 +2001,7 @@ var SlackAdapter = class _SlackAdapter {
|
|
|
1793
2001
|
}
|
|
1794
2002
|
return {
|
|
1795
2003
|
channel: parts[1],
|
|
1796
|
-
threadTs: parts[2]
|
|
2004
|
+
threadTs: parts.length === 3 ? parts[2] : ""
|
|
1797
2005
|
};
|
|
1798
2006
|
}
|
|
1799
2007
|
parseMessage(raw) {
|
|
@@ -1837,6 +2045,221 @@ var SlackAdapter = class _SlackAdapter {
|
|
|
1837
2045
|
)
|
|
1838
2046
|
});
|
|
1839
2047
|
}
|
|
2048
|
+
// =========================================================================
|
|
2049
|
+
// Channel-level methods
|
|
2050
|
+
// =========================================================================
|
|
2051
|
+
/**
|
|
2052
|
+
* Derive channel ID from a Slack thread ID.
|
|
2053
|
+
* Slack thread IDs are "slack:CHANNEL:THREAD_TS", channel ID is "slack:CHANNEL".
|
|
2054
|
+
*/
|
|
2055
|
+
channelIdFromThreadId(threadId) {
|
|
2056
|
+
const { channel } = this.decodeThreadId(threadId);
|
|
2057
|
+
return `slack:${channel}`;
|
|
2058
|
+
}
|
|
2059
|
+
/**
|
|
2060
|
+
* Fetch channel-level messages (conversations.history, not thread replies).
|
|
2061
|
+
*/
|
|
2062
|
+
async fetchChannelMessages(channelId, options = {}) {
|
|
2063
|
+
const channel = channelId.split(":")[1];
|
|
2064
|
+
if (!channel) {
|
|
2065
|
+
throw new ValidationError(
|
|
2066
|
+
"slack",
|
|
2067
|
+
`Invalid Slack channel ID: ${channelId}`
|
|
2068
|
+
);
|
|
2069
|
+
}
|
|
2070
|
+
const direction = options.direction ?? "backward";
|
|
2071
|
+
const limit = options.limit || 100;
|
|
2072
|
+
try {
|
|
2073
|
+
if (direction === "forward") {
|
|
2074
|
+
return await this.fetchChannelMessagesForward(
|
|
2075
|
+
channel,
|
|
2076
|
+
limit,
|
|
2077
|
+
options.cursor
|
|
2078
|
+
);
|
|
2079
|
+
}
|
|
2080
|
+
return await this.fetchChannelMessagesBackward(
|
|
2081
|
+
channel,
|
|
2082
|
+
limit,
|
|
2083
|
+
options.cursor
|
|
2084
|
+
);
|
|
2085
|
+
} catch (error) {
|
|
2086
|
+
this.handleSlackError(error);
|
|
2087
|
+
}
|
|
2088
|
+
}
|
|
2089
|
+
async fetchChannelMessagesForward(channel, limit, cursor) {
|
|
2090
|
+
this.logger.debug("Slack API: conversations.history (forward)", {
|
|
2091
|
+
channel,
|
|
2092
|
+
limit,
|
|
2093
|
+
cursor
|
|
2094
|
+
});
|
|
2095
|
+
const result = await this.client.conversations.history(
|
|
2096
|
+
this.withToken({
|
|
2097
|
+
channel,
|
|
2098
|
+
limit,
|
|
2099
|
+
oldest: cursor,
|
|
2100
|
+
inclusive: cursor ? false : void 0
|
|
2101
|
+
})
|
|
2102
|
+
);
|
|
2103
|
+
const slackMessages = (result.messages || []).reverse();
|
|
2104
|
+
const messages = await Promise.all(
|
|
2105
|
+
slackMessages.map((msg) => {
|
|
2106
|
+
const threadTs = msg.thread_ts || msg.ts || "";
|
|
2107
|
+
const threadId = `slack:${channel}:${threadTs}`;
|
|
2108
|
+
return this.parseSlackMessage(msg, threadId, {
|
|
2109
|
+
skipSelfMention: false
|
|
2110
|
+
});
|
|
2111
|
+
})
|
|
2112
|
+
);
|
|
2113
|
+
let nextCursor;
|
|
2114
|
+
if (result.has_more && slackMessages.length > 0) {
|
|
2115
|
+
const newest = slackMessages[slackMessages.length - 1];
|
|
2116
|
+
if (newest?.ts) {
|
|
2117
|
+
nextCursor = newest.ts;
|
|
2118
|
+
}
|
|
2119
|
+
}
|
|
2120
|
+
return {
|
|
2121
|
+
messages,
|
|
2122
|
+
nextCursor
|
|
2123
|
+
};
|
|
2124
|
+
}
|
|
2125
|
+
async fetchChannelMessagesBackward(channel, limit, cursor) {
|
|
2126
|
+
this.logger.debug("Slack API: conversations.history (backward)", {
|
|
2127
|
+
channel,
|
|
2128
|
+
limit,
|
|
2129
|
+
cursor
|
|
2130
|
+
});
|
|
2131
|
+
const result = await this.client.conversations.history(
|
|
2132
|
+
this.withToken({
|
|
2133
|
+
channel,
|
|
2134
|
+
limit,
|
|
2135
|
+
latest: cursor,
|
|
2136
|
+
inclusive: cursor ? false : void 0
|
|
2137
|
+
})
|
|
2138
|
+
);
|
|
2139
|
+
const slackMessages = result.messages || [];
|
|
2140
|
+
const chronological = [...slackMessages].reverse();
|
|
2141
|
+
const messages = await Promise.all(
|
|
2142
|
+
chronological.map((msg) => {
|
|
2143
|
+
const threadTs = msg.thread_ts || msg.ts || "";
|
|
2144
|
+
const threadId = `slack:${channel}:${threadTs}`;
|
|
2145
|
+
return this.parseSlackMessage(msg, threadId, {
|
|
2146
|
+
skipSelfMention: false
|
|
2147
|
+
});
|
|
2148
|
+
})
|
|
2149
|
+
);
|
|
2150
|
+
let nextCursor;
|
|
2151
|
+
if (result.has_more && chronological.length > 0) {
|
|
2152
|
+
const oldest = chronological[0];
|
|
2153
|
+
if (oldest?.ts) {
|
|
2154
|
+
nextCursor = oldest.ts;
|
|
2155
|
+
}
|
|
2156
|
+
}
|
|
2157
|
+
return {
|
|
2158
|
+
messages,
|
|
2159
|
+
nextCursor
|
|
2160
|
+
};
|
|
2161
|
+
}
|
|
2162
|
+
/**
|
|
2163
|
+
* List threads in a Slack channel.
|
|
2164
|
+
* Fetches channel history and filters for messages with replies.
|
|
2165
|
+
*/
|
|
2166
|
+
async listThreads(channelId, options = {}) {
|
|
2167
|
+
const channel = channelId.split(":")[1];
|
|
2168
|
+
if (!channel) {
|
|
2169
|
+
throw new ValidationError(
|
|
2170
|
+
"slack",
|
|
2171
|
+
`Invalid Slack channel ID: ${channelId}`
|
|
2172
|
+
);
|
|
2173
|
+
}
|
|
2174
|
+
const limit = options.limit || 50;
|
|
2175
|
+
try {
|
|
2176
|
+
this.logger.debug("Slack API: conversations.history (listThreads)", {
|
|
2177
|
+
channel,
|
|
2178
|
+
limit,
|
|
2179
|
+
cursor: options.cursor
|
|
2180
|
+
});
|
|
2181
|
+
const result = await this.client.conversations.history(
|
|
2182
|
+
this.withToken({
|
|
2183
|
+
channel,
|
|
2184
|
+
limit: Math.min(limit * 3, 200),
|
|
2185
|
+
// Fetch extra since not all have threads
|
|
2186
|
+
cursor: options.cursor
|
|
2187
|
+
})
|
|
2188
|
+
);
|
|
2189
|
+
const slackMessages = result.messages || [];
|
|
2190
|
+
const threadMessages = slackMessages.filter(
|
|
2191
|
+
(msg) => (msg.reply_count ?? 0) > 0
|
|
2192
|
+
);
|
|
2193
|
+
const selected = threadMessages.slice(0, limit);
|
|
2194
|
+
const threads = await Promise.all(
|
|
2195
|
+
selected.map(async (msg) => {
|
|
2196
|
+
const threadTs = msg.ts || "";
|
|
2197
|
+
const threadId = `slack:${channel}:${threadTs}`;
|
|
2198
|
+
const rootMessage = await this.parseSlackMessage(msg, threadId, {
|
|
2199
|
+
skipSelfMention: false
|
|
2200
|
+
});
|
|
2201
|
+
return {
|
|
2202
|
+
id: threadId,
|
|
2203
|
+
rootMessage,
|
|
2204
|
+
replyCount: msg.reply_count,
|
|
2205
|
+
lastReplyAt: msg.latest_reply ? new Date(Number.parseFloat(msg.latest_reply) * 1e3) : void 0
|
|
2206
|
+
};
|
|
2207
|
+
})
|
|
2208
|
+
);
|
|
2209
|
+
const nextCursor = result.response_metadata?.next_cursor;
|
|
2210
|
+
return {
|
|
2211
|
+
threads,
|
|
2212
|
+
nextCursor: nextCursor || void 0
|
|
2213
|
+
};
|
|
2214
|
+
} catch (error) {
|
|
2215
|
+
this.handleSlackError(error);
|
|
2216
|
+
}
|
|
2217
|
+
}
|
|
2218
|
+
/**
|
|
2219
|
+
* Fetch Slack channel info/metadata.
|
|
2220
|
+
*/
|
|
2221
|
+
async fetchChannelInfo(channelId) {
|
|
2222
|
+
const channel = channelId.split(":")[1];
|
|
2223
|
+
if (!channel) {
|
|
2224
|
+
throw new ValidationError(
|
|
2225
|
+
"slack",
|
|
2226
|
+
`Invalid Slack channel ID: ${channelId}`
|
|
2227
|
+
);
|
|
2228
|
+
}
|
|
2229
|
+
try {
|
|
2230
|
+
this.logger.debug("Slack API: conversations.info (channel)", { channel });
|
|
2231
|
+
const result = await this.client.conversations.info(
|
|
2232
|
+
this.withToken({ channel })
|
|
2233
|
+
);
|
|
2234
|
+
const info = result.channel;
|
|
2235
|
+
return {
|
|
2236
|
+
id: channelId,
|
|
2237
|
+
name: info?.name ? `#${info.name}` : void 0,
|
|
2238
|
+
isDM: info?.is_im || info?.is_mpim || false,
|
|
2239
|
+
memberCount: info?.num_members,
|
|
2240
|
+
metadata: {
|
|
2241
|
+
purpose: info?.purpose?.value,
|
|
2242
|
+
topic: info?.topic?.value
|
|
2243
|
+
}
|
|
2244
|
+
};
|
|
2245
|
+
} catch (error) {
|
|
2246
|
+
this.handleSlackError(error);
|
|
2247
|
+
}
|
|
2248
|
+
}
|
|
2249
|
+
/**
|
|
2250
|
+
* Post a top-level message to a channel (not in a thread).
|
|
2251
|
+
*/
|
|
2252
|
+
async postChannelMessage(channelId, message) {
|
|
2253
|
+
const channel = channelId.split(":")[1];
|
|
2254
|
+
if (!channel) {
|
|
2255
|
+
throw new ValidationError(
|
|
2256
|
+
"slack",
|
|
2257
|
+
`Invalid Slack channel ID: ${channelId}`
|
|
2258
|
+
);
|
|
2259
|
+
}
|
|
2260
|
+
const syntheticThreadId = `slack:${channel}:`;
|
|
2261
|
+
return this.postMessage(syntheticThreadId, message);
|
|
2262
|
+
}
|
|
1840
2263
|
renderFormatted(content) {
|
|
1841
2264
|
return this.formatConverter.fromAst(content);
|
|
1842
2265
|
}
|