UNPKG

agents

Version:

A home for your AI agents

614 lines (613 loc) 23.6 kB
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("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;"); } function escapeLinkUrl(value) { return escapeMrkdwn(value.replaceAll("|", "%7C")); } function escapeLinkLabel(value) { return escapeMrkdwn(value).replaceAll("|", "&#124;"); } /** * 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