agents
Version:
A home for your AI agents
614 lines (613 loc) • 23.6 kB
JavaScript
import { t as isChannelMessageSurface } from "../surface-bZZJqBka.js";
import { a as isRecord, i as encodeUtf8, n as defaultText, o as renderInput, r as emptyIngressResponse, s as uncertain } from "../internal-CYlgHl1l.js";
import { i as createPacer, n as collectText, r as consumeChunks, t as matchesPath } from "../ingress-BfetZbMO.js";
//#region src/channels/adapters/slack.ts
const DEFAULT_SLACK_WEBHOOK_PATH = "/webhooks/slack";
const DEFAULT_MAX_SKEW_SECONDS = 300;
const APPROVE_ACTION_ID = "cloudflare_channels_approve_v1";
const REJECT_ACTION_ID = "cloudflare_channels_reject_v1";
const DEFAULT_STREAM_INTERVAL_MS = 500;
const SLACK_APPEND_LIMIT = 12e3;
const SLACK_TASK_FIELD_LIMIT = 256;
const SLACK_CONTEXT_LIMIT = 3e3;
const AMBIGUOUS_SLACK_ERRORS = /* @__PURE__ */ new Set([
"fatal_error",
"internal_error",
"request_timeout",
"service_unavailable"
]);
function approvalText(request) {
return [
...request.title ? [request.title] : [],
request.summary,
`Input:\n\`\`\`\n${renderInput(request.input)}\n\`\`\``
].join("\n\n");
}
function channelIdentity(teamId, channelId) {
return `slack:${teamId}:channel:${channelId}`;
}
function messageIdentity(teamId, channelId, timestamp) {
return `${channelIdentity(teamId, channelId)}:message:${timestamp}`;
}
function outboundReference(channelId, timestamp) {
return `slack:channel:${channelId}:message:${timestamp}`;
}
function replySurfaceLabel(channelId, threadTimestamp) {
return threadTimestamp ? `Slack · ${channelId} · thread ${threadTimestamp}` : `Slack · ${channelId}`;
}
function threadIdentity(teamId, channelId, timestamp, threadTimestamp, isDirectMessage) {
const channel = channelIdentity(teamId, channelId);
if (isDirectMessage && threadTimestamp === void 0) return channel;
return `${channel}:thread:${threadTimestamp ?? timestamp}`;
}
function slackTimestamp(timestamp) {
const milliseconds = Number(timestamp) * 1e3;
if (!Number.isFinite(milliseconds)) return void 0;
try {
return new Date(milliseconds).toISOString();
} catch {
return;
}
}
function selfUserIds(payload, configuredBotUserId) {
const ids = /* @__PURE__ */ new Set();
if (configuredBotUserId) ids.add(configuredBotUserId);
if (Array.isArray(payload.authorizations)) {
for (const authorization of payload.authorizations) if (authorization?.is_bot === true && typeof authorization.user_id === "string") ids.add(authorization.user_id);
}
return ids;
}
function normalizedEvent(payload, configuredBotUserId) {
const event = payload.event;
if (!event || !isRecord(event)) return void 0;
if (event.type !== "app_mention" && event.type !== "message") return;
const isMention = event.type === "app_mention";
const isDirectMessage = event.type === "message" && event.channel_type === "im";
if (event.subtype !== void 0 || typeof event.bot_id === "string" || typeof event.app_id === "string") return;
const teamId = payload.team_id;
const eventId = payload.event_id;
const channelId = event.channel;
const actorId = event.user;
const text = event.text;
const timestamp = event.ts;
if (typeof teamId !== "string" || typeof eventId !== "string" || typeof channelId !== "string" || typeof actorId !== "string" || typeof text !== "string" || typeof timestamp !== "string") return;
if (selfUserIds(payload, configuredBotUserId).has(actorId)) return;
const threadTimestamp = typeof event.thread_ts === "string" ? event.thread_ts : void 0;
const sentAt = slackTimestamp(timestamp);
return {
type: "message",
eventId: `slack:${teamId}:event:${eventId}`,
thread: {
id: threadIdentity(teamId, channelId, timestamp, threadTimestamp, isDirectMessage),
isDirectMessage
},
replySurface: {
version: 1,
address: {
teamId,
channelId,
...(threadTimestamp || !isDirectMessage) && { threadTs: threadTimestamp ?? timestamp },
recipientUserId: actorId,
recipientTeamId: teamId
},
label: replySurfaceLabel(channelId, threadTimestamp ?? (isDirectMessage ? void 0 : timestamp))
},
actor: {
id: `slack:${teamId}:user:${actorId}`,
identity: {
scope: teamId,
subject: actorId
},
isBot: false,
isSelf: false
},
message: {
id: messageIdentity(teamId, channelId, timestamp),
text,
markdown: text,
...isMention && { isMention: true },
...threadTimestamp && threadTimestamp !== timestamp && { reply: { id: messageIdentity(teamId, channelId, threadTimestamp) } },
...sentAt && { metadata: { sentAt } }
}
};
}
function parseApprovalValue(value) {
let parsed;
try {
parsed = JSON.parse(value);
} catch {
return;
}
if (!isRecord(parsed) || parsed.v !== 1 || typeof parsed.interactionId !== "string" || !parsed.interactionId || parsed.decision !== "approve" && parsed.decision !== "reject") return;
return parsed;
}
async function sha256Hex(value) {
const digest = await crypto.subtle.digest("SHA-256", encodeUtf8(value));
return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("");
}
async function normalizedInteractions(payload, rawBody) {
const teamId = payload.team?.id;
const actorId = payload.user?.id;
const channelId = typeof payload.channel?.id === "string" ? payload.channel.id : payload.container?.channel_id;
const timestamp = typeof payload.message?.ts === "string" ? payload.message.ts : payload.container?.message_ts;
if (typeof teamId !== "string" || typeof actorId !== "string" || typeof channelId !== "string" || typeof timestamp !== "string" || !Array.isArray(payload.actions)) return [];
const threadTimestamp = typeof payload.message?.thread_ts === "string" ? payload.message.thread_ts : typeof payload.container?.thread_ts === "string" ? payload.container.thread_ts : void 0;
const isDirectMessage = channelId.startsWith("D");
const bodyHash = await sha256Hex(rawBody);
const events = [];
for (const [index, action] of payload.actions.entries()) {
if (!action || typeof action.value !== "string") continue;
const expectedDecision = action.action_id === APPROVE_ACTION_ID ? "approve" : action.action_id === REJECT_ACTION_ID ? "reject" : void 0;
if (!expectedDecision) continue;
const approval = parseApprovalValue(action.value);
if (!approval || approval.decision !== expectedDecision) continue;
const stableEventId = `slack:${teamId}:interaction:sha256:${bodyHash}:action:${index}`;
const actionTimestamp = typeof action.action_ts === "string" ? action.action_ts : void 0;
events.push({
type: "approval-response",
eventId: stableEventId,
thread: {
id: threadIdentity(teamId, channelId, timestamp, threadTimestamp, isDirectMessage),
isDirectMessage
},
replySurface: {
version: 1,
address: {
teamId,
channelId,
threadTs: threadTimestamp ?? timestamp,
...!isDirectMessage && {
recipientUserId: actorId,
recipientTeamId: teamId
}
},
label: replySurfaceLabel(channelId, threadTimestamp ?? timestamp)
},
actor: {
id: `slack:${teamId}:user:${actorId}`,
identity: {
scope: teamId,
subject: actorId
},
...typeof payload.user?.username === "string" && { username: payload.user.username },
isBot: false
},
interactionId: approval.interactionId,
decision: approval.decision,
reference: actionTimestamp ? `${channelIdentity(teamId, channelId)}:action:${actionTimestamp}` : stableEventId
});
}
return events;
}
function bytesFromHex(hex) {
if (!/^[a-f0-9]{64}$/.test(hex)) return void 0;
const bytes = new Uint8Array(hex.length / 2);
for (let index = 0; index < bytes.length; index += 1) bytes[index] = Number.parseInt(hex.slice(index * 2, index * 2 + 2), 16);
return bytes;
}
async function hasValidSignature(request, rawBody, signingSecret, maxSkewSeconds) {
const timestamp = request.headers.get("x-slack-request-timestamp");
const signature = request.headers.get("x-slack-signature");
if (!timestamp || !signature || !/^\d+$/.test(timestamp)) return false;
const timestampSeconds = Number(timestamp);
if (!Number.isSafeInteger(timestampSeconds) || Math.abs(Math.floor(Date.now() / 1e3) - timestampSeconds) > maxSkewSeconds) return false;
const match = signature.match(/^v0=([a-f0-9]{64})$/);
const signatureBytes = match ? bytesFromHex(match[1]) : void 0;
if (!signatureBytes) return false;
const key = await crypto.subtle.importKey("raw", encodeUtf8(signingSecret), {
name: "HMAC",
hash: "SHA-256"
}, false, ["verify"]);
return crypto.subtle.verify("HMAC", key, signatureBytes, encodeUtf8(`v0:${timestamp}:${rawBody}`));
}
function parseJsonPayload(rawBody) {
let parsed;
try {
parsed = JSON.parse(rawBody);
} catch {
return;
}
if (!isRecord(parsed)) return void 0;
if (parsed.type !== "url_verification" && parsed.type !== "event_callback") return;
return parsed;
}
function parseInteractionPayload(rawBody) {
const encodedPayload = new URLSearchParams(rawBody).get("payload");
if (!encodedPayload) return void 0;
let parsed;
try {
parsed = JSON.parse(encodedPayload);
} catch {
return;
}
return isRecord(parsed) && parsed.type === "block_actions" ? parsed : void 0;
}
/** Create dependency-free, request-signed Slack HTTP ingress. */
function slackWebhook(options) {
if (!options.signingSecret.trim()) throw new Error("signingSecret is required to create Slack webhook ingress");
const maxSkewSeconds = options.maxSkewSeconds ?? DEFAULT_MAX_SKEW_SECONDS;
if (!Number.isInteger(maxSkewSeconds) || maxSkewSeconds < 0) throw new Error("maxSkewSeconds must be a non-negative integer");
const path = options.path ?? DEFAULT_SLACK_WEBHOOK_PATH;
return { async receive(request) {
if (!matchesPath(request, path)) return null;
if (request.method !== "POST") return emptyIngressResponse(405);
const rawBody = await request.text();
if (!await hasValidSignature(request, rawBody, options.signingSecret, maxSkewSeconds)) return emptyIngressResponse(401);
if ((request.headers.get("content-type")?.split(";", 1)[0]?.trim() ?? "") === "application/x-www-form-urlencoded") {
const payload = parseInteractionPayload(rawBody);
if (!payload) return emptyIngressResponse(400);
return {
events: (await normalizedInteractions(payload, rawBody)).map((event) => ({
event,
raw: payload
})),
response: new Response(null, { status: 200 })
};
}
const payload = parseJsonPayload(rawBody);
if (!payload) return emptyIngressResponse(400);
if (payload.type === "url_verification") return typeof payload.challenge === "string" ? {
events: [],
response: Response.json({ challenge: payload.challenge })
} : emptyIngressResponse(400);
const event = normalizedEvent(payload, options.botUserId);
return {
events: event ? [{
event,
raw: payload
}] : [],
response: new Response(null, { status: 200 })
};
} };
}
function failed(code, message, retryable) {
return {
status: "failed",
retryable,
error: {
code,
message
}
};
}
function slackErrorCode(error) {
return `SLACK_API_ERROR_${error.toUpperCase().replace(/[^A-Z0-9]+/g, "_")}`;
}
function classifyApiFailure(response, payload) {
if (payload.ok === false) {
const error = typeof payload.error === "string" ? payload.error : "unknown_error";
const code = slackErrorCode(error);
const message = `Slack rejected the message: ${error}`;
if (response.status >= 500 || AMBIGUOUS_SLACK_ERRORS.has(error)) return uncertain(code, message);
return failed(code, message, response.status === 429 || error === "ratelimited");
}
if (response.status === 429) return failed("SLACK_HTTP_ERROR_429", "Slack rate limited the message", true);
if (response.status >= 400 && response.status < 500) return failed(`SLACK_HTTP_ERROR_${response.status}`, `Slack rejected the message with HTTP ${response.status}`, false);
return uncertain("SLACK_DELIVERY_ERROR", "Slack returned an invalid delivery response");
}
function asApiResponse(value) {
return isRecord(value) ? value : void 0;
}
function approvalValue(interactionId, decision) {
return JSON.stringify({
v: 1,
interactionId,
decision
});
}
function optionalId(value) {
return typeof value === "string" && value.length > 0 ? value : void 0;
}
function slackTarget(surface) {
if (!isChannelMessageSurface(surface) || surface.version !== 1 || !isRecord(surface.address)) return;
const address = surface.address;
if ("userId" in address) return typeof address.teamId === "string" && address.teamId.length > 0 && typeof address.userId === "string" && address.userId.length > 0 ? {
teamId: address.teamId,
userId: address.userId
} : void 0;
if (typeof address.channelId !== "string" || address.channelId.length === 0) return;
if ("threadTs" in address && address.threadTs !== void 0 && (typeof address.threadTs !== "string" || address.threadTs.length === 0)) return;
const recipientUserId = optionalId(address.recipientUserId);
const recipientTeamId = optionalId(address.recipientTeamId);
return {
channelId: address.channelId,
...typeof address.threadTs === "string" && { threadTs: address.threadTs },
...recipientUserId && recipientTeamId && {
recipientUserId,
recipientTeamId
}
};
}
const SLACK_TASK_STATUS = {
started: "in_progress",
completed: "complete",
failed: "error"
};
function splitText(text, limit) {
const pieces = [];
let piece = "";
let length = 0;
for (const character of text) {
if (length === limit) {
pieces.push(piece);
piece = "";
length = 0;
}
piece += character;
length += 1;
}
if (piece.length > 0) pieces.push(piece);
return pieces;
}
function clamp(value, limit) {
const characters = [...value];
return characters.length <= limit ? value : `${characters.slice(0, limit - 1).join("")}\u2026`;
}
function escapeMrkdwn(value) {
return value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">");
}
function escapeLinkUrl(value) {
return escapeMrkdwn(value.replaceAll("|", "%7C"));
}
function escapeLinkLabel(value) {
return escapeMrkdwn(value).replaceAll("|", "|");
}
/**
* Render a neutral chunk as Slack stream content.
*
* `reasoning` has no Slack rendering and is dropped. A `source` is collected
* rather than appended, so it can be rendered once beneath the finished
* message instead of interrupting the text.
*/
function toStreamChunks(chunk, sources) {
switch (chunk.type) {
case "text": return splitText(chunk.text, SLACK_APPEND_LIMIT).map((text) => ({
type: "markdown_text",
text
}));
case "tool": return [{
type: "task_update",
id: clamp(chunk.id ?? chunk.name, SLACK_TASK_FIELD_LIMIT),
title: clamp(chunk.title ?? chunk.name, SLACK_TASK_FIELD_LIMIT),
status: SLACK_TASK_STATUS[chunk.status],
...chunk.detail !== void 0 && { details: clamp(chunk.detail, SLACK_TASK_FIELD_LIMIT) }
}];
case "source":
sources.push(chunk);
return [];
case "reasoning": return [];
}
}
function sourcesBlock(sources) {
return {
type: "context",
elements: [{
type: "mrkdwn",
text: clamp(sources.map((source) => `<${escapeLinkUrl(source.url)}|${escapeLinkLabel(source.title ?? source.url)}>`).join(" · "), SLACK_CONTEXT_LIMIT)
}]
};
}
/** Create a configured Slack Web API Channel. */
function slack(options) {
if (!options.botToken.trim()) throw new Error("botToken is required to create a Slack channel");
const fetch = options.fetch ?? globalThis.fetch;
const apiBaseUrl = (options.apiBaseUrl ?? "https://slack.com/api").replace(/\/$/, "");
const toText = options.toText ?? defaultText;
const streamIntervalMs = options.streamIntervalMs ?? DEFAULT_STREAM_INTERVAL_MS;
if (!Number.isInteger(streamIntervalMs) || streamIntervalMs < 0) throw new Error("streamIntervalMs must be a non-negative integer");
const ingress = options.webhook ? slackWebhook(options.webhook) : void 0;
async function resolveTarget(target) {
if (!("userId" in target)) return target;
let response;
try {
response = await fetch(`${apiBaseUrl}/conversations.open`, {
method: "POST",
headers: {
authorization: `Bearer ${options.botToken}`,
"content-type": "application/json; charset=utf-8"
},
body: JSON.stringify({ users: target.userId })
});
} catch {
return failed("SLACK_CONTACT_RESOLUTION_FAILED", "Slack could not resolve a direct-message destination", true);
}
let payload;
try {
payload = await response.json();
} catch {
return failed("SLACK_CONTACT_RESOLUTION_FAILED", "Slack returned an invalid direct-message destination", response.status === 429 || response.status >= 500);
}
const result = isRecord(payload) ? payload : void 0;
if (result?.ok === true && typeof result.channel?.id === "string") return {
channelId: result.channel.id,
recipientUserId: target.userId,
recipientTeamId: target.teamId
};
const error = typeof result?.error === "string" ? result.error : "unknown";
return failed(slackErrorCode(error), `Slack could not open a direct message: ${error}`, response.status === 429 || response.status >= 500 || error === "ratelimited");
}
async function postMessage(destination, text, blocks) {
const unresolvedTarget = slackTarget(destination);
if (!unresolvedTarget) return failed("SLACK_SURFACE_INVALID", `Slack cannot parse the address for Channel "${destination.channelKey}"`, false);
const target = await resolveTarget(unresolvedTarget);
if ("status" in target) return target;
const sent = await callSlack("chat.postMessage", {
channel: target.channelId,
text,
mrkdwn: true,
...target.threadTs && { thread_ts: target.threadTs },
...blocks && { blocks }
});
return "status" in sent ? sent : {
status: "delivered",
reference: outboundReference(sent.channelId, sent.ts)
};
}
/**
* One transport path for every Slack method this Channel calls.
*
* Each of them answers with the conversation and timestamp on success, and
* every failure mode is classified the same way, so posting and streaming
* cannot drift apart.
*/
async function callSlack(method, body) {
let response;
try {
response = await fetch(`${apiBaseUrl}/${method}`, {
method: "POST",
headers: {
authorization: `Bearer ${options.botToken}`,
"content-type": "application/json; charset=utf-8"
},
body: JSON.stringify(body)
});
} catch {
return uncertain("SLACK_DELIVERY_ERROR", "Slack delivery failed with an unknown outcome");
}
let payload;
try {
payload = await response.json();
} catch {
payload = void 0;
}
const apiResponse = asApiResponse(payload);
if (apiResponse?.ok === true && typeof apiResponse.channel === "string" && typeof apiResponse.ts === "string") return {
channelId: apiResponse.channel,
ts: apiResponse.ts
};
return apiResponse?.ok === true ? uncertain("SLACK_DELIVERY_ERROR", "Slack returned an invalid delivery response") : classifyApiFailure(response, apiResponse ?? {});
}
async function streamMessage(destination, chunks, streamOptions) {
const unresolvedTarget = slackTarget(destination);
if (!unresolvedTarget) {
await chunks.cancel().catch(() => {});
return failed("SLACK_SURFACE_INVALID", `Slack cannot parse the address for Channel "${destination.channelKey}"`, false);
}
if (!("userId" in unresolvedTarget) && !unresolvedTarget.threadTs && !unresolvedTarget.recipientUserId) {
const collected = await collectText(chunks);
if (collected.interrupted && collected.text.length === 0) return failed("SLACK_STREAM_INTERRUPTED", "The stream ended before producing any content to deliver", false);
const delivered = await postMessage(destination, toText({
...streamOptions.title && { title: streamOptions.title },
markdown: collected.text
}));
if (!collected.interrupted || delivered.status !== "delivered") return delivered;
return uncertain("SLACK_STREAM_INTERRUPTED", "An incomplete answer was delivered because the stream ended early", delivered.reference);
}
const target = await resolveTarget(unresolvedTarget);
if ("status" in target) {
await chunks.cancel().catch(() => {});
return target;
}
const started = await callSlack("chat.startStream", {
channel: target.channelId,
...target.threadTs && { thread_ts: target.threadTs },
...target.recipientUserId && {
recipient_user_id: target.recipientUserId,
recipient_team_id: target.recipientTeamId
},
...streamOptions.title && { chunks: splitText(`${streamOptions.title}\n\n`, SLACK_APPEND_LIMIT).map((text) => ({
type: "markdown_text",
text
})) }
});
if ("status" in started) {
await chunks.cancel().catch(() => {});
return started;
}
const reference = outboundReference(started.channelId, started.ts);
const sources = [];
const shouldFlush = createPacer(streamIntervalMs);
let pending = [];
let appendFailure;
return consumeChunks(chunks, {
async onChunk(chunk) {
pending.push(...toStreamChunks(chunk, sources));
if (pending.length === 0 || !shouldFlush()) return;
const appended = await callSlack("chat.appendStream", {
channel: started.channelId,
ts: started.ts,
chunks: pending
});
pending = [];
if ("status" in appended) {
appendFailure = appended;
throw new Error(appended.error.message);
}
},
async onFinish(outcome) {
const stopped = await callSlack("chat.stopStream", {
channel: started.channelId,
ts: started.ts,
...pending.length > 0 && !appendFailure && { chunks: pending },
...sources.length > 0 && { blocks: [sourcesBlock(sources)] }
});
if ("status" in stopped) return uncertain(stopped.error.code, `Slack could not stop the stream: ${stopped.error.message}`, reference);
if (appendFailure) return uncertain(appendFailure.error.code, appendFailure.error.message, reference);
if (outcome.interrupted) return uncertain("SLACK_STREAM_INTERRUPTED", "The answer ended early, so the Slack message is incomplete", reference);
return {
status: "delivered",
reference
};
}
});
}
return {
...options.route && { route: options.route },
...ingress && { ingress },
contactSurface(identity) {
if (identity.scope === void 0) return null;
return {
version: 1,
address: {
teamId: identity.scope,
userId: identity.subject
},
label: `Slack · user ${identity.subject}`
};
},
deliver(destination, message) {
return postMessage(destination, toText(message));
},
stream(destination, chunks, streamOptions) {
return streamMessage(destination, chunks, streamOptions);
},
requestApproval(destination, { interactionId, request }) {
if (interactionId.length === 0) return Promise.resolve(failed("SLACK_INTERACTION_ID_REQUIRED", "Slack approval requests require a non-empty interaction id", false));
const text = approvalText(request);
const approveValue = approvalValue(interactionId, "approve");
const rejectValue = approvalValue(interactionId, "reject");
if (text.length > 3e3 || approveValue.length > 2e3 || rejectValue.length > 2e3) return Promise.resolve(failed("SLACK_APPROVAL_TOO_LONG", "Slack approval content exceeds Block Kit limits", false));
return postMessage(destination, text, [{
type: "section",
text: {
type: "mrkdwn",
text
}
}, {
type: "actions",
elements: [{
type: "button",
action_id: APPROVE_ACTION_ID,
text: {
type: "plain_text",
text: "Approve"
},
style: "primary",
value: approveValue
}, {
type: "button",
action_id: REJECT_ACTION_ID,
text: {
type: "plain_text",
text: "Reject"
},
style: "danger",
value: rejectValue
}]
}]);
}
};
}
//#endregion
export { slack, slackWebhook };
//# sourceMappingURL=slack.js.map