import type { MarkdownTableMode } from "openclaw/plugin-sdk/config-runtime"; import { deliverTextOrMediaReply, resolveSendableOutboundReplyParts, } from "openclaw/plugin-sdk/reply-payload"; import type { ChunkMode } from "openclaw/plugin-sdk/reply-runtime"; import { chunkMarkdownTextWithMode } from "openclaw/plugin-sdk/reply-runtime"; import { createReplyReferencePlanner } from "openclaw/plugin-sdk/reply-runtime"; import { isSilentReplyText, SILENT_REPLY_TOKEN } from "openclaw/plugin-sdk/reply-runtime"; import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime"; import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; import { parseSlackBlocksInput } from "../blocks-input.js"; import { markdownToSlackMrkdwnChunks } from "../format.js"; import { sendMessageSlack, type SlackSendIdentity } from "../send.js"; export function readSlackReplyBlocks(payload: ReplyPayload) { const slackData = payload.channelData?.slack; if (!slackData || typeof slackData !== "object" || Array.isArray(slackData)) { return undefined; } try { return parseSlackBlocksInput((slackData as { blocks?: unknown }).blocks); } catch { return undefined; } } export async function deliverReplies(params: { replies: ReplyPayload[]; target: string; token: string; accountId?: string; runtime: RuntimeEnv; textLimit: number; replyThreadTs?: string; replyToMode: "off" | "first" | "all"; identity?: SlackSendIdentity; }) { for (const payload of params.replies) { // Keep reply tags opt-in: when replyToMode is off, explicit reply tags // must not force threading. const inlineReplyToId = params.replyToMode === "off" ? undefined : payload.replyToId; const threadTs = inlineReplyToId ?? params.replyThreadTs; const reply = resolveSendableOutboundReplyParts(payload); const slackBlocks = readSlackReplyBlocks(payload); if (!reply.hasContent && !slackBlocks?.length) { continue; } if (!reply.hasMedia && slackBlocks?.length) { const trimmed = reply.trimmedText; if (!trimmed && !slackBlocks?.length) { continue; } if (trimmed && isSilentReplyText(trimmed, SILENT_REPLY_TOKEN)) { continue; } await sendMessageSlack(params.target, trimmed, { token: params.token, threadTs, accountId: params.accountId, ...(slackBlocks?.length ? { blocks: slackBlocks } : {}), ...(params.identity ? { identity: params.identity } : {}), }); params.runtime.log?.(`delivered reply to ${params.target}`); continue; } const delivered = await deliverTextOrMediaReply({ payload, text: reply.text, chunkText: !reply.hasMedia ? (value) => { const trimmed = value.trim(); if (!trimmed || isSilentReplyText(trimmed, SILENT_REPLY_TOKEN)) { return []; } return [trimmed]; } : undefined, sendText: async (trimmed) => { await sendMessageSlack(params.target, trimmed, { token: params.token, threadTs, accountId: params.accountId, ...(params.identity ? { identity: params.identity } : {}), }); }, sendMedia: async ({ mediaUrl, caption }) => { await sendMessageSlack(params.target, caption ?? "", { token: params.token, mediaUrl, threadTs, accountId: params.accountId, ...(params.identity ? { identity: params.identity } : {}), }); }, }); if (delivered !== "empty") { params.runtime.log?.(`delivered reply to ${params.target}`); } } } export type SlackRespondFn = (payload: { text: string; response_type?: "ephemeral" | "in_channel"; }) => Promise; /** * Compute effective threadTs for a Slack reply based on replyToMode. * - "off": stay in thread if already in one, otherwise main channel * - "first": first reply goes to thread, subsequent replies to main channel * - "all": all replies go to thread */ export function resolveSlackThreadTs(params: { replyToMode: "off" | "first" | "all"; incomingThreadTs: string | undefined; messageTs: string | undefined; hasReplied: boolean; isThreadReply?: boolean; }): string | undefined { const planner = createSlackReplyReferencePlanner({ replyToMode: params.replyToMode, incomingThreadTs: params.incomingThreadTs, messageTs: params.messageTs, hasReplied: params.hasReplied, isThreadReply: params.isThreadReply, }); return planner.use(); } type SlackReplyDeliveryPlan = { nextThreadTs: () => string | undefined; markSent: () => void; }; function createSlackReplyReferencePlanner(params: { replyToMode: "off" | "first" | "all"; incomingThreadTs: string | undefined; messageTs: string | undefined; hasReplied?: boolean; isThreadReply?: boolean; }) { // Keep backward-compatible behavior: when a thread id is present and caller // does not provide explicit classification, stay in thread. Callers that can // distinguish Slack's auto-populated top-level thread_ts should pass // `isThreadReply: false` to preserve replyToMode behavior. const effectiveIsThreadReply = params.isThreadReply ?? Boolean(params.incomingThreadTs); const effectiveMode = effectiveIsThreadReply ? "all" : params.replyToMode; return createReplyReferencePlanner({ replyToMode: effectiveMode, existingId: params.incomingThreadTs, startId: params.messageTs, hasReplied: params.hasReplied, }); } export function createSlackReplyDeliveryPlan(params: { replyToMode: "off" | "first" | "all"; incomingThreadTs: string | undefined; messageTs: string | undefined; hasRepliedRef: { value: boolean }; isThreadReply?: boolean; }): SlackReplyDeliveryPlan { const replyReference = createSlackReplyReferencePlanner({ replyToMode: params.replyToMode, incomingThreadTs: params.incomingThreadTs, messageTs: params.messageTs, hasReplied: params.hasRepliedRef.value, isThreadReply: params.isThreadReply, }); return { nextThreadTs: () => replyReference.use(), markSent: () => { replyReference.markSent(); params.hasRepliedRef.value = replyReference.hasReplied(); }, }; } export async function deliverSlackSlashReplies(params: { replies: ReplyPayload[]; respond: SlackRespondFn; ephemeral: boolean; textLimit: number; tableMode?: MarkdownTableMode; chunkMode?: ChunkMode; }) { const messages: string[] = []; const chunkLimit = Math.min(params.textLimit, 4000); for (const payload of params.replies) { const reply = resolveSendableOutboundReplyParts(payload); const text = reply.hasText && !isSilentReplyText(reply.trimmedText, SILENT_REPLY_TOKEN) ? reply.trimmedText : undefined; const combined = [text ?? "", ...reply.mediaUrls].filter(Boolean).join("\n"); if (!combined) { continue; } const chunkMode = params.chunkMode ?? "length"; const markdownChunks = chunkMode === "newline" ? chunkMarkdownTextWithMode(combined, chunkLimit, chunkMode) : [combined]; const chunks = markdownChunks.flatMap((markdown) => markdownToSlackMrkdwnChunks(markdown, chunkLimit, { tableMode: params.tableMode }), ); if (!chunks.length && combined) { chunks.push(combined); } for (const chunk of chunks) { messages.push(chunk); } } if (messages.length === 0) { return; } // Slack slash command responses can be multi-part by sending follow-ups via response_url. const responseType = params.ephemeral ? "ephemeral" : "in_channel"; for (const text of messages) { await params.respond({ text, response_type: responseType }); } }