/** * Extracted helpers for delivering generative approval replies (stale notices, * identity mismatch notices, reminders, etc.) that were duplicated across * guardian-approval-interception.ts. */ import type { MessageAudience } from "@vellumai/gateway-client"; import type pino from "pino"; import type { ChannelId } from "../../channels/types.js"; import type { ApprovalMessageContext } from "../approval-message-composer.js"; import { composeApprovalMessageGenerative } from "../approval-message-composer.js"; import { deliverChannelReply } from "../gateway-client.js"; import type { ApprovalCopyGenerator } from "../http-types.js"; // --------------------------------------------------------------------------- // Deduplication for "already resolved" ephemeral messages // --------------------------------------------------------------------------- /** * Tracks recently sent stale approval notifications to prevent flooding the * user when they rapidly click stale approval buttons. Keyed by * `${chatId}:${scenario}` with a 30-second TTL per entry. */ const recentStaleNotifications = new Set(); /** TTL in milliseconds for dedup entries. Exported for testing. */ const STALE_DEDUP_TTL_MS = 30_000; /** Clear the dedup cache. Exported for testing only. */ export function clearStaleNotificationCache(): void { recentStaleNotifications.clear(); } interface DeliverApprovalReplyParams { context: ApprovalMessageContext; replyCallbackUrl: string; chatId: string; assistantId: string; approvalCopyGenerator?: ApprovalCopyGenerator; logger: pino.Logger; errorLogMessage: string; /** Extra fields merged into the pino error context. */ errorLogContext?: Record; /** * When set, deliver via `chat.postEphemeral` so only this Slack user * sees the message. Used to keep approval-related noise out of shared * channels. */ /** Restricts the reply to one reader. Absent means the room. */ audience?: MessageAudience; } /** * Compose a generative approval message and deliver it as a channel reply. * Throws on failure — callers decide whether to swallow or propagate. */ async function composeAndDeliver( params: DeliverApprovalReplyParams, ): Promise { const { context, replyCallbackUrl, chatId, assistantId, approvalCopyGenerator, audience, } = params; const text = await composeApprovalMessageGenerative( context, {}, approvalCopyGenerator, ); const payload: Parameters[1] = { chatId, text, assistantId, }; if (audience) { payload.audience = audience; } await deliverChannelReply(replyCallbackUrl, payload); } /** * Compose a generative approval message and deliver it as a channel reply. * Swallows delivery errors and logs them — callers don't need their own * try/catch blocks. */ async function deliverApprovalReply( params: DeliverApprovalReplyParams, ): Promise { try { await composeAndDeliver(params); } catch (err) { params.logger.error( { err, ...params.errorLogContext }, params.errorLogMessage, ); } } // --------------------------------------------------------------------------- // Stale approval reply // --------------------------------------------------------------------------- export interface DeliverStaleApprovalReplyParams { scenario: ApprovalMessageContext["scenario"]; sourceChannel: ChannelId; replyCallbackUrl: string; chatId: string; assistantId: string; approvalCopyGenerator?: ApprovalCopyGenerator; logger: pino.Logger; errorLogMessage: string; /** Extra context fields (e.g. pendingCount, toolName) forwarded to the message composer. */ extraContext?: Partial; /** Extra fields merged into the pino error context. */ errorLogContext?: Record; /** * When set, deliver via `chat.postEphemeral` so only this Slack user * sees the message. Keeps approval noise out of shared channels. */ /** Restricts the reply to one reader. Absent means the room. */ audience?: MessageAudience; } /** * Deliver a stale/already-resolved approval notice to a channel chat. * Consolidates the repeated compose + deliver + try/catch pattern. * * For `approval_already_resolved` scenarios, deduplicates notifications * per chat so rapid stale button clicks don't flood the user with * repeated ephemeral warnings. */ export async function deliverStaleApprovalReply( params: DeliverStaleApprovalReplyParams, ): Promise { const { scenario, sourceChannel, extraContext, audience, ...rest } = params; const replyParams: DeliverApprovalReplyParams = { ...rest, audience, context: { scenario, channel: sourceChannel, ...extraContext, }, }; // Deduplicate "already resolved" ephemeral messages per chat. // If the same (chatId, scenario) pair was notified within the TTL, skip. if (scenario === "approval_already_resolved") { const dedupeKey = `${rest.chatId}:${scenario}`; if (recentStaleNotifications.has(dedupeKey)) { return; } // Cache the dedup key only after successful delivery so that failures // don't silently suppress retries for the TTL window. try { await composeAndDeliver(replyParams); recentStaleNotifications.add(dedupeKey); setTimeout(() => { recentStaleNotifications.delete(dedupeKey); }, STALE_DEDUP_TTL_MS); } catch (err) { rest.logger.error({ err, ...rest.errorLogContext }, rest.errorLogMessage); } return; } await deliverApprovalReply(replyParams); } // --------------------------------------------------------------------------- // Identity mismatch reply // --------------------------------------------------------------------------- export interface DeliverIdentityMismatchReplyParams { sourceChannel: ChannelId; replyCallbackUrl: string; chatId: string; assistantId: string; approvalCopyGenerator?: ApprovalCopyGenerator; logger: pino.Logger; errorLogMessage: string; /** Extra fields merged into the pino error context. */ errorLogContext?: Record; /** * When set, deliver via `chat.postEphemeral` so only this Slack user * sees the message. */ /** Restricts the reply to one reader. Absent means the room. */ audience?: MessageAudience; } /** * Deliver a guardian identity mismatch notice. The scenario is always * `guardian_identity_mismatch`. */ export async function deliverIdentityMismatchReply( params: DeliverIdentityMismatchReplyParams, ): Promise { const { sourceChannel, audience, ...rest } = params; await deliverApprovalReply({ ...rest, audience, context: { scenario: "guardian_identity_mismatch", channel: sourceChannel, }, }); }