import { type FilesUploadV2Arguments, WebClient } from "@slack/web-api"; import { resolveTextChunkLimit } from "../auto-reply/chunk.js"; import { loadConfig } from "../config/config.js"; import { logVerbose } from "../globals.js"; import { loadWebMedia } from "../web/media.js"; import type { SlackTokenSource } from "./accounts.js"; import { resolveSlackAccount } from "./accounts.js"; import { markdownToSlackMrkdwnChunks } from "./format.js"; import { resolveSlackBotToken } from "./token.js"; const SLACK_TEXT_LIMIT = 4000; type SlackRecipient = | { kind: "user"; id: string; } | { kind: "channel"; id: string; }; type SlackSendOpts = { token?: string; accountId?: string; mediaUrl?: string; client?: WebClient; threadTs?: string; }; export type SlackSendResult = { messageId: string; channelId: string; }; function resolveToken(params: { explicit?: string; accountId: string; fallbackToken?: string; fallbackSource?: SlackTokenSource; }) { const explicit = resolveSlackBotToken(params.explicit); if (explicit) return explicit; const fallback = resolveSlackBotToken(params.fallbackToken); if (!fallback) { logVerbose( `slack send: missing bot token for account=${params.accountId} explicit=${Boolean( params.explicit, )} source=${params.fallbackSource ?? "unknown"}`, ); throw new Error( `Slack bot token missing for account "${params.accountId}" (set channels.slack.accounts.${params.accountId}.botToken or SLACK_BOT_TOKEN for default).`, ); } return fallback; } function parseRecipient(raw: string): SlackRecipient { const trimmed = raw.trim(); if (!trimmed) { throw new Error("Recipient is required for Slack sends"); } const mentionMatch = trimmed.match(/^<@([A-Z0-9]+)>$/i); if (mentionMatch) { return { kind: "user", id: mentionMatch[1] }; } if (trimmed.startsWith("user:")) { return { kind: "user", id: trimmed.slice("user:".length) }; } if (trimmed.startsWith("channel:")) { return { kind: "channel", id: trimmed.slice("channel:".length) }; } if (trimmed.startsWith("slack:")) { return { kind: "user", id: trimmed.slice("slack:".length) }; } if (trimmed.startsWith("@")) { const candidate = trimmed.slice(1); if (!/^[A-Z0-9]+$/i.test(candidate)) { throw new Error("Slack DMs require a user id (use user: or <@id>)"); } return { kind: "user", id: candidate }; } if (trimmed.startsWith("#")) { const candidate = trimmed.slice(1); if (!/^[A-Z0-9]+$/i.test(candidate)) { throw new Error("Slack channels require a channel id (use channel:)"); } return { kind: "channel", id: candidate }; } return { kind: "channel", id: trimmed }; } async function resolveChannelId( client: WebClient, recipient: SlackRecipient, ): Promise<{ channelId: string; isDm?: boolean }> { if (recipient.kind === "channel") { return { channelId: recipient.id }; } const response = await client.conversations.open({ users: recipient.id }); const channelId = response.channel?.id; if (!channelId) { throw new Error("Failed to open Slack DM channel"); } return { channelId, isDm: true }; } async function uploadSlackFile(params: { client: WebClient; channelId: string; mediaUrl: string; caption?: string; threadTs?: string; maxBytes?: number; }): Promise { const { buffer, contentType, fileName } = await loadWebMedia(params.mediaUrl, params.maxBytes); const basePayload = { channel_id: params.channelId, file: buffer, filename: fileName, ...(params.caption ? { initial_comment: params.caption } : {}), ...(contentType ? { filetype: contentType } : {}), }; const payload: FilesUploadV2Arguments = params.threadTs ? { ...basePayload, thread_ts: params.threadTs } : basePayload; const response = await params.client.files.uploadV2(payload); const parsed = response as { files?: Array<{ id?: string; name?: string }>; file?: { id?: string; name?: string }; }; const fileId = parsed.files?.[0]?.id ?? parsed.file?.id ?? parsed.files?.[0]?.name ?? parsed.file?.name ?? "unknown"; return fileId; } export async function sendMessageSlack( to: string, message: string, opts: SlackSendOpts = {}, ): Promise { const trimmedMessage = message?.trim() ?? ""; if (!trimmedMessage && !opts.mediaUrl) { throw new Error("Slack send requires text or media"); } const cfg = loadConfig(); const account = resolveSlackAccount({ cfg, accountId: opts.accountId, }); const token = resolveToken({ explicit: opts.token, accountId: account.accountId, fallbackToken: account.botToken, fallbackSource: account.botTokenSource, }); const client = opts.client ?? new WebClient(token); const recipient = parseRecipient(to); const { channelId } = await resolveChannelId(client, recipient); const textLimit = resolveTextChunkLimit(cfg, "slack", account.accountId); const chunkLimit = Math.min(textLimit, SLACK_TEXT_LIMIT); const chunks = markdownToSlackMrkdwnChunks(trimmedMessage, chunkLimit); const mediaMaxBytes = typeof account.config.mediaMaxMb === "number" ? account.config.mediaMaxMb * 1024 * 1024 : undefined; let lastMessageId = ""; if (opts.mediaUrl) { const [firstChunk, ...rest] = chunks; lastMessageId = await uploadSlackFile({ client, channelId, mediaUrl: opts.mediaUrl, caption: firstChunk, threadTs: opts.threadTs, maxBytes: mediaMaxBytes, }); for (const chunk of rest) { const response = await client.chat.postMessage({ channel: channelId, text: chunk, thread_ts: opts.threadTs, }); lastMessageId = response.ts ?? lastMessageId; } } else { for (const chunk of chunks.length ? chunks : [""]) { const response = await client.chat.postMessage({ channel: channelId, text: chunk, thread_ts: opts.threadTs, }); lastMessageId = response.ts ?? lastMessageId; } } return { messageId: lastMessageId || "unknown", channelId, }; }