2026-03-18 18:14:57 +00:00

228 lines
7.6 KiB
TypeScript

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<unknown>;
/**
* 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 });
}
}