@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 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((button) => {
114
- if (button.type === "link-button") {
115
- return convertLinkButtonToElement(button);
113
+ const elements = element.children.map((child) => {
114
+ if (child.type === "link-button") {
115
+ return convertLinkButtonToElement(child);
116
116
  }
117
- return convertButtonToElement(button);
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
- text: { type: "plain_text", text: opt.label },
435
- value: opt.value
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((o) => o.value === select.initialOption);
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: action.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
- this.chat.processMessage(
1045
- this,
1046
- threadId,
1047
- () => this.parseSlackMessage(event, threadId),
1048
- options
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
- async parseSlackMessage(event, threadId) {
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 text = event.text || "";
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 !== 3 || parts[0] !== "slack") {
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
  }