Files
openclaw/extensions/slack/src/send.ts
T
Peter Steinberger 5bab0c2ea8 feat(slack): support running as a real Slack user (user identity) (#109837)
* feat(slack): add user-identity config surface (session token/cookie)

Introduce an account-level identity discriminator ("bot" default | "user") for
the Slack channel, plus sessionToken/sessionCookie secret fields for the
browser-session (user) path. Schema enforces structural coupling only
(identity="user" rejects appToken/signingSecret/relay/non-default mode);
credential presence is deferred to runtime like bot/app tokens. Account entries
inherit the top-level identity. Adds env fallback (SLACK_SESSION_TOKEN/COOKIE,
default account only), secret-contract registration, provenance *Source fields,
and UI hints. Config surface only; resolver/transport are later phases.

* refactor(slack): identity-aware operation-token resolver + dedup

Route Slack read/write token selection through a single identity-aware
resolveSlackOperationToken: identity="user" selects the browser-session token
for both operations (companion cookie attached later at client construction),
identity="bot" keeps the exact userToken/botToken + userTokenReadOnly behavior.
Delete the duplicated token-selection gate in action-runtime and route direct
sends/DM opens through the shared resolver with identity-neutral error wording.
Behavior-neutral for bot identity; net non-test LOC -2.

* refactor(slack): retarget user identity to xoxp + companion app

Replace the abandoned browser-session (xoxc/xoxd) approach for identity="user"
with the official path: authenticate as the human via the existing userToken
(xoxp) while a companion app (appToken/socket or signingSecret/http) carries
events. Remove sessionToken/sessionCookie config, env vars, secret-contract
entries, resolver fields, and their tests. Invert the schema coupling so
identity="user" permits the companion-app transport credentials, and drop the
now-vestigial user-identity structural guard entirely (identity is a permissive
discriminator; credential presence is a runtime concern). Resolver returns the
user token for user-identity reads and writes. Behavior-neutral for bot
identity.

* feat(slack): user-identity provider transport (xoxp + companion app)

Wire the identity="user" event transport. The companion Slack app runs Socket
Mode (appToken) or HTTP (signingSecret) while Bolt authenticates as the human
via the user token (no bot token). Startup auth.test runs on the user token and
its user_id becomes the self/mention target and self-send dedupe id (Bolt's
lazy per-event authorize populates context.botUserId=user_id, so the existing
self-event middleware drops the human's own messages). Identity-aware token
requirements and diagnostics; the bot-token identity warning is suppressed for
user identity. Behavior-neutral for identity="bot".

* feat(slack): user-identity setup, doctor, and docs

Add the setup wizard, doctor/status, and documentation for identity="user".
The wizard collects a user token plus the companion-app transport credential
(app token for socket, signing secret for http) and only persists identity when
"user" so bot setups keep byte-identical generated config; doctor validates the
user token via auth.test and warns on a missing transport credential; account
inspection reports user-token status. Message actions are gated on the active
identity's credential so user accounts expose actions. Docs add the exact
companion-app recipe (user scopes, on-behalf-of-user message events, socket/http
examples, the DM/group-DM-only-via-user-scope note, and self-send dedupe).
Behavior-neutral for identity="bot".

* test(slack): isolate user-identity provider tests to fix parallel flake

The new user-identity provider tests shared the globalThis-stored Slack test
client/handlers across files under parallel isolate:false, so their reaction
closures could reference a prior file's state and intermittently fail sibling
monitor tests (monitor.tool-result reactions). Track started monitors and
abort/await them all in afterEach (even on failed assertions), reset the shared
auth.test/mock state, and dispose the global Slack test runtime per file via a
new disposeSlackTestRuntime helper. Full Slack suite green across repeated
parallel runs.

* fix(slack): regenerate channel config metadata after rebase

* fix(slack): resolve setup consistent-return and doctor dead-export lint

* style: format install script test

* docs(slack): regenerate docs map for user-identity section

* test(slack): use renamed createSlackBoltApp token param in interop test

The rebase folded main's new wrapReceiver test into the renamed
createSlackBoltApp signature (botToken -> token); update the call site.
2026-07-17 23:23:21 +01:00

1415 lines
48 KiB
TypeScript

// Slack plugin module implements send behavior.
import { createHash, createHmac } from "node:crypto";
import type { MessageMetadata } from "@slack/types";
import type { Block, KnownBlock, WebClient } from "@slack/web-api";
import {
createMessageReceiptFromOutboundResults,
type ChannelMessageUnknownSendContext,
type ChannelMessageUnknownSendReconciliationResult,
type MessageReceipt,
type MessageReceiptPartKind,
type MessageReceiptSourceResult,
} from "openclaw/plugin-sdk/channel-outbound";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { KeyedAsyncQueue } from "openclaw/plugin-sdk/keyed-async-queue";
import { resolveMarkdownTableMode } from "openclaw/plugin-sdk/markdown-table-runtime";
import { requireRuntimeConfig } from "openclaw/plugin-sdk/plugin-config-runtime";
import {
chunkMarkdownTextWithMode,
isSilentReplyText,
resolveChunkMode,
resolveTextChunkLimit,
} from "openclaw/plugin-sdk/reply-chunking";
import { resolveTextChunksWithFallback } from "openclaw/plugin-sdk/reply-payload";
import { logVerbose } from "openclaw/plugin-sdk/runtime-env";
import { safeEqualSecret } from "openclaw/plugin-sdk/security-runtime";
import {
normalizeOptionalString,
normalizeOptionalString as normalizeSlackApiString,
} from "openclaw/plugin-sdk/string-coerce-runtime";
import { sliceUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime";
import type { SlackTokenSource } from "./accounts.js";
import { resolveSlackAccount, resolveSlackOperationToken } from "./accounts.js";
import type { SlackAuthoredTextPlacement } from "./authored-text.js";
import { buildSlackCompleteBlocksFallbackText } from "./blocks-fallback.js";
import { validateSlackBlocksArray } from "./blocks-input.js";
import {
postSlackMessageBestEffort,
uploadSlackFile,
withSlackDnsRequestRetry,
} from "./client-delivery.js";
import { createSlackTokenCacheKey, createSlackWebClient, getSlackWriteClient } from "./client.js";
import { assertSlackDirectSendAllowed } from "./direct-send-admission.js";
import { chunkSlackMrkdwnText, markdownToSlackMrkdwnChunks } from "./format.js";
import { SLACK_TEXT_LIMIT } from "./limits.js";
import {
buildSlackNativeDataAccessibilityText,
hasSlackNativeDataBlock,
isSlackInvalidBlocksError,
} from "./native-data-blocks.js";
import { buildSlackNativeDataDeliveryPlan } from "./native-data-fallback.js";
import { recordSlackThreadParticipation } from "./sent-thread-cache.js";
import { canonicalizeSlackApiTargetId, parseSlackTarget } from "./target-parsing.js";
import { normalizeSlackThreadTsCandidate, resolveSlackThreadTsValue } from "./thread-ts.js";
import { truncateSlackText } from "./truncate.js";
const SLACK_DM_CHANNEL_CACHE_MAX = 1024;
const SLACK_DELIVERY_METADATA_EVENT = "openclaw_delivery";
const SLACK_DELIVERY_METADATA_KEY = "openclaw_delivery_id";
const SLACK_DELIVERY_METADATA_PART_INDEX_KEY = "openclaw_delivery_part_index";
const SLACK_DELIVERY_METADATA_PART_COUNT_KEY = "openclaw_delivery_part_count";
const SLACK_DELIVERY_METADATA_SIGNATURE_KEY = "openclaw_delivery_signature";
const SLACK_RECONCILE_LOOKBACK_MS = 30_000;
const SLACK_RECONCILE_CLOCK_SKEW_MS = 5 * 60_000;
const SLACK_RECONCILE_LIMIT = 100;
const SLACK_RECONCILE_MAX_PAGES = 10;
const SLACK_ENTERPRISE_LISTENER_QUEUE_CREDENTIAL = "listener-scoped-enterprise";
const slackDmChannelCaches = new WeakMap<WebClient, Map<string, string>>();
const slackSendQueue = new KeyedAsyncQueue();
type SlackRecipient =
| {
kind: "user";
id: string;
}
| {
kind: "channel";
id: string;
};
export type SlackSendIdentity = {
username?: string;
iconUrl?: string;
iconEmoji?: string;
};
type SlackEnterpriseEventScope = Readonly<{
apiAppId: string;
enterpriseId: string;
teamId: string;
isEnterpriseInstall: true;
client: WebClient;
uploadCompletionClient?: WebClient;
}>;
type SlackEnterpriseDelivery = Readonly<{
client: WebClient;
teamId: string;
uploadCompletionClient?: WebClient;
}>;
const slackDefaultSendIdentities = new Map<string, SlackSendIdentity>();
type SlackSendOpts = {
cfg: OpenClawConfig;
token?: string;
accountId?: string;
mediaUrl?: string;
mediaAccess?: {
localRoots?: readonly string[];
readFile?: (filePath: string) => Promise<Buffer>;
};
uploadFileName?: string;
uploadTitle?: string;
mediaLocalRoots?: readonly string[];
mediaReadFile?: (filePath: string) => Promise<Buffer>;
client?: WebClient;
/** Monitor-private proof that `client` belongs to the validated Enterprise event turn. */
enterpriseEventScope?: SlackEnterpriseEventScope;
/** Monitor-private delivery limits already resolved for the active listener. */
textLimit?: number;
/** Slack-private marker for text that is already safe mrkdwn and must not be parsed again. */
textIsSlackMrkdwn?: boolean;
/** Slack-private literal fallback text; disables mrkdwn parsing. */
textIsSlackPlainText?: boolean;
/** Producer-owned placement of authored text after final block compilation. */
authoredTextPlacement?: SlackAuthoredTextPlacement;
/** Slack-private authored text outside blocks; enables ordered plain accessibility text. */
nativeDataFallbackBaseText?: string;
mediaMaxBytes?: number;
threadTs?: string;
replyBroadcast?: boolean;
identity?: SlackSendIdentity;
blocks?: (Block | KnownBlock)[];
metadata?: MessageMetadata;
/** Opaque durable intent id used to reconcile ambiguous platform outcomes. */
deliveryQueueId?: string;
/** Refresh durable timing after the per-target queue and before Slack API work. */
onPlatformSendDispatch?: () => Promise<void>;
/** Persist each concrete platform send before any later chunk can fail. */
onDeliveryResult?: (result: SlackSendResult) => Promise<void> | void;
};
type SlackWebApiErrorData = {
error?: unknown;
needed?: unknown;
response_metadata?: {
scopes?: unknown;
acceptedScopes?: unknown;
};
};
type SlackWebApiError = Error & {
data?: SlackWebApiErrorData;
};
function hasCustomIdentity(identity?: SlackSendIdentity): boolean {
return Boolean(identity?.username || identity?.iconUrl || identity?.iconEmoji);
}
function normalizeSlackSendIdentity(identity?: SlackSendIdentity): SlackSendIdentity | undefined {
const username = normalizeOptionalString(identity?.username);
const iconUrl = normalizeOptionalString(identity?.iconUrl);
const iconEmoji = normalizeOptionalString(identity?.iconEmoji);
const normalized = {
...(username ? { username } : {}),
...(iconUrl ? { iconUrl } : {}),
...(iconEmoji ? { iconEmoji } : {}),
};
return hasCustomIdentity(normalized) ? normalized : undefined;
}
export function setSlackDefaultSendIdentity(accountId: string, identity?: SlackSendIdentity): void {
const normalizedAccountId = normalizeOptionalString(accountId);
if (!normalizedAccountId) {
return;
}
const normalizedIdentity = normalizeSlackSendIdentity(identity);
if (normalizedIdentity) {
slackDefaultSendIdentities.set(normalizedAccountId, normalizedIdentity);
} else {
slackDefaultSendIdentities.delete(normalizedAccountId);
}
}
function getSlackDefaultSendIdentity(accountId: string): SlackSendIdentity | undefined {
const normalizedAccountId = normalizeOptionalString(accountId);
return normalizedAccountId ? slackDefaultSendIdentities.get(normalizedAccountId) : undefined;
}
function resolveSlackSendIdentity(params: {
accountId: string;
explicit?: SlackSendIdentity;
}): SlackSendIdentity | undefined {
return (
normalizeSlackSendIdentity(params.explicit) ?? getSlackDefaultSendIdentity(params.accountId)
);
}
function normalizeSlackScopeList(value: unknown): string[] {
if (!Array.isArray(value)) {
return [];
}
return value.flatMap((scope) => {
const normalized = normalizeSlackApiString(scope);
return normalized ? [normalized] : [];
});
}
function getSlackWebApiErrorData(err: unknown): SlackWebApiErrorData | undefined {
if (!(err instanceof Error)) {
return undefined;
}
const data = (err as SlackWebApiError).data;
if (!data || typeof data !== "object") {
return undefined;
}
return data;
}
function formatSlackWebApiErrorMessage(err: unknown): string | undefined {
if (!(err instanceof Error)) {
return undefined;
}
const data = getSlackWebApiErrorData(err);
const code = normalizeSlackApiString(data?.error);
if (!code) {
return undefined;
}
const details: string[] = [];
const needed = normalizeSlackApiString(data?.needed);
if (needed) {
details.push(`needed: ${needed}`);
}
const scopes = normalizeSlackScopeList(data?.response_metadata?.scopes);
if (scopes.length) {
details.push(`granted: ${scopes.join(", ")}`);
}
const acceptedScopes = normalizeSlackScopeList(data?.response_metadata?.acceptedScopes);
if (acceptedScopes.length) {
details.push(`accepted: ${acceptedScopes.join(", ")}`);
}
return `${err.message || `An API error occurred: ${code}`}${
details.length ? ` (${details.join("; ")})` : ""
}`;
}
function enrichSlackWebApiError(err: unknown): unknown {
const message = formatSlackWebApiErrorMessage(err);
if (!message || !(err instanceof Error) || message === err.message) {
return err;
}
return new Error(message);
}
function readSlackRequestErrorMessage(value: unknown): string {
if (value instanceof Error) {
return value.message;
}
return typeof value === "string" ? value : "";
}
function resolvePostedMessageThreadTs(response: {
message?: { thread_ts?: unknown };
}): string | undefined {
const threadTs = response.message?.thread_ts;
return typeof threadTs === "string" ? normalizeSlackThreadTsCandidate(threadTs) : undefined;
}
export type SlackSendResult = {
messageId: string;
channelId: string;
receipt: MessageReceipt;
threadTs?: string;
};
type SlackConversationMessage = {
ts?: unknown;
thread_ts?: unknown;
metadata?: unknown;
};
type SlackConversationLookupResponse = {
messages?: unknown;
has_more?: unknown;
response_metadata?: { next_cursor?: unknown };
};
type SlackConversationLookupClient = Pick<WebClient, "conversations">;
function createSlackSendReceipt(params: {
platformMessageIds: readonly string[];
channelId?: string;
kind: MessageReceiptPartKind;
threadTs?: string;
}): MessageReceipt {
const platformMessageIds = params.platformMessageIds
.map((messageId) => messageId.trim())
.filter((messageId) => messageId && messageId !== "unknown" && messageId !== "suppressed");
return createMessageReceiptFromOutboundResults({
results: platformMessageIds.map((messageId) => {
const result: MessageReceiptSourceResult = {
channel: "slack",
messageId,
};
if (params.channelId) {
result.channelId = params.channelId;
}
return result;
}),
kind: params.kind,
threadId: params.threadTs,
});
}
function resolveToken(params: {
explicit?: string;
accountId: string;
fallbackToken?: string;
fallbackSource?: SlackTokenSource;
}) {
const explicit = normalizeOptionalString(params.explicit);
if (explicit) {
return explicit;
}
const fallback = normalizeOptionalString(params.fallbackToken);
if (!fallback) {
logVerbose(
`slack send: missing write token for account=${params.accountId} explicit=${Boolean(
params.explicit,
)} source=${params.fallbackSource ?? "unknown"}`,
);
throw new Error(`Slack write token missing for account "${params.accountId}".`);
}
return fallback;
}
function parseRecipient(raw: string): SlackRecipient {
const target = parseSlackTarget(raw);
if (!target) {
throw new Error("Recipient is required for Slack sends");
}
return {
kind: target.kind,
id: canonicalizeSlackApiTargetId(target.kind, target.id, raw),
};
}
function parseEnterpriseEventRecipient(raw: string): SlackRecipient {
const match = /^(?:channel:)?([CDG][A-Z0-9]+)$/i.exec(raw.trim());
if (!match?.[1]) {
throw new Error("unsupported_enterprise_slack_delivery_target");
}
return { kind: "channel", id: canonicalizeSlackApiTargetId("channel", match[1]) };
}
function resolveEnterpriseEventScope(params: {
account: ReturnType<typeof resolveSlackAccount>;
opts: SlackSendOpts;
}): SlackEnterpriseEventScope | undefined {
const scope = params.opts.enterpriseEventScope;
if (!scope) {
assertSlackDirectSendAllowed(params.account);
return undefined;
}
if (params.account.config.enterpriseOrgInstall !== true) {
throw new Error("unexpected_enterprise_slack_listener_scope");
}
if (
!scope.isEnterpriseInstall ||
!normalizeOptionalString(scope.apiAppId) ||
!normalizeOptionalString(scope.enterpriseId) ||
!/^T[A-Z0-9]+$/i.test(scope.teamId) ||
!scope.client ||
params.opts.client !== scope.client
) {
throw new Error("invalid_enterprise_slack_listener_scope");
}
return scope;
}
function resolveSlackTextChunks(params: {
cfg: OpenClawConfig;
accountId?: string;
text: string;
textLimit?: number;
textIsSlackMrkdwn?: boolean;
preservePlainText?: boolean;
}): string[] {
const text = params.preservePlainText ? params.text : params.text.trim();
const configuredLimit =
params.textLimit ??
resolveTextChunkLimit(params.cfg, "slack", params.accountId, {
fallbackLimit: SLACK_TEXT_LIMIT,
});
const chunkLimit = Math.min(configuredLimit, SLACK_TEXT_LIMIT);
if (params.preservePlainText) {
const chunks: string[] = [];
let remaining = text;
while (remaining) {
const chunk = sliceUtf16Safe(remaining, 0, chunkLimit) || Array.from(remaining)[0] || "";
chunks.push(chunk);
remaining = remaining.slice(chunk.length);
}
return chunks;
}
if (params.textIsSlackMrkdwn) {
return resolveTextChunksWithFallback(text, chunkSlackMrkdwnText(text, chunkLimit));
}
const tableMode = resolveMarkdownTableMode({
cfg: params.cfg,
channel: "slack",
...(params.accountId ? { accountId: params.accountId } : {}),
});
const chunkMode = resolveChunkMode(params.cfg, "slack", params.accountId);
const markdownChunks =
chunkMode === "newline" ? chunkMarkdownTextWithMode(text, chunkLimit, chunkMode) : [text];
const chunks = markdownChunks.flatMap((markdown) =>
markdownToSlackMrkdwnChunks(markdown, chunkLimit, { tableMode }),
);
return resolveTextChunksWithFallback(text, chunks);
}
function createSlackSendQueueKey(params: {
accountId: string;
token: string;
recipient: SlackRecipient;
threadTs?: string;
teamId?: string;
}): string {
const recipientKey = `${params.recipient.kind}:${params.recipient.id}`;
const workspaceScope = params.teamId ? `:${params.teamId}` : "";
return `${params.accountId}:${createSlackTokenCacheKey(params.token)}${workspaceScope}:${recipientKey}:${
params.threadTs ?? ""
}`;
}
async function runQueuedSlackSend<T>(key: string, task: () => Promise<T>): Promise<T> {
return await slackSendQueue.enqueue(key, task);
}
function createSlackDmCacheKey(params: {
accountId?: string;
token: string;
recipientId: string;
}): string {
return `${params.accountId ?? "default"}:${createSlackTokenCacheKey(params.token)}:${
params.recipientId
}`;
}
function getSlackDmChannelCache(client: WebClient): Map<string, string> {
const existing = slackDmChannelCaches.get(client);
if (existing) {
return existing;
}
const cache = new Map<string, string>();
slackDmChannelCaches.set(client, cache);
return cache;
}
function setSlackDmChannelCache(cache: Map<string, string>, key: string, channelId: string): void {
if (cache.has(key)) {
cache.delete(key);
} else if (cache.size >= SLACK_DM_CHANNEL_CACHE_MAX) {
const oldest = cache.keys().next().value;
if (oldest) {
cache.delete(oldest);
}
}
cache.set(key, channelId);
}
function isSlackUserRecipient(recipient: SlackRecipient): boolean {
return recipient.kind === "user";
}
function resolveDirectUserPostChannelId(params: {
recipient: SlackRecipient;
hasMedia: boolean;
threadTs?: string;
}): string | undefined {
if (!isSlackUserRecipient(params.recipient) || params.hasMedia || params.threadTs) {
return undefined;
}
return params.recipient.id;
}
function resolvePostedMessageChannelId(response: { channel?: unknown }, fallback: string): string {
return (
(typeof response.channel === "string" ? normalizeOptionalString(response.channel) : null) ??
fallback
);
}
async function resolveChannelId(
client: WebClient,
recipient: SlackRecipient,
params: { accountId?: string; token: string },
): Promise<{ channelId: string; isDm?: boolean; cacheHit?: boolean }> {
// Bare Slack user IDs are classified as user recipients by target parsing.
// chat.postMessage tolerates user IDs directly, but
// files.uploadV2 → completeUploadExternal validates channel_id against
// ^[CGDZ][A-Z0-9]{8,}$ and rejects user IDs. Resolve them via
// conversations.open only for paths that require the concrete DM channel ID.
if (!isSlackUserRecipient(recipient)) {
return { channelId: recipient.id };
}
const cacheKey = createSlackDmCacheKey({
accountId: params.accountId,
token: params.token,
recipientId: recipient.id,
});
const cache = getSlackDmChannelCache(client);
const cachedChannelId = cache.get(cacheKey);
if (cachedChannelId) {
return { channelId: cachedChannelId, isDm: true, cacheHit: true };
}
const response = await withSlackDnsRequestRetry("conversations.open", () =>
client.conversations.open({ users: recipient.id }),
);
const channelId = response.channel?.id;
if (!channelId) {
throw new Error("Failed to open Slack DM channel");
}
setSlackDmChannelCache(cache, cacheKey, channelId);
return { channelId, isDm: true, cacheHit: false };
}
export async function resolveSlackDmChannelId(params: {
client: WebClient;
userId: string;
accountId?: string;
token: string;
}): Promise<string> {
const resolved = await resolveChannelId(
params.client,
{ kind: "user", id: params.userId },
{ accountId: params.accountId, token: params.token },
);
return resolved.channelId;
}
function createSlackDeliveryMetadataId(queueId?: string): string | undefined {
const normalized = normalizeOptionalString(queueId);
if (!normalized) {
return undefined;
}
// Slack metadata is visible to workspace apps and members. Keep the durable
// store key inside OpenClaw while retaining a stable provider-side marker.
return createHash("sha256").update(normalized).digest("base64url");
}
function createSlackDeliveryMetadataSignature(params: {
queueId: string;
channelId: string;
threadTs?: string;
partIndex: number;
partCount: number;
}): string {
return createHmac("sha256", params.queueId)
.update(
JSON.stringify([
SLACK_DELIVERY_METADATA_EVENT,
params.channelId,
params.threadTs ?? "",
params.partIndex,
params.partCount,
]),
)
.digest("base64url");
}
function withSlackDeliveryMetadata(
metadata: MessageMetadata | undefined,
params: {
queueId?: string;
channelId: string;
threadTs?: string;
partIndex: number;
partCount: number;
},
): MessageMetadata | undefined {
const queueId = normalizeOptionalString(params.queueId);
const deliveryId = createSlackDeliveryMetadataId(queueId);
if (!queueId || !deliveryId) {
return metadata;
}
const marker = {
[SLACK_DELIVERY_METADATA_KEY]: deliveryId,
[SLACK_DELIVERY_METADATA_PART_INDEX_KEY]: params.partIndex,
[SLACK_DELIVERY_METADATA_PART_COUNT_KEY]: params.partCount,
[SLACK_DELIVERY_METADATA_SIGNATURE_KEY]: createSlackDeliveryMetadataSignature({
queueId,
channelId: params.channelId,
threadTs: params.threadTs,
partIndex: params.partIndex,
partCount: params.partCount,
}),
};
if (!metadata) {
return {
event_type: SLACK_DELIVERY_METADATA_EVENT,
event_payload: marker,
};
}
return {
...metadata,
event_payload: {
...metadata.event_payload,
...marker,
},
};
}
function formatSlackTimestampFromMs(ms: number): string {
return (Math.max(0, ms) / 1000).toFixed(6);
}
function asSlackConversationMessages(
response: SlackConversationLookupResponse,
): SlackConversationMessage[] {
return Array.isArray(response.messages)
? response.messages.filter(
(message): message is SlackConversationMessage =>
typeof message === "object" && message !== null && !Array.isArray(message),
)
: [];
}
type SlackDeliveryPart = {
messageId: string;
threadTs?: string;
partIndex: number;
partCount: number;
};
type SlackConversationDeliveryScan = {
reconciliation: ChannelMessageUnknownSendReconciliationResult;
evidence: "none" | "partial" | "conflict" | "complete";
};
const SLACK_RECONCILIATION_EVIDENCE_RANK = {
none: 0,
partial: 1,
conflict: 2,
complete: 3,
} as const satisfies Record<SlackConversationDeliveryScan["evidence"], number>;
function findSlackConversationDeliveryParts(params: {
messages: readonly SlackConversationMessage[];
queueId: string;
deliveryId: string;
channelId: string;
threadTs?: string;
}): SlackDeliveryPart[] {
const matches: SlackDeliveryPart[] = [];
for (const message of params.messages) {
if (
!message.metadata ||
typeof message.metadata !== "object" ||
Array.isArray(message.metadata)
) {
continue;
}
const eventPayload = (message.metadata as { event_payload?: unknown }).event_payload;
if (!eventPayload || typeof eventPayload !== "object" || Array.isArray(eventPayload)) {
continue;
}
const marker = eventPayload as Record<string, unknown>;
if (marker[SLACK_DELIVERY_METADATA_KEY] !== params.deliveryId) {
continue;
}
const partIndex = marker[SLACK_DELIVERY_METADATA_PART_INDEX_KEY];
const partCount = marker[SLACK_DELIVERY_METADATA_PART_COUNT_KEY];
if (
typeof partIndex !== "number" ||
!Number.isInteger(partIndex) ||
typeof partCount !== "number" ||
!Number.isInteger(partCount) ||
partIndex < 0 ||
partCount <= 0 ||
partIndex >= partCount
) {
continue;
}
const expectedSignature = createSlackDeliveryMetadataSignature({
queueId: params.queueId,
channelId: params.channelId,
threadTs: params.threadTs,
partIndex,
partCount,
});
const actualSignature = marker[SLACK_DELIVERY_METADATA_SIGNATURE_KEY];
if (
typeof actualSignature !== "string" ||
!safeEqualSecret(actualSignature, expectedSignature)
) {
continue;
}
const messageId =
typeof message.ts === "string" ? normalizeSlackThreadTsCandidate(message.ts) : undefined;
if (!messageId) {
continue;
}
const threadTs =
typeof message.thread_ts === "string"
? normalizeSlackThreadTsCandidate(message.thread_ts)
: undefined;
matches.push({ messageId, partIndex, partCount, ...(threadTs ? { threadTs } : {}) });
}
return matches;
}
async function scanSlackConversationForDelivery(params: {
client: SlackConversationLookupClient;
channelId: string;
threadTs?: string;
oldest: string;
latest: string;
queueId: string;
deliveryId: string;
retryCount: number;
}): Promise<SlackConversationDeliveryScan> {
const threadTs = params.threadTs;
let cursor: string | undefined;
let expectedPartCount: number | undefined;
const deliveryParts = new Map<number, SlackDeliveryPart>();
for (let page = 0; page < SLACK_RECONCILE_MAX_PAGES; page += 1) {
const response = (
threadTs
? await withSlackDnsRequestRetry("conversations.replies", () =>
params.client.conversations.replies({
channel: params.channelId,
ts: threadTs,
oldest: params.oldest,
latest: params.latest,
include_all_metadata: true,
limit: SLACK_RECONCILE_LIMIT,
...(cursor ? { cursor } : {}),
}),
)
: await withSlackDnsRequestRetry("conversations.history", () =>
params.client.conversations.history({
channel: params.channelId,
oldest: params.oldest,
latest: params.latest,
include_all_metadata: true,
limit: SLACK_RECONCILE_LIMIT,
...(cursor ? { cursor } : {}),
}),
)
) as SlackConversationLookupResponse;
const matches = findSlackConversationDeliveryParts({
messages: asSlackConversationMessages(response),
queueId: params.queueId,
deliveryId: params.deliveryId,
channelId: params.channelId,
...(threadTs ? { threadTs } : {}),
});
for (const match of matches) {
expectedPartCount ??= match.partCount;
const existing = deliveryParts.get(match.partIndex);
if (
expectedPartCount !== match.partCount ||
(existing && existing.messageId !== match.messageId)
) {
return {
reconciliation: {
status: "unresolved",
error: "Slack history contains conflicting durable delivery markers",
retryable: false,
},
evidence: "conflict",
};
}
deliveryParts.set(match.partIndex, match);
}
if (expectedPartCount !== undefined && deliveryParts.size === expectedPartCount) {
const orderedParts = Array.from({ length: expectedPartCount }, (_, index) =>
deliveryParts.get(index),
);
if (orderedParts.some((part) => !part)) {
return {
reconciliation: {
status: "unresolved",
error: "Slack history contains an invalid durable delivery marker set",
retryable: false,
},
evidence: "conflict",
};
}
const completeParts = orderedParts as SlackDeliveryPart[];
const reconciledThreadTs = completeParts[0]?.threadTs ?? params.threadTs;
const platformMessageIds = completeParts.map((part) => part.messageId);
return {
reconciliation: {
status: "sent",
messageId: platformMessageIds[0],
receipt: createSlackSendReceipt({
platformMessageIds,
channelId: params.channelId,
kind: "text",
...(reconciledThreadTs ? { threadTs: reconciledThreadTs } : {}),
}),
},
evidence: "complete",
};
}
const nextCursor = normalizeOptionalString(response.response_metadata?.next_cursor);
if (!nextCursor) {
if (response.has_more === true) {
break;
}
// Marker absence cannot prove that Slack never committed the request: a
// delayed, deleted, or visibility-filtered message would make replay duplicate it.
return {
reconciliation: {
status: "unresolved",
error:
deliveryParts.size > 0
? "Slack history contains an incomplete durable delivery marker set"
: "Slack history contains no exact durable delivery marker",
retryable: params.retryCount < 2,
},
evidence: deliveryParts.size > 0 ? "partial" : "none",
};
}
cursor = nextCursor;
}
return {
reconciliation: {
status: "unresolved",
error: "Slack unknown-send reconciliation exceeded its history page budget",
retryable: params.retryCount < 2,
},
evidence: deliveryParts.size > 0 ? "partial" : "none",
};
}
export async function reconcileSlackUnknownSend(
ctx: ChannelMessageUnknownSendContext,
opts?: { client?: SlackConversationLookupClient },
): Promise<ChannelMessageUnknownSendReconciliationResult> {
const cfg = requireRuntimeConfig(ctx.cfg, "Slack delivery reconciliation");
const account = resolveSlackAccount({
cfg,
accountId: ctx.accountId ?? undefined,
});
const deliveryId = createSlackDeliveryMetadataId(ctx.queueId);
if (!deliveryId) {
return {
status: "unresolved",
error: "Slack unknown-send reconciliation requires a durable delivery id",
retryable: false,
};
}
const readToken = resolveSlackOperationToken(account, "read");
if (!readToken) {
return {
status: "unresolved",
error: `Slack read token missing for account "${account.accountId}"`,
retryable: false,
};
}
const recipient = parseRecipient(ctx.to);
const userRecipient = isSlackUserRecipient(recipient);
const writeToken = resolveSlackOperationToken(account, "write");
if (userRecipient && !writeToken) {
return {
status: "unresolved",
error: `Slack write token missing for direct-message reconciliation on account "${account.accountId}"`,
retryable: false,
};
}
const readClient = opts?.client ?? createSlackWebClient(readToken);
const writeClient = opts?.client ?? (writeToken ? getSlackWriteClient(writeToken) : undefined);
const payloadReplyToId = ctx.payloads[0]?.replyToId;
const effectiveReplyToId = Object.hasOwn(ctx, "effectiveReplyToId")
? normalizeOptionalString(ctx.effectiveReplyToId)
: payloadReplyToId != null
? normalizeOptionalString(payloadReplyToId)
: ctx.replyToMode === "off"
? undefined
: normalizeOptionalString(ctx.replyToId);
const threadTs = resolveSlackThreadTsValue({
replyToId: effectiveReplyToId,
threadId: ctx.threadId,
});
const searchStartedAt = ctx.platformSendStartedAt ?? ctx.enqueuedAt;
const oldest = formatSlackTimestampFromMs(
searchStartedAt - SLACK_RECONCILE_LOOKBACK_MS - SLACK_RECONCILE_CLOCK_SKEW_MS,
);
// Slack message timestamps use provider time. Bound the scan near the local
// attempt while leaving a generous skew budget between the two clocks.
const latest = formatSlackTimestampFromMs(searchStartedAt + SLACK_RECONCILE_CLOCK_SKEW_MS);
try {
const channelClient = userRecipient ? writeClient : readClient;
const channelToken = userRecipient ? writeToken : readToken;
if (!channelClient || !channelToken) {
throw new Error(`Slack channel resolution token missing for account "${account.accountId}"`);
}
const { channelId } = await resolveChannelId(channelClient as WebClient, recipient, {
accountId: account.accountId,
token: channelToken,
});
const lookupClients = opts?.client
? [opts.client]
: [readClient, ...(writeClient && writeToken !== readToken ? [writeClient] : [])];
let lookupError: unknown;
let bestUnresolvedScan: SlackConversationDeliveryScan | undefined;
for (const lookupClient of lookupClients) {
try {
const scan = await scanSlackConversationForDelivery({
client: lookupClient,
channelId,
...(threadTs ? { threadTs } : {}),
oldest,
latest,
queueId: ctx.queueId,
deliveryId,
retryCount: ctx.retryCount,
});
if (scan.reconciliation.status === "sent") {
return scan.reconciliation;
}
if (
!bestUnresolvedScan ||
SLACK_RECONCILIATION_EVIDENCE_RANK[scan.evidence] >
SLACK_RECONCILIATION_EVIDENCE_RANK[bestUnresolvedScan.evidence]
) {
bestUnresolvedScan = scan;
}
} catch (err) {
lookupError = err;
}
}
if (bestUnresolvedScan) {
return bestUnresolvedScan.reconciliation;
}
throw lookupError;
} catch (err) {
const enriched = enrichSlackWebApiError(err);
return {
status: "unresolved",
error: readSlackRequestErrorMessage(enriched),
retryable: ctx.retryCount < 3,
};
}
}
export async function sendMessageSlack(
to: string,
message: string,
opts: SlackSendOpts,
): Promise<SlackSendResult> {
const normalizedMessage = normalizeOptionalString(message) ?? "";
const trimmedMessage =
opts.textIsSlackPlainText && normalizedMessage ? message : normalizedMessage;
const cfg = requireRuntimeConfig(opts.cfg, "Slack send");
const account = resolveSlackAccount({
cfg,
accountId: opts.accountId,
});
const enterpriseEventScope = resolveEnterpriseEventScope({ account, opts });
const enterpriseDelivery = enterpriseEventScope
? Object.freeze({
client: enterpriseEventScope.client,
teamId: enterpriseEventScope.teamId,
...(enterpriseEventScope.uploadCompletionClient
? { uploadCompletionClient: enterpriseEventScope.uploadCompletionClient }
: {}),
})
: undefined;
if (isSilentReplyText(normalizedMessage) && !opts.mediaUrl && !opts.blocks) {
logVerbose("slack send: suppressed NO_REPLY token before API call");
return {
messageId: "suppressed",
channelId: "",
receipt: createSlackSendReceipt({ platformMessageIds: [], kind: "unknown" }),
};
}
const blocks = opts.blocks == null ? undefined : validateSlackBlocksArray(opts.blocks);
if (!normalizedMessage && !opts.mediaUrl && !blocks) {
throw new Error("Slack send requires text, blocks, or media");
}
const token = enterpriseDelivery
? SLACK_ENTERPRISE_LISTENER_QUEUE_CREDENTIAL
: resolveToken({
explicit: opts.token,
accountId: account.accountId,
fallbackToken: resolveSlackOperationToken(account, "write"),
fallbackSource:
account.identity === "user" ? account.userTokenSource : account.botTokenSource,
});
const recipient = enterpriseDelivery ? parseEnterpriseEventRecipient(to) : parseRecipient(to);
const queueKey = createSlackSendQueueKey({
accountId: account.accountId,
token,
recipient,
threadTs: opts.threadTs,
...(enterpriseDelivery ? { teamId: enterpriseDelivery.teamId } : {}),
});
const queuedOpts = enterpriseDelivery
? Object.freeze({ ...opts, client: enterpriseDelivery.client })
: opts;
const result = await runQueuedSlackSend(queueKey, () =>
sendMessageSlackQueued({
trimmedMessage,
opts: queuedOpts,
cfg,
account,
token,
recipient,
blocks,
...(enterpriseDelivery ? { enterpriseDelivery } : {}),
}),
);
const threadTs = result.threadTs ?? normalizeSlackThreadTsCandidate(queuedOpts.threadTs);
if (threadTs && result.channelId && account.accountId) {
if (enterpriseDelivery) {
recordSlackThreadParticipation(account.accountId, result.channelId, threadTs, {
teamId: enterpriseDelivery.teamId,
});
} else {
recordSlackThreadParticipation(account.accountId, result.channelId, threadTs);
}
}
return result;
}
async function sendMessageSlackQueued(params: {
trimmedMessage: string;
opts: SlackSendOpts;
cfg: OpenClawConfig;
account: ReturnType<typeof resolveSlackAccount>;
token: string;
recipient: SlackRecipient;
blocks?: (Block | KnownBlock)[];
enterpriseDelivery?: SlackEnterpriseDelivery;
}): Promise<SlackSendResult> {
try {
return await sendMessageSlackQueuedInner(params);
} catch (err) {
throw enrichSlackWebApiError(err);
}
}
async function sendMessageSlackQueuedInner(params: {
trimmedMessage: string;
opts: SlackSendOpts;
cfg: OpenClawConfig;
account: ReturnType<typeof resolveSlackAccount>;
token: string;
recipient: SlackRecipient;
blocks?: (Block | KnownBlock)[];
enterpriseDelivery?: SlackEnterpriseDelivery;
}): Promise<SlackSendResult> {
const { opts, cfg, account, token, recipient, blocks, trimmedMessage, enterpriseDelivery } =
params;
const client = enterpriseDelivery?.client ?? opts.client ?? getSlackWriteClient(token);
const identity = enterpriseDelivery
? normalizeSlackSendIdentity(opts.identity)
: resolveSlackSendIdentity({
accountId: account.accountId,
explicit: opts.identity,
});
if (opts.replyBroadcast && opts.mediaUrl) {
throw new Error("Slack replyBroadcast is only supported for text or block thread replies.");
}
const unfurl = enterpriseDelivery
? { unfurlMedia: account.config.unfurlMedia }
: {
unfurlLinks: account.config.unfurlLinks,
unfurlMedia: account.config.unfurlMedia,
};
// Durable signatures bind the concrete provider channel, so user-targeted
// sends must resolve U... to the resulting D... conversation first.
const directUserPostChannelId = opts.deliveryQueueId
? undefined
: resolveDirectUserPostChannelId({
recipient,
hasMedia: Boolean(opts.mediaUrl),
...(opts.threadTs ? { threadTs: opts.threadTs } : {}),
});
const { channelId } = directUserPostChannelId
? { channelId: directUserPostChannelId }
: await resolveChannelId(client, recipient, {
accountId: account.accountId,
token,
});
const reportDelivery = async (result: SlackSendResult) => {
await opts.onDeliveryResult?.(result);
return result;
};
let didDispatch = false;
const dispatchOnce = async () => {
if (didDispatch) {
return;
}
didDispatch = true;
await opts.onPlatformSendDispatch?.();
};
const explicitNativeDataFallbackBase = Object.hasOwn(opts, "nativeDataFallbackBaseText")
? (opts.nativeDataFallbackBaseText?.trim() ?? "")
: undefined;
const authoredTextPlacement = opts.authoredTextPlacement ?? "outside-blocks";
const nativeDataFallbackBase =
blocks && authoredTextPlacement === "outside-blocks"
? (explicitNativeDataFallbackBase ?? trimmedMessage)
: "";
const hasNativeData = Boolean(blocks && hasSlackNativeDataBlock(blocks));
const usesOrderedBlockAccessibility = Boolean(
blocks && (hasNativeData || opts.authoredTextPlacement !== undefined),
);
const orderedBlockDeliveryPlan =
blocks && usesOrderedBlockAccessibility
? buildSlackNativeDataDeliveryPlan({
baseText: nativeDataFallbackBase,
blocks,
})
: undefined;
const orderedBlockAccessibilityText =
blocks && usesOrderedBlockAccessibility
? (orderedBlockDeliveryPlan?.accessibilityText ??
(buildSlackNativeDataAccessibilityText(nativeDataFallbackBase, blocks) ||
"Slack could not render this Block Kit message."))
: undefined;
const completeBlockFallbackText = blocks ? buildSlackCompleteBlocksFallbackText(blocks) : "";
const rawBlockAccessibilityText = trimmedMessage
? [
trimmedMessage,
...(completeBlockFallbackText.includes(trimmedMessage) ? [] : [completeBlockFallbackText]),
].join("\n\n")
: completeBlockFallbackText;
const blockAccessibilityText = blocks
? (orderedBlockAccessibilityText ?? rawBlockAccessibilityText)
: undefined;
let pendingBlockFallback =
(hasNativeData || opts.authoredTextPlacement !== undefined) &&
orderedBlockDeliveryPlan?.skipOriginalBlocks
? orderedBlockDeliveryPlan
: undefined;
const sentMessageIds: string[] = [];
let lastMessageId = "";
let deliveredChannelId = channelId;
let canonicalDeliveredThreadTs: string | undefined;
let sendIdentity = identity;
if (blocks && opts.mediaUrl) {
throw new Error("Slack send does not support blocks with mediaUrl");
}
if (blocks) {
if (pendingBlockFallback) {
// A truncated top-level fallback makes block content inaccessible to screen readers.
// Deliver controls plus complete text instead of attempting the native blocks.
logVerbose("slack send: native data accessibility exceeds hard limit, using text fallback");
} else {
const accessibilityText = hasNativeData
? (blockAccessibilityText ?? "Slack could not render this Block Kit message.")
: usesOrderedBlockAccessibility
? (blockAccessibilityText ?? "Slack could not render this Block Kit message.")
: truncateSlackText(blockAccessibilityText ?? "", SLACK_TEXT_LIMIT);
const initialBlockMetadata = withSlackDeliveryMetadata(opts.metadata, {
queueId: opts.deliveryQueueId,
channelId,
threadTs: opts.threadTs,
partIndex: 0,
partCount: 1,
});
await dispatchOnce();
try {
const { response } = await postSlackMessageBestEffort({
client,
channelId,
text: accessibilityText,
threadTs: opts.threadTs,
replyBroadcast: opts.replyBroadcast,
identity,
blocks,
metadata: initialBlockMetadata,
...(usesOrderedBlockAccessibility ? { mrkdwn: false } : {}),
unfurl,
});
if (enterpriseDelivery && (!response.ok || !response.ts)) {
throw new Error(
response.ok
? "Slack chat.postMessage returned no message timestamp"
: `Slack chat.postMessage failed: ${response.error ?? "unknown error"}`,
);
}
const messageId = response.ts ?? "unknown";
deliveredChannelId = resolvePostedMessageChannelId(response, channelId);
const deliveredThreadTs =
resolvePostedMessageThreadTs(response) ?? normalizeSlackThreadTsCandidate(opts.threadTs);
return await reportDelivery({
messageId,
channelId: deliveredChannelId,
threadTs: deliveredThreadTs,
receipt: createSlackSendReceipt({
platformMessageIds: [messageId],
channelId: deliveredChannelId,
kind: "card",
threadTs: deliveredThreadTs,
}),
});
} catch (error) {
if (!hasNativeData || !isSlackInvalidBlocksError(error)) {
throw error;
}
logVerbose("slack send: native data rejected, delivering complete text fallback");
pendingBlockFallback = orderedBlockDeliveryPlan;
}
}
if (pendingBlockFallback) {
const fallbackMessages = pendingBlockFallback.fallbackMessages;
for (const [partIndex, fallback] of fallbackMessages.entries()) {
const metadata = withSlackDeliveryMetadata(partIndex === 0 ? opts.metadata : undefined, {
queueId: opts.deliveryQueueId,
channelId,
threadTs: opts.threadTs,
partIndex,
partCount: fallbackMessages.length,
});
if (partIndex === 0) {
await dispatchOnce();
}
const posted = await postSlackMessageBestEffort({
client,
channelId,
text: fallback.text,
threadTs: opts.threadTs,
replyBroadcast: partIndex === 0 ? opts.replyBroadcast : undefined,
identity: sendIdentity,
...(fallback.blocks ? { blocks: fallback.blocks } : {}),
metadata,
mrkdwn: false,
unfurl,
});
const response = posted.response;
if (enterpriseDelivery && (!response.ok || !response.ts)) {
throw new Error(
response.ok
? "Slack chat.postMessage returned no message timestamp"
: `Slack chat.postMessage failed: ${response.error ?? "unknown error"}`,
);
}
sendIdentity = posted.identity;
lastMessageId = response.ts ?? lastMessageId;
deliveredChannelId = resolvePostedMessageChannelId(response, deliveredChannelId);
canonicalDeliveredThreadTs ??= resolvePostedMessageThreadTs(response);
if (!response.ts) {
continue;
}
sentMessageIds.push(response.ts);
const deliveredThreadTs =
resolvePostedMessageThreadTs(response) ?? normalizeSlackThreadTsCandidate(opts.threadTs);
await reportDelivery({
messageId: response.ts,
channelId: deliveredChannelId,
threadTs: deliveredThreadTs,
receipt: createSlackSendReceipt({
platformMessageIds: [response.ts],
channelId: deliveredChannelId,
kind: fallback.blocks ? "card" : "text",
threadTs: deliveredThreadTs,
}),
});
}
const messageId = lastMessageId || "unknown";
const deliveredThreadTs =
canonicalDeliveredThreadTs ?? normalizeSlackThreadTsCandidate(opts.threadTs);
return {
messageId,
channelId: deliveredChannelId,
threadTs: deliveredThreadTs,
receipt: createSlackSendReceipt({
platformMessageIds: sentMessageIds.length ? sentMessageIds : [messageId],
channelId: deliveredChannelId,
kind: fallbackMessages.some((message) => message.blocks) ? "card" : "text",
threadTs: deliveredThreadTs,
}),
};
}
}
const resolvedChunks = resolveSlackTextChunks({
cfg,
accountId: account.accountId,
text: trimmedMessage,
...(opts.textLimit !== undefined ? { textLimit: opts.textLimit } : {}),
...(opts.textIsSlackMrkdwn ? { textIsSlackMrkdwn: true } : {}),
...(opts.textIsSlackPlainText ? { preservePlainText: true } : {}),
});
const mediaMaxBytes =
opts.mediaMaxBytes ??
(typeof account.config.mediaMaxMb === "number"
? account.config.mediaMaxMb * 1024 * 1024
: undefined);
let chunksToPost: string[];
if (opts.mediaUrl) {
if (enterpriseDelivery && !enterpriseDelivery.uploadCompletionClient) {
throw new Error("missing_enterprise_slack_upload_completion_client");
}
const [firstChunk, ...rest] = resolvedChunks;
lastMessageId = await uploadSlackFile({
client,
...(enterpriseDelivery?.uploadCompletionClient
? { completionClient: enterpriseDelivery.uploadCompletionClient }
: {}),
channelId,
mediaUrl: opts.mediaUrl,
mediaAccess: opts.mediaAccess,
uploadFileName: opts.uploadFileName,
uploadTitle: opts.uploadTitle,
mediaLocalRoots: opts.mediaLocalRoots,
mediaReadFile: opts.mediaReadFile,
caption: firstChunk,
threadTs: opts.threadTs,
maxBytes: mediaMaxBytes,
onPlatformSendDispatch: dispatchOnce,
...(enterpriseDelivery ? { auditContext: "slack-enterprise-immediate-upload" } : {}),
});
sentMessageIds.push(lastMessageId);
await reportDelivery({
messageId: lastMessageId,
channelId,
threadTs: normalizeSlackThreadTsCandidate(opts.threadTs),
receipt: createSlackSendReceipt({
platformMessageIds: [lastMessageId],
channelId,
kind: "media",
threadTs: normalizeSlackThreadTsCandidate(opts.threadTs),
}),
});
chunksToPost = rest;
} else {
chunksToPost = resolvedChunks.length ? resolvedChunks : [""];
}
for (const [partIndex, chunk] of chunksToPost.entries()) {
const carriesPrimaryMessageOptions =
partIndex === 0 && !opts.mediaUrl && sentMessageIds.length === 0;
const baseMetadata = carriesPrimaryMessageOptions ? opts.metadata : undefined;
// Every post carries its index/count so reconciliation proves the complete
// logical text send and never mistakes a partial chunk fanout for success.
const metadata = opts.mediaUrl
? baseMetadata
: withSlackDeliveryMetadata(baseMetadata, {
queueId: opts.deliveryQueueId,
channelId,
threadTs: opts.threadTs,
partIndex,
partCount: chunksToPost.length,
});
if (partIndex === 0 && !opts.mediaUrl) {
await dispatchOnce();
}
const posted = await postSlackMessageBestEffort({
client,
channelId,
text: chunk,
threadTs: opts.threadTs,
replyBroadcast: carriesPrimaryMessageOptions ? opts.replyBroadcast : undefined,
identity: sendIdentity,
metadata,
...(opts.textIsSlackPlainText ? { mrkdwn: false } : {}),
unfurl,
});
const response = posted.response;
if (enterpriseDelivery && (!response.ok || !response.ts)) {
throw new Error(
response.ok
? "Slack chat.postMessage returned no message timestamp"
: `Slack chat.postMessage failed: ${response.error ?? "unknown error"}`,
);
}
sendIdentity = posted.identity;
lastMessageId = response.ts ?? lastMessageId;
deliveredChannelId = resolvePostedMessageChannelId(response, deliveredChannelId);
canonicalDeliveredThreadTs ??= resolvePostedMessageThreadTs(response);
if (response.ts) {
sentMessageIds.push(response.ts);
await reportDelivery({
messageId: response.ts,
channelId: deliveredChannelId,
threadTs:
resolvePostedMessageThreadTs(response) ?? normalizeSlackThreadTsCandidate(opts.threadTs),
receipt: createSlackSendReceipt({
platformMessageIds: [response.ts],
channelId: deliveredChannelId,
kind: "text",
threadTs:
resolvePostedMessageThreadTs(response) ??
normalizeSlackThreadTsCandidate(opts.threadTs),
}),
});
}
}
const messageId = lastMessageId || "unknown";
const deliveredThreadTs =
canonicalDeliveredThreadTs ?? normalizeSlackThreadTsCandidate(opts.threadTs);
return {
messageId,
channelId: deliveredChannelId,
threadTs: deliveredThreadTs,
receipt: createSlackSendReceipt({
platformMessageIds: sentMessageIds.length ? sentMessageIds : [messageId],
channelId: deliveredChannelId,
kind: opts.mediaUrl ? "media" : "text",
threadTs: deliveredThreadTs,
}),
};
}
/* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */