228 lines
7.6 KiB
TypeScript
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 });
|
|
}
|
|
}
|