Files
clawdbot/src/slack/monitor/replies.ts
2026-01-15 00:31:07 +00:00

143 lines
4.6 KiB
TypeScript

import { createReplyReferencePlanner } from "../../auto-reply/reply/reply-reference.js";
import { isSilentReplyText, SILENT_REPLY_TOKEN } from "../../auto-reply/tokens.js";
import type { ReplyPayload } from "../../auto-reply/types.js";
import type { RuntimeEnv } from "../../runtime.js";
import { markdownToSlackMrkdwnChunks } from "../format.js";
import { sendMessageSlack } from "../send.js";
export async function deliverReplies(params: {
replies: ReplyPayload[];
target: string;
token: string;
accountId?: string;
runtime: RuntimeEnv;
textLimit: number;
replyThreadTs?: string;
}) {
for (const payload of params.replies) {
const threadTs = payload.replyToId ?? params.replyThreadTs;
const mediaList = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
const text = payload.text ?? "";
if (!text && mediaList.length === 0) continue;
if (mediaList.length === 0) {
const trimmed = text.trim();
if (!trimmed || isSilentReplyText(trimmed, SILENT_REPLY_TOKEN)) continue;
await sendMessageSlack(params.target, trimmed, {
token: params.token,
threadTs,
accountId: params.accountId,
});
} else {
let first = true;
for (const mediaUrl of mediaList) {
const caption = first ? text : "";
first = false;
await sendMessageSlack(params.target, caption, {
token: params.token,
mediaUrl,
threadTs,
accountId: params.accountId,
});
}
}
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;
}): string | undefined {
const planner = createSlackReplyReferencePlanner({
replyToMode: params.replyToMode,
incomingThreadTs: params.incomingThreadTs,
messageTs: params.messageTs,
hasReplied: params.hasReplied,
});
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;
}) {
return createReplyReferencePlanner({
replyToMode: params.replyToMode,
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 };
}): SlackReplyDeliveryPlan {
const replyReference = createSlackReplyReferencePlanner({
replyToMode: params.replyToMode,
incomingThreadTs: params.incomingThreadTs,
messageTs: params.messageTs,
hasReplied: params.hasRepliedRef.value,
});
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;
}) {
const messages: string[] = [];
const chunkLimit = Math.min(params.textLimit, 4000);
for (const payload of params.replies) {
const textRaw = payload.text?.trim() ?? "";
const text = textRaw && !isSilentReplyText(textRaw, SILENT_REPLY_TOKEN) ? textRaw : undefined;
const mediaList = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
const combined = [text ?? "", ...mediaList.map((url) => url.trim()).filter(Boolean)]
.filter(Boolean)
.join("\n");
if (!combined) continue;
for (const chunk of markdownToSlackMrkdwnChunks(combined, chunkLimit)) {
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 });
}
}