From d2b92248c714429047ed8e1be4db568ca0d7cf7b Mon Sep 17 00:00:00 2001 From: David Harvey Date: Thu, 18 Jun 2026 13:18:27 -0700 Subject: [PATCH 1/2] feat(channels): add built-in Blooio channel Add `blooioChannel()` (`eve/channels/blooio`) connecting agents to iMessage, RCS, and SMS through the Blooio v2 API. - Inbound: HMAC-SHA256 X-Blooio-Signature verification over `.`, message.received dispatch with per-line continuation tokens, 1:1 + group support, allowFrom filtering, and acknowledgement of delivery-status events. - Outbound BlooioHandle: sendMessage (text/attachments/iMessage effects/ inline replies/idempotency/contact-card), tapback + emoji reactions, typing indicators, read receipts, capability checks, message history, and a raw API escape hatch. - Credentials default to BLOOIO_API_KEY and BLOOIO_WEBHOOK_SECRET. - Registered in the package exports map, channel metadata/reference maps, the @vercel/eve-catalog gallery, and the docs integrations gallery. - Unit tests for signature verification, inbound parsing, the webhook route, and default delivery. Co-authored-by: Cursor --- .changeset/blooio-channel.md | 8 + apps/docs/lib/integrations/data.ts | 21 + apps/docs/lib/integrations/logos.tsx | 14 + packages/eve-catalog/src/index.ts | 7 + packages/eve/package.json | 5 + .../eve/src/public/channels/blooio/api.ts | 310 ++++++++++++ .../channels/blooio/blooioChannel.test.ts | 152 ++++++ .../public/channels/blooio/blooioChannel.ts | 461 ++++++++++++++++++ .../src/public/channels/blooio/defaults.ts | 72 +++ .../eve/src/public/channels/blooio/inbound.ts | 143 ++++++ .../eve/src/public/channels/blooio/index.ts | 62 +++ .../src/public/channels/blooio/verify.test.ts | 69 +++ .../eve/src/public/channels/blooio/verify.ts | 108 ++++ packages/eve/src/public/channels/index.ts | 2 + 14 files changed, 1434 insertions(+) create mode 100644 .changeset/blooio-channel.md create mode 100644 packages/eve/src/public/channels/blooio/api.ts create mode 100644 packages/eve/src/public/channels/blooio/blooioChannel.test.ts create mode 100644 packages/eve/src/public/channels/blooio/blooioChannel.ts create mode 100644 packages/eve/src/public/channels/blooio/defaults.ts create mode 100644 packages/eve/src/public/channels/blooio/inbound.ts create mode 100644 packages/eve/src/public/channels/blooio/index.ts create mode 100644 packages/eve/src/public/channels/blooio/verify.test.ts create mode 100644 packages/eve/src/public/channels/blooio/verify.ts diff --git a/.changeset/blooio-channel.md b/.changeset/blooio-channel.md new file mode 100644 index 000000000..ec50cf986 --- /dev/null +++ b/.changeset/blooio-channel.md @@ -0,0 +1,8 @@ +--- +"eve": minor +"@vercel/eve-catalog": minor +--- + +Add a built-in Blooio channel (`eve/channels/blooio`). + +`blooioChannel()` connects an agent to iMessage, RCS, and SMS through the Blooio v2 API. It verifies inbound `X-Blooio-Signature` webhooks (HMAC-SHA256 over `.`), dispatches `message.received` events with per-line continuation tokens, and supports 1:1 and group conversations. The `BlooioHandle` exposed to hooks and event handlers wraps the full messaging surface: sending text/attachments with iMessage send-effects, inline replies, idempotency keys and contact-card sharing, tapback/emoji reactions, typing indicators, read receipts, contact capability checks, message history, and a raw API escape hatch. Credentials default to `BLOOIO_API_KEY` and `BLOOIO_WEBHOOK_SECRET`. diff --git a/apps/docs/lib/integrations/data.ts b/apps/docs/lib/integrations/data.ts index ac8e86ac6..a6380da5d 100644 --- a/apps/docs/lib/integrations/data.ts +++ b/apps/docs/lib/integrations/data.ts @@ -225,6 +225,27 @@ export default twilioChannel({ \`\`\``, configure: `In the Twilio console, point your messaging service or phone number webhook at eve's route (\`/eve/v1/twilio\`). Provide the account SID and auth token via environment variables. See the [Twilio channel docs](/docs/channels/twilio) for SMS vs. WhatsApp specifics.`, }, + blooio: { + logo: "blooio", + docsHref: "/docs/channels/blooio", + keywords: ["imessage", "rcs", "sms", "messaging", "phone", "apple"], + install: `Install the framework and the Blooio channel: + +\`\`\`bash +npm install eve@latest +\`\`\``, + quickStart: `Create \`agent/channels/blooio.ts\`: + +\`\`\`ts +// agent/channels/blooio.ts +import { blooioChannel } from "eve/channels/blooio"; + +export default blooioChannel(); +\`\`\` + +The channel reads \`BLOOIO_API_KEY\` and \`BLOOIO_WEBHOOK_SECRET\` from the environment by default.`, + configure: `Create a Blooio API key and a webhook that points at the route eve serves (\`/eve/v1/blooio\`). Provide the API key and webhook signing secret through \`BLOOIO_API_KEY\` and \`BLOOIO_WEBHOOK_SECRET\`. Inbound \`message.received\` events are verified with HMAC-SHA256 and dispatched to your agent; replies, tapback reactions, typing indicators, and read receipts go back through the Blooio v2 API. See the [Blooio channel docs](/docs/channels/blooio) for group chats and send options.`, + }, github: { logo: "github", docsHref: "/docs/channels/github", diff --git a/apps/docs/lib/integrations/logos.tsx b/apps/docs/lib/integrations/logos.tsx index 69e15f7ee..8562fb89d 100644 --- a/apps/docs/lib/integrations/logos.tsx +++ b/apps/docs/lib/integrations/logos.tsx @@ -110,6 +110,19 @@ export const twilioLogo = (props: LogoProps) => ( ); +export const blooioLogo = (props: LogoProps) => ( + + + + +); + export const linearLogo = (props: LogoProps) => ( string | Promise); + +/** Webhook signing secret, materialized directly or from an async secret provider. */ +export type BlooioWebhookSecret = string | (() => string | Promise); + +/** Fetch implementation override matching the global `fetch` signature. Defaults to the runtime global; supply a custom one for tests or non-standard runtimes. */ +export type BlooioFetch = typeof fetch; + +/** Credentials required for Blooio REST API calls and webhook verification. */ +export interface BlooioCredentials { + /** Blooio API key (Bearer token). Falls back to `BLOOIO_API_KEY`. */ + readonly apiKey?: BlooioApiKey; + /** Webhook signing secret (`whsec_...`). Falls back to `BLOOIO_WEBHOOK_SECRET`. */ + readonly webhookSecret?: BlooioWebhookSecret; +} + +/** Shared Blooio REST API options. */ +export interface BlooioApiOptions { + readonly credentials?: BlooioCredentials; + /** Override the API base URL. Falls back to `BLOOIO_BASE_URL`, then the public default. */ + readonly baseUrl?: string; + readonly fetch?: BlooioFetch; +} + +/** + * Result of a Blooio REST call: HTTP `status`, an `ok` flag, and `body`. + * `body` holds parsed JSON for a JSON response, the raw text string + * otherwise, or `null` when empty. + */ +export interface BlooioApiResponse { + readonly status: number; + readonly ok: boolean; + readonly body: unknown; +} + +/** iMessage send-with-effect identifiers. iMessage-only; ignored on SMS/RCS. */ +export type BlooioMessageEffect = + | "balloons" + | "celebration" + | "confetti" + | "echo" + | "fireworks" + | "gentle" + | "invisible-ink" + | "lasers" + | "loud" + | "love" + | "slam" + | "spotlight"; + +/** One outbound attachment: a public URL with an optional display name. */ +export interface BlooioAttachment { + readonly url: string; + readonly name?: string; +} + +/** Parameters for sending an outbound Blooio message. */ +export interface BlooioSendMessageInput extends BlooioApiOptions { + /** Conversation target: phone (E.164), email, group ID (`grp_...`), or comma-separated recipients. */ + readonly chatId: string; + readonly text?: string; + readonly attachments?: readonly (string | BlooioAttachment)[]; + /** E.164 number to send from. Must be assigned to your API key. Optional for Twilio keys. */ + readonly fromNumber?: string; + readonly effect?: BlooioMessageEffect; + /** Send as an inline reply to an earlier Blooio message (`msg_...`). iMessage-only. */ + readonly replyToMessageId?: string; + readonly shareContact?: boolean; + readonly useTypingIndicator?: boolean; + /** Unique key to prevent duplicate sends. Re-using a key returns the original result. */ + readonly idempotencyKey?: string; +} + +/** Parameters for adding or removing a reaction on a message. */ +export interface BlooioReactInput extends BlooioApiOptions { + readonly chatId: string; + /** Message ID (`msg_...`) or a relative index (`-1` for the last message). */ + readonly messageId: string; + /** Prefix with `+` to add or `-` to remove. Tapbacks: love, like, dislike, laugh, emphasize, question. Emoji also accepted. */ + readonly reaction: string; + /** Only used when `messageId` is a relative index: filters which direction the index counts. */ + readonly direction?: "inbound" | "outbound"; +} + +/** Filters for listing messages in a conversation. */ +export interface BlooioListMessagesInput extends BlooioApiOptions { + readonly chatId: string; + readonly limit?: number; + readonly offset?: number; + readonly direction?: "inbound" | "outbound"; + readonly since?: number; + readonly until?: number; + readonly sort?: "asc" | "desc"; +} + +/** + * Builds the Blooio channel-local continuation token + * (`:`). Route `send()` namespaces this with the + * channel name before passing it to the runtime + * (`blooio::`), keeping a conversation sticky to a + * specific phone line. `internalId` may be empty for proactive sessions + * that do not yet know the sending number. + */ +export function blooioContinuationToken(internalId: string | undefined, chatId: string): string { + return `${internalId ?? ""}:${chatId}`; +} + +/** Resolves a Blooio API key, falling back to `BLOOIO_API_KEY`. */ +export async function resolveBlooioApiKey(apiKey?: BlooioApiKey): Promise { + const source = apiKey ?? process.env.BLOOIO_API_KEY; + if (!source) throw new Error("blooioChannel: BLOOIO_API_KEY is required."); + return typeof source === "function" ? await source() : source; +} + +/** Resolves the API base URL, falling back to `BLOOIO_BASE_URL` then the public default. */ +export function resolveBlooioBaseUrl(baseUrl?: string): string { + const resolved = baseUrl ?? process.env.BLOOIO_BASE_URL ?? DEFAULT_BLOOIO_BASE_URL; + return resolved.endsWith("/") ? resolved.slice(0, -1) : resolved; +} + +/** + * Calls the Blooio v2 REST API with Bearer auth and an optional JSON body. + * + * `path` is relative to the resolved base URL and must begin with `/`. + */ +export async function callBlooioApi( + input: BlooioApiOptions & { + readonly method: "GET" | "POST" | "PATCH" | "DELETE" | "PUT"; + readonly path: string; + readonly body?: unknown; + readonly query?: Readonly>; + readonly headers?: Readonly>; + }, +): Promise { + const apiKey = await resolveBlooioApiKey(input.credentials?.apiKey); + const apiFetch = input.fetch ?? fetch; + const base = resolveBlooioBaseUrl(input.baseUrl); + const url = new URL(`${base}${input.path}`); + if (input.query) { + for (const [key, value] of Object.entries(input.query)) { + if (value === undefined || value === null) continue; + url.searchParams.set(key, String(value)); + } + } + + const headers: Record = { + authorization: `Bearer ${apiKey}`, + ...input.headers, + }; + let body: string | undefined; + if (input.body !== undefined && input.method !== "GET") { + headers["content-type"] = "application/json"; + body = JSON.stringify(input.body); + } + + const response = await apiFetch(url.toString(), { + method: input.method, + headers, + body, + }); + return { + status: response.status, + ok: response.ok, + body: await parseResponseBody(response), + }; +} + +/** Sends a text and/or attachment message to a chat (`POST /chats/{chatId}/messages`). */ +export async function sendBlooioMessage(input: BlooioSendMessageInput): Promise { + if (!input.text && (!input.attachments || input.attachments.length === 0)) { + throw new Error("blooioChannel: sending a message requires text or at least one attachment."); + } + const body: Record = {}; + if (input.text) body.text = input.text; + if (input.attachments && input.attachments.length > 0) { + body.attachments = input.attachments.map((attachment) => + typeof attachment === "string" + ? attachment + : attachment.name + ? { url: attachment.url, name: attachment.name } + : attachment.url, + ); + } + if (input.fromNumber) body.from_number = input.fromNumber; + if (input.effect) body.effect = input.effect; + if (input.replyToMessageId) body.reply_to = { message_id: input.replyToMessageId }; + if (input.shareContact !== undefined) body.share_contact = input.shareContact; + if (input.useTypingIndicator !== undefined) body.use_typing_indicator = input.useTypingIndicator; + + return callBlooioApi({ + baseUrl: input.baseUrl, + credentials: input.credentials, + fetch: input.fetch, + method: "POST", + path: `/chats/${encodeURIComponent(input.chatId)}/messages`, + body, + headers: input.idempotencyKey ? { "Idempotency-Key": input.idempotencyKey } : undefined, + }); +} + +/** Adds or removes a tapback/emoji reaction on a message. */ +export async function reactBlooioMessage(input: BlooioReactInput): Promise { + const body: Record = { reaction: input.reaction }; + if (input.direction) body.direction = input.direction; + return callBlooioApi({ + baseUrl: input.baseUrl, + credentials: input.credentials, + fetch: input.fetch, + method: "POST", + path: `/chats/${encodeURIComponent(input.chatId)}/messages/${encodeURIComponent( + input.messageId, + )}/reactions`, + body, + }); +} + +/** Shows the typing indicator in a chat (`POST /chats/{chatId}/typing`). iMessage-only. */ +export async function startBlooioTyping( + input: BlooioApiOptions & { readonly chatId: string }, +): Promise { + return callBlooioApi({ + baseUrl: input.baseUrl, + credentials: input.credentials, + fetch: input.fetch, + method: "POST", + path: `/chats/${encodeURIComponent(input.chatId)}/typing`, + }); +} + +/** Hides the typing indicator in a chat (`DELETE /chats/{chatId}/typing`). */ +export async function stopBlooioTyping( + input: BlooioApiOptions & { readonly chatId: string }, +): Promise { + return callBlooioApi({ + baseUrl: input.baseUrl, + credentials: input.credentials, + fetch: input.fetch, + method: "DELETE", + path: `/chats/${encodeURIComponent(input.chatId)}/typing`, + }); +} + +/** Marks a chat as read and sends a read receipt (`POST /chats/{chatId}/read`). */ +export async function markBlooioChatRead( + input: BlooioApiOptions & { readonly chatId: string }, +): Promise { + return callBlooioApi({ + baseUrl: input.baseUrl, + credentials: input.credentials, + fetch: input.fetch, + method: "POST", + path: `/chats/${encodeURIComponent(input.chatId)}/read`, + }); +} + +/** Checks whether a contact supports iMessage, SMS, and/or FaceTime. */ +export async function checkBlooioCapabilities( + input: BlooioApiOptions & { readonly contact: string }, +): Promise { + return callBlooioApi({ + baseUrl: input.baseUrl, + credentials: input.credentials, + fetch: input.fetch, + method: "GET", + path: `/contacts/${encodeURIComponent(input.contact)}/capabilities`, + }); +} + +/** Lists messages in a conversation with optional filters. */ +export async function listBlooioMessages( + input: BlooioListMessagesInput, +): Promise { + return callBlooioApi({ + baseUrl: input.baseUrl, + credentials: input.credentials, + fetch: input.fetch, + method: "GET", + path: `/chats/${encodeURIComponent(input.chatId)}/messages`, + query: { + direction: input.direction, + limit: input.limit, + offset: input.offset, + since: input.since, + sort: input.sort, + until: input.until, + }, + }); +} + +async function parseResponseBody(response: Response): Promise { + const text = await response.text(); + if (!text) return null; + try { + return JSON.parse(text); + } catch { + return text; + } +} diff --git a/packages/eve/src/public/channels/blooio/blooioChannel.test.ts b/packages/eve/src/public/channels/blooio/blooioChannel.test.ts new file mode 100644 index 000000000..ce5c30e43 --- /dev/null +++ b/packages/eve/src/public/channels/blooio/blooioChannel.test.ts @@ -0,0 +1,152 @@ +import { describe, expect, it, vi } from "vitest"; + +import { blooioChannel } from "#public/channels/blooio/blooioChannel.js"; +import { defaultEvents } from "#public/channels/blooio/defaults.js"; +import { parseBlooioInboundMessage } from "#public/channels/blooio/inbound.js"; +import { signBlooioPayload } from "#public/channels/blooio/verify.js"; + +const SECRET = "whsec_test_secret"; + +function getPostHandler(channel: ReturnType) { + const route = channel.routes.find( + (r) => (r as { method?: string }).method === "POST", + ) as { handler: (req: Request, args: unknown) => Promise }; + return route.handler; +} + +function signedWebhook(payload: unknown): Request { + const body = JSON.stringify(payload); + const t = Math.floor(Date.now() / 1000); + return new Request("https://example.com/eve/v1/blooio", { + method: "POST", + headers: { "x-blooio-signature": `t=${t},v1=${signBlooioPayload(SECRET, t, body)}` }, + body, + }); +} + +describe("parseBlooioInboundMessage", () => { + it("routes 1:1 chats to the sender and group chats to the group id", () => { + const direct = parseBlooioInboundMessage({ + event: "message.received", + sender: "+15551234567", + internal_id: "+15557654321", + text: "hi", + }); + expect(direct?.chatId).toBe("+15551234567"); + expect(direct?.isGroup).toBe(false); + + const group = parseBlooioInboundMessage({ + event: "message.received", + is_group: true, + group_id: "grp_abc", + sender: "+15551234567", + text: "hi all", + }); + expect(group?.chatId).toBe("grp_abc"); + expect(group?.isGroup).toBe(true); + }); + + it("ignores non-received events", () => { + expect(parseBlooioInboundMessage({ event: "message.delivered" })).toBeNull(); + expect(parseBlooioInboundMessage(null)).toBeNull(); + }); +}); + +describe("blooioChannel inbound route", () => { + it("dispatches a verified message.received webhook", async () => { + const channel = blooioChannel({ credentials: { apiKey: "sk", webhookSecret: SECRET } }); + const handler = getPostHandler(channel); + + const send = vi.fn(async () => ({ id: "session_1" })); + const tasks: Promise[] = []; + const waitUntil = (task: Promise) => { + tasks.push(task); + }; + + const res = await handler( + signedWebhook({ + event: "message.received", + message_id: "msg_1", + sender: "+15551234567", + internal_id: "+15557654321", + text: "hello", + }), + { send, waitUntil }, + ); + await Promise.all(tasks); + + expect(res.status).toBe(200); + expect(send).toHaveBeenCalledTimes(1); + const [payload, options] = send.mock.calls[0] as unknown as [ + { message: string }, + { continuationToken: string; state: { chatId: string } }, + ]; + expect(payload.message).toBe("hello"); + expect(options.continuationToken).toBe("+15557654321:+15551234567"); + expect(options.state.chatId).toBe("+15551234567"); + }); + + it("rejects an unsigned webhook", async () => { + const channel = blooioChannel({ credentials: { apiKey: "sk", webhookSecret: SECRET } }); + const handler = getPostHandler(channel); + const res = await handler( + new Request("https://example.com/eve/v1/blooio", { + method: "POST", + body: JSON.stringify({ event: "message.received", sender: "+1" }), + }), + { send: vi.fn(), waitUntil: () => {} }, + ); + expect(res.status).toBe(401); + }); + + it("acks non-received events without dispatching", async () => { + const channel = blooioChannel({ credentials: { apiKey: "sk", webhookSecret: SECRET } }); + const handler = getPostHandler(channel); + const send = vi.fn(); + const res = await handler(signedWebhook({ event: "message.delivered", message_id: "msg_1" }), { + send, + waitUntil: () => {}, + }); + expect(res.status).toBe(200); + expect(send).not.toHaveBeenCalled(); + }); + + it("honors an allowFrom list", async () => { + const channel = blooioChannel({ + allowFrom: ["+15559999999"], + credentials: { apiKey: "sk", webhookSecret: SECRET }, + }); + const handler = getPostHandler(channel); + const send = vi.fn(); + const res = await handler( + signedWebhook({ event: "message.received", sender: "+15551234567", text: "hi" }), + { send, waitUntil: () => {} }, + ); + expect(res.status).toBe(403); + expect(send).not.toHaveBeenCalled(); + }); +}); + +describe("default message.completed handler", () => { + it("sends the completed assistant message", async () => { + const sendMessage = vi.fn(async () => ({ ok: true, status: 200, body: null })); + const channel = { blooio: { sendMessage } } as never; + await defaultEvents["message.completed"]!( + { finishReason: "stop", message: "the answer", sequence: 0, stepIndex: 0, turnId: "t" }, + channel, + {} as never, + ); + expect(sendMessage).toHaveBeenCalledWith("the answer"); + }); + + it("skips tool-call boundaries", async () => { + const sendMessage = vi.fn(); + const channel = { blooio: { sendMessage } } as never; + await defaultEvents["message.completed"]!( + { finishReason: "tool-calls", message: null, sequence: 0, stepIndex: 0, turnId: "t" }, + channel, + {} as never, + ); + expect(sendMessage).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/eve/src/public/channels/blooio/blooioChannel.ts b/packages/eve/src/public/channels/blooio/blooioChannel.ts new file mode 100644 index 000000000..65ce70eaf --- /dev/null +++ b/packages/eve/src/public/channels/blooio/blooioChannel.ts @@ -0,0 +1,461 @@ +import type { SessionAuthContext } from "#channel/types.js"; +import type { SessionContext } from "#public/definitions/callback-context.js"; +import type { ChannelSessionOps } from "#public/definitions/defineChannel.js"; + +import { createLogger } from "#internal/logging.js"; +import type { HandleMessageStreamEvent } from "#protocol/message.js"; +import { + blooioContinuationToken, + callBlooioApi, + checkBlooioCapabilities, + listBlooioMessages, + markBlooioChatRead, + reactBlooioMessage, + sendBlooioMessage, + startBlooioTyping, + stopBlooioTyping, + type BlooioApiResponse, + type BlooioCredentials, + type BlooioFetch, + type BlooioListMessagesInput, + type BlooioMessageEffect, + type BlooioSendMessageInput, + type BlooioWebhookSecret, +} from "#public/channels/blooio/api.js"; +import { defaultEvents, defaultOnMessage } from "#public/channels/blooio/defaults.js"; +import { + formatBlooioContextBlock, + parseBlooioInboundMessage, + type BlooioInboundMessage, +} from "#public/channels/blooio/inbound.js"; +import { verifyBlooioRequest } from "#public/channels/blooio/verify.js"; +import { + defineChannel, + POST, + type Channel, + type SendFn, +} from "#public/definitions/defineChannel.js"; + +const log = createLogger("blooio.channel"); + +type EventData = + Extract extends { data: infer D } ? D : undefined; + +/** Pre-dispatch Blooio context passed to the inbound message hook. */ +export interface BlooioContext { + readonly blooio: BlooioHandle; +} + +/** Channel-owned Blooio context returned by `context()`. */ +export interface BlooioChannelContext extends BlooioContext { + state: BlooioChannelState; +} + +/** Event-handler Blooio context, including session operations. */ +export interface BlooioEventContext extends BlooioChannelContext, ChannelSessionOps {} + +/** JSON-serializable durable state for one Blooio conversation. */ +export interface BlooioChannelState { + /** Reply target: phone (E.164), email, or group ID (`grp_...`). */ + chatId: string | null; + /** Blooio device/number that received the conversation (our line). */ + internalId: string | null; + /** Sender of the most recent inbound message. */ + sender: string | null; + /** Whether the conversation is a group chat. */ + isGroup: boolean; + /** Most recent inbound Blooio message ID. */ + lastMessageId: string | null; +} + +/** Per-session instrumentation snapshot for Blooio runtime telemetry. Reports the active line, reply target, group flag, and the most recent inbound message ID. */ +export interface BlooioInstrumentationMetadata extends Record { + readonly chatId: string | null; + readonly internalId: string | null; + readonly isGroup: boolean; + readonly lastMessageId: string | null; +} + +/** Sender allow list for inbound Blooio webhook triggers. `"*"` allows every verified sender. */ +export type BlooioAllowFrom = + | string + | readonly string[] + | (() => string | readonly string[] | Promise); + +/** Result of an inbound Blooio message hook. Return `null` (or `undefined`) to drop the webhook without dispatching; otherwise supply the session `auth` context. */ +export type BlooioInboundResult = { + auth: SessionAuthContext | null; +} | null; + +/** Sync or async {@link BlooioInboundResult}. */ +export type BlooioInboundResultOrPromise = BlooioInboundResult | Promise; + +/** Target accepted by `receive(blooio, { target })` for proactive conversations. */ +export interface BlooioReceiveTarget { + /** Conversation target: phone (E.164), email, group ID (`grp_...`), or comma-separated recipients. */ + readonly chatId: string; + /** Blooio number to send from, included in the continuation token. */ + readonly fromNumber?: string; +} + +type BlooioEventHandler = ( + data: EventData, + channel: BlooioEventContext, + ctx: SessionContext, +) => void | Promise; + +type BlooioSessionFailedHandler = ( + data: EventData<"session.failed">, + channel: BlooioEventContext, +) => void | Promise; + +/** Event handlers supported by `blooioChannel({ events })`. */ +export interface BlooioChannelEvents { + readonly "turn.started"?: BlooioEventHandler<"turn.started">; + readonly "actions.requested"?: BlooioEventHandler<"actions.requested">; + readonly "action.result"?: BlooioEventHandler<"action.result">; + readonly "message.completed"?: BlooioEventHandler<"message.completed">; + readonly "message.appended"?: BlooioEventHandler<"message.appended">; + readonly "input.requested"?: BlooioEventHandler<"input.requested">; + readonly "turn.failed"?: BlooioEventHandler<"turn.failed">; + readonly "turn.completed"?: BlooioEventHandler<"turn.completed">; + readonly "session.failed"?: BlooioSessionFailedHandler; + readonly "session.completed"?: BlooioEventHandler<"session.completed">; + readonly "session.waiting"?: BlooioEventHandler<"session.waiting">; + readonly "authorization.required"?: BlooioEventHandler<"authorization.required">; + readonly "authorization.completed"?: BlooioEventHandler<"authorization.completed">; +} + +/** Per-call overrides for {@link BlooioHandle.sendMessage}. */ +export interface BlooioSendMessageOptions { + /** Recipient. Defaults to the conversation's `chatId`. */ + readonly chatId?: string; + /** Sender number. Defaults to `fromNumber`, then the inbound `internalId`. */ + readonly fromNumber?: string; + readonly attachments?: BlooioSendMessageInput["attachments"]; + readonly effect?: BlooioMessageEffect; + readonly replyToMessageId?: string; + readonly shareContact?: boolean; + readonly useTypingIndicator?: boolean; + readonly idempotencyKey?: string; +} + +/** Low-level Blooio handle exposed to hooks and event handlers. */ +export interface BlooioHandle { + /** Reply target bound to this conversation. */ + readonly chatId: string; + /** Blooio number that received this conversation, when known. */ + readonly internalId: string | undefined; + /** Sender of the most recent inbound message, when known. */ + readonly sender: string | undefined; + readonly isGroup: boolean; + /** Sends a text and/or attachment message to this conversation by default. */ + sendMessage(message: string, options?: BlooioSendMessageOptions): Promise; + /** Adds (`+`) or removes (`-`) a tapback/emoji reaction on a message. */ + react( + messageId: string, + reaction: string, + options?: { chatId?: string; direction?: "inbound" | "outbound" }, + ): Promise; + /** Shows the typing indicator (iMessage-only). */ + startTyping(chatId?: string): Promise; + /** Hides the typing indicator. */ + stopTyping(chatId?: string): Promise; + /** Marks the conversation read and sends a read receipt. */ + markRead(chatId?: string): Promise; + /** Checks whether a contact supports iMessage, SMS, and/or FaceTime. */ + checkCapabilities(contact?: string): Promise; + /** Lists messages in the conversation. */ + listMessages( + options?: Omit & { + chatId?: string; + }, + ): Promise; + /** Raw Blooio v2 API escape hatch. `path` is appended to the API base URL. */ + request( + method: "GET" | "POST" | "PATCH" | "DELETE" | "PUT", + path: string, + body?: unknown, + query?: Readonly>, + ): Promise; +} + +/** Configuration for {@link blooioChannel}. */ +export interface BlooioChannelConfig { + readonly credentials?: BlooioCredentials; + /** Route for the Blooio webhook. Defaults to `/eve/v1/blooio`. */ + readonly route?: string; + /** Override the API base URL. Falls back to `BLOOIO_BASE_URL`, then the public default. */ + readonly baseUrl?: string; + /** Fetch override for REST calls. */ + readonly fetch?: BlooioFetch; + /** Maximum allowed age of a webhook signature, in seconds. Defaults to 300. */ + readonly timestampToleranceSec?: number; + /** + * Exact senders allowed to reach the inbound hook, or `"*"` to allow every + * verified sender. Defaults to `"*"`. + */ + readonly allowFrom?: BlooioAllowFrom; + /** Default sender number for outbound replies. Falls back to the inbound `internalId`. */ + readonly fromNumber?: string; + /** Mark conversations read when an inbound message is received. Defaults to `false`. */ + readonly markReadOnReceive?: boolean; + /** Override the secret used to verify webhook signatures (defaults to `credentials.webhookSecret`). */ + readonly webhookSecret?: BlooioWebhookSecret; + + /** Inbound message hook. Defaults to sender auth and dispatch. */ + onMessage?(ctx: BlooioContext, message: BlooioInboundMessage): BlooioInboundResultOrPromise; + + readonly events?: BlooioChannelEvents; +} + +/** Concrete return type of {@link blooioChannel}. */ +export interface BlooioChannel + extends Channel {} + +/** + * Blooio channel factory for inbound and outbound iMessage, RCS, and SMS via + * the Blooio v2 API. Verifies `X-Blooio-Signature` webhooks, dispatches + * `message.received` events into the agent, and replies through the Blooio + * REST API. + */ +export function blooioChannel(config: BlooioChannelConfig = {}): BlooioChannel { + const route = config.route ?? "/eve/v1/blooio"; + const allowFrom = config.allowFrom ?? "*"; + const onMessage = config.onMessage ?? defaultOnMessage; + const mergedEvents: BlooioChannelEvents = { ...defaultEvents, ...config.events }; + + return defineChannel< + BlooioChannelState, + BlooioChannelContext, + BlooioReceiveTarget, + BlooioInstrumentationMetadata + >({ + kindHint: "blooio", + state: { + chatId: null, + internalId: null, + isGroup: false, + lastMessageId: null, + sender: null, + }, + metadata(state): BlooioInstrumentationMetadata { + return { + chatId: state.chatId, + internalId: state.internalId, + isGroup: state.isGroup, + lastMessageId: state.lastMessageId, + }; + }, + + context(state): BlooioChannelContext { + return { + state, + blooio: buildBlooioHandle({ + chatId: state.chatId ?? "", + config, + internalId: state.internalId ?? undefined, + isGroup: state.isGroup, + sender: state.sender ?? undefined, + }), + }; + }, + + routes: [ + POST(route, async (req, { send, waitUntil }) => { + const verified = await verifyInbound(req, config); + if (verified === null) return new Response("unauthorized", { status: 401 }); + + const message = parsePayload(verified.body); + // Acknowledge non-inbound events (delivery status, polls, etc.). + if (!message) return new Response("ok"); + if (!(await isAllowed(message.sender, allowFrom))) { + return new Response("forbidden", { status: 403 }); + } + + waitUntil(dispatch({ config, message, onMessage, send })); + return new Response("ok"); + }), + ], + + async receive(input, { send }) { + const chatId = input.target.chatId; + if (!chatId) throw new Error("blooioChannel().receive requires target.chatId."); + const fromNumber = input.target.fromNumber ?? config.fromNumber ?? null; + return send(input.message, { + auth: input.auth, + continuationToken: blooioContinuationToken(fromNumber ?? undefined, chatId), + state: { + chatId, + internalId: fromNumber, + isGroup: chatId.startsWith("grp_"), + lastMessageId: null, + sender: null, + }, + }); + }, + + events: mergedEvents, + }); +} + +async function verifyInbound( + req: Request, + config: BlooioChannelConfig, +): Promise<{ body: string } | null> { + try { + return await verifyBlooioRequest(req, { + timestampToleranceSec: config.timestampToleranceSec, + webhookSecret: config.webhookSecret ?? config.credentials?.webhookSecret, + }); + } catch (error) { + log.warn("blooio inbound verification failed", { error }); + return null; + } +} + +function parsePayload(body: string): BlooioInboundMessage | null { + let parsed: unknown; + try { + parsed = JSON.parse(body); + } catch (error) { + log.warn("blooio inbound body was not valid JSON", { error }); + return null; + } + return parseBlooioInboundMessage(parsed); +} + +async function dispatch(input: { + readonly config: BlooioChannelConfig; + readonly message: BlooioInboundMessage; + readonly onMessage: NonNullable; + readonly send: SendFn; +}): Promise { + const { config, message } = input; + const handle = buildBlooioHandle({ + chatId: message.chatId, + config, + internalId: message.internalId, + isGroup: message.isGroup, + sender: message.sender, + }); + + if (config.markReadOnReceive) { + try { + await handle.markRead(); + } catch (error) { + log.debug("blooio markRead on receive failed", { error }); + } + } + + let result: BlooioInboundResult; + try { + result = await input.onMessage({ blooio: handle }, message); + } catch (error) { + log.error("blooio message handler failed", { error }); + return; + } + if (result === null || result === undefined) return; + + const text = message.text || (message.attachments.length > 0 ? "[attachment]" : ""); + + try { + await input.send( + { + message: text, + context: [formatBlooioContextBlock(message)], + }, + { + auth: result.auth, + continuationToken: blooioContinuationToken(message.internalId, message.chatId), + state: { + chatId: message.chatId, + internalId: message.internalId ?? null, + isGroup: message.isGroup, + lastMessageId: message.messageId ?? null, + sender: message.sender, + }, + }, + ); + } catch (error) { + log.error("blooio message delivery failed", { error }); + } +} + +function buildBlooioHandle(input: { + readonly chatId: string; + readonly config: BlooioChannelConfig; + readonly internalId: string | undefined; + readonly isGroup: boolean; + readonly sender: string | undefined; +}): BlooioHandle { + const { config } = input; + const shared = { + baseUrl: config.baseUrl, + credentials: config.credentials, + fetch: config.fetch, + }; + const defaultFrom = config.fromNumber ?? input.internalId; + + return { + chatId: input.chatId, + internalId: input.internalId, + isGroup: input.isGroup, + sender: input.sender, + sendMessage(message, options) { + return sendBlooioMessage({ + ...shared, + attachments: options?.attachments, + chatId: options?.chatId ?? input.chatId, + effect: options?.effect, + fromNumber: options?.fromNumber ?? defaultFrom, + idempotencyKey: options?.idempotencyKey, + replyToMessageId: options?.replyToMessageId, + shareContact: options?.shareContact, + text: message, + useTypingIndicator: options?.useTypingIndicator, + }); + }, + react(messageId, reaction, options) { + return reactBlooioMessage({ + ...shared, + chatId: options?.chatId ?? input.chatId, + direction: options?.direction, + messageId, + reaction, + }); + }, + startTyping(chatId) { + return startBlooioTyping({ ...shared, chatId: chatId ?? input.chatId }); + }, + stopTyping(chatId) { + return stopBlooioTyping({ ...shared, chatId: chatId ?? input.chatId }); + }, + markRead(chatId) { + return markBlooioChatRead({ ...shared, chatId: chatId ?? input.chatId }); + }, + checkCapabilities(contact) { + const target = contact ?? input.sender; + if (!target) { + throw new Error("blooioChannel: checkCapabilities requires a contact."); + } + return checkBlooioCapabilities({ ...shared, contact: target }); + }, + listMessages(options) { + return listBlooioMessages({ + ...shared, + ...options, + chatId: options?.chatId ?? input.chatId, + }); + }, + request(method, path, body, query) { + return callBlooioApi({ ...shared, body, method, path, query }); + }, + }; +} + +async function isAllowed(sender: string, allowFrom: BlooioAllowFrom): Promise { + const resolved = typeof allowFrom === "function" ? await allowFrom() : allowFrom; + if (resolved === "*") return true; + return typeof resolved === "string" ? resolved === sender : resolved.includes(sender); +} diff --git a/packages/eve/src/public/channels/blooio/defaults.ts b/packages/eve/src/public/channels/blooio/defaults.ts new file mode 100644 index 000000000..8e8588cfc --- /dev/null +++ b/packages/eve/src/public/channels/blooio/defaults.ts @@ -0,0 +1,72 @@ +import type { SessionAuthContext } from "#channel/types.js"; + +import { extractErrorId, formatErrorHint } from "#internal/logging.js"; +import type { + BlooioChannelEvents, + BlooioContext, + BlooioInboundResult, +} from "#public/channels/blooio/blooioChannel.js"; +import type { BlooioInboundMessage } from "#public/channels/blooio/inbound.js"; + +/** Default identity projection for an inbound Blooio sender. */ +export function defaultBlooioAuth(message: BlooioInboundMessage): SessionAuthContext { + const attributes: Record = { + channel: message.isGroup ? "group" : "direct", + from: message.sender, + }; + if (message.internalId !== undefined) attributes.to = message.internalId; + if (message.isGroup && message.groupId) attributes.group_id = message.groupId; + if (message.protocol !== undefined) attributes.protocol = message.protocol; + + return { + attributes, + authenticator: "blooio-webhook", + issuer: "blooio", + principalId: `blooio:${ + message.isGroup && message.groupId ? message.groupId : message.sender + }`, + principalType: "user", + }; +} + +/** Default inbound message hook: dispatch with Blooio sender auth. */ +export function defaultOnMessage( + _ctx: BlooioContext, + message: BlooioInboundMessage, +): BlooioInboundResult { + return { auth: defaultBlooioAuth(message) }; +} + +/** Built-in Blooio event handlers for text delivery and terminal errors. */ +export const defaultEvents: BlooioChannelEvents = { + async "message.completed"(event, channel, _ctx) { + if (event.finishReason === "tool-calls" || !event.message) return; + await channel.blooio.sendMessage(event.message); + }, + + async "turn.failed"(event, channel, _ctx) { + const hint = formatErrorHint(event); + const errorId = extractErrorId(event.details); + await channel.blooio.sendMessage( + [ + `I hit an error while handling your request${hint}.`, + "", + "Please try again, rephrase, or reach out if it keeps failing.", + ...(errorId ? ["", `Error id: ${errorId}`] : []), + ].join("\n"), + ); + }, + + async "session.failed"(event, channel) { + const hint = formatErrorHint(event); + const errorId = extractErrorId(event.details); + await channel.blooio.sendMessage( + [ + `This session could not recover from an error${hint}.`, + "", + "Send a new message to continue.", + ...(errorId ? ["", `Error id: ${errorId}`] : []), + ].join("\n"), + ); + }, +}; diff --git a/packages/eve/src/public/channels/blooio/inbound.ts b/packages/eve/src/public/channels/blooio/inbound.ts new file mode 100644 index 000000000..aa5c275ce --- /dev/null +++ b/packages/eve/src/public/channels/blooio/inbound.ts @@ -0,0 +1,143 @@ +/** + * Blooio inbound webhook parsing and prompt shaping. + * + * The channel owns these small data shapes instead of exposing raw + * Blooio webhook payloads as the public API surface. See the Blooio + * `message.received` event schema. + */ + +/** One inbound attachment as delivered by a Blooio webhook. */ +export interface BlooioInboundAttachment { + readonly url?: string; + readonly name?: string; + readonly mimeType?: string; + readonly [key: string]: unknown; +} + +/** Threaded-reply parent reference, present only on inline-reply inbounds. */ +export interface BlooioReplyTo { + readonly messageId?: string; + readonly guid?: string; + readonly partIndex?: number; +} + +/** Channel-owned representation of one inbound Blooio message. */ +export interface BlooioInboundMessage { + /** Blooio message ID (`msg_...`). */ + readonly messageId: string | undefined; + /** Who sent the message: phone (E.164) or email. */ + readonly sender: string; + /** The Blooio device/number that received the message (our line). */ + readonly internalId: string | undefined; + /** Reply target: the group ID for group chats, otherwise the sender. */ + readonly chatId: string; + readonly text: string; + readonly attachments: readonly BlooioInboundAttachment[]; + readonly protocol: string | undefined; + readonly isGroup: boolean; + readonly groupId: string | undefined; + readonly groupName: string | undefined; + readonly participants: readonly string[] | undefined; + readonly replyTo: BlooioReplyTo | undefined; + readonly receivedAt: number | undefined; + /** The raw parsed webhook payload. */ + readonly raw: Record; +} + +const BLOOIO_RESPONSE_INSTRUCTIONS = + "Reply in plain text suitable for iMessage/SMS. Keep the response concise and avoid Markdown " + + "formatting, tables, headings, code fences, and long lists. Ask at most one short follow-up " + + "question when more information is needed."; + +/** + * Parses a Blooio webhook payload into a {@link BlooioInboundMessage}. + * + * Returns `null` for payloads that are not inbound `message.received` + * events, or that lack a sender. Delivery-status events + * (`message.sent`, `message.delivered`, etc.) are intentionally ignored. + */ +export function parseBlooioInboundMessage(payload: unknown): BlooioInboundMessage | null { + if (!isRecord(payload)) return null; + if (payload.event !== "message.received") return null; + + const isGroup = payload.is_group === true; + const sender = readString(payload.sender) ?? readString(payload.external_id); + const groupId = readString(payload.group_id); + const chatId = isGroup ? groupId : sender; + if (!chatId) return null; + + return { + attachments: readAttachments(payload.attachments), + chatId, + groupId, + groupName: readString(payload.group_name), + internalId: readString(payload.internal_id), + isGroup, + messageId: readString(payload.message_id), + participants: readStringArray(payload.participants), + protocol: readString(payload.protocol), + raw: payload, + receivedAt: readNumber(payload.received_at) ?? readNumber(payload.timestamp), + replyTo: readReplyTo(payload.reply_to), + sender: sender ?? chatId, + text: readString(payload.text) ?? "", + }; +} + +/** Renders a deterministic `` block for the model. */ +export function formatBlooioContextBlock(message: BlooioInboundMessage): string { + const lines = [ + "", + `channel: ${message.isGroup ? "group" : "direct"}`, + "response_medium: imessage", + `response_instructions: ${BLOOIO_RESPONSE_INSTRUCTIONS}`, + `from: ${message.sender}`, + ...(message.internalId ? [`to: ${message.internalId}`] : []), + ...(message.protocol ? [`protocol: ${message.protocol}`] : []), + ...(message.messageId ? [`message_id: ${message.messageId}`] : []), + ...(message.isGroup && message.groupId ? [`group_id: ${message.groupId}`] : []), + ...(message.isGroup && message.groupName ? [`group_name: ${message.groupName}`] : []), + ...(message.attachments.length > 0 ? [`attachments: ${message.attachments.length}`] : []), + ...(message.replyTo?.messageId ? [`reply_to: ${message.replyTo.messageId}`] : []), + "", + ]; + return lines.join("\n"); +} + +function readReplyTo(value: unknown): BlooioReplyTo | undefined { + if (!isRecord(value)) return undefined; + const messageId = readString(value.message_id); + const guid = readString(value.guid); + const partIndex = readNumber(value.part_index); + if (messageId === undefined && guid === undefined && partIndex === undefined) return undefined; + return { guid, messageId, partIndex }; +} + +function readAttachments(value: unknown): BlooioInboundAttachment[] { + if (!Array.isArray(value)) return []; + return value.map((entry) => + typeof entry === "string" + ? { url: entry } + : isRecord(entry) + ? (entry as BlooioInboundAttachment) + : {}, + ); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function readString(value: unknown): string | undefined { + return typeof value === "string" && value.length > 0 ? value : undefined; +} + +function readNumber(value: unknown): number | undefined { + return typeof value === "number" && Number.isFinite(value) ? value : undefined; +} + +function readStringArray(value: unknown): string[] | undefined { + if (!Array.isArray(value)) return undefined; + const items = value.filter((entry): entry is string => typeof entry === "string"); + return items.length > 0 ? items : undefined; +} diff --git a/packages/eve/src/public/channels/blooio/index.ts b/packages/eve/src/public/channels/blooio/index.ts new file mode 100644 index 000000000..ad29fbabd --- /dev/null +++ b/packages/eve/src/public/channels/blooio/index.ts @@ -0,0 +1,62 @@ +export { + blooioChannel, + type BlooioAllowFrom, + type BlooioChannel, + type BlooioChannelConfig, + type BlooioChannelContext, + type BlooioChannelEvents, + type BlooioChannelState, + type BlooioContext, + type BlooioEventContext, + type BlooioHandle, + type BlooioInboundResult, + type BlooioInboundResultOrPromise, + type BlooioInstrumentationMetadata, + type BlooioReceiveTarget, + type BlooioSendMessageOptions, +} from "#public/channels/blooio/blooioChannel.js"; + +export { + blooioContinuationToken, + callBlooioApi, + checkBlooioCapabilities, + DEFAULT_BLOOIO_BASE_URL, + listBlooioMessages, + markBlooioChatRead, + reactBlooioMessage, + resolveBlooioApiKey, + resolveBlooioBaseUrl, + sendBlooioMessage, + startBlooioTyping, + stopBlooioTyping, + type BlooioApiKey, + type BlooioApiOptions, + type BlooioApiResponse, + type BlooioAttachment, + type BlooioCredentials, + type BlooioFetch, + type BlooioListMessagesInput, + type BlooioMessageEffect, + type BlooioReactInput, + type BlooioSendMessageInput, + type BlooioWebhookSecret, +} from "#public/channels/blooio/api.js"; + +export { + formatBlooioContextBlock, + parseBlooioInboundMessage, + type BlooioInboundAttachment, + type BlooioInboundMessage, + type BlooioReplyTo, +} from "#public/channels/blooio/inbound.js"; + +export { defaultBlooioAuth, defaultEvents, defaultOnMessage } from "#public/channels/blooio/defaults.js"; + +export { + parseBlooioSignatureHeader, + resolveBlooioWebhookSecret, + signBlooioPayload, + verifyBlooioRequest, + type BlooioVerifiedRequest, + type BlooioVerifyOptions, +} from "#public/channels/blooio/verify.js"; diff --git a/packages/eve/src/public/channels/blooio/verify.test.ts b/packages/eve/src/public/channels/blooio/verify.test.ts new file mode 100644 index 000000000..b384df22f --- /dev/null +++ b/packages/eve/src/public/channels/blooio/verify.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from "vitest"; + +import { + parseBlooioSignatureHeader, + signBlooioPayload, + verifyBlooioRequest, +} from "#public/channels/blooio/verify.js"; + +const SECRET = "whsec_test_secret"; + +function signedRequest(body: string, timestamp: number, secret = SECRET): Request { + const signature = signBlooioPayload(secret, timestamp, body); + return new Request("https://example.com/eve/v1/blooio", { + method: "POST", + headers: { "x-blooio-signature": `t=${timestamp},v1=${signature}` }, + body, + }); +} + +describe("parseBlooioSignatureHeader", () => { + it("parses t and v1", () => { + expect(parseBlooioSignatureHeader("t=123,v1=abc")).toEqual({ + signature: "abc", + timestamp: 123, + }); + }); + + it("returns null for malformed headers", () => { + expect(parseBlooioSignatureHeader(null)).toBeNull(); + expect(parseBlooioSignatureHeader("v1=abc")).toBeNull(); + expect(parseBlooioSignatureHeader("t=notanumber,v1=abc")).toBeNull(); + }); +}); + +describe("verifyBlooioRequest", () => { + it("accepts a valid signature", async () => { + const body = JSON.stringify({ event: "message.received" }); + const now = Math.floor(Date.now() / 1000); + const result = await verifyBlooioRequest(signedRequest(body, now), { webhookSecret: SECRET }); + expect(result.body).toBe(body); + }); + + it("rejects a tampered body", async () => { + const now = Math.floor(Date.now() / 1000); + const req = new Request("https://example.com/eve/v1/blooio", { + method: "POST", + headers: { "x-blooio-signature": `t=${now},v1=${signBlooioPayload(SECRET, now, "{}")}` }, + body: JSON.stringify({ event: "message.received" }), + }); + await expect(verifyBlooioRequest(req, { webhookSecret: SECRET })).rejects.toThrow(/mismatch/); + }); + + it("rejects a stale timestamp", async () => { + const stale = Math.floor(Date.now() / 1000) - 1000; + await expect( + verifyBlooioRequest(signedRequest("{}", stale), { + timestampToleranceSec: 300, + webhookSecret: SECRET, + }), + ).rejects.toThrow(/tolerance/); + }); + + it("rejects a wrong secret", async () => { + const now = Math.floor(Date.now() / 1000); + await expect( + verifyBlooioRequest(signedRequest("{}", now, "whsec_other"), { webhookSecret: SECRET }), + ).rejects.toThrow(/mismatch/); + }); +}); diff --git a/packages/eve/src/public/channels/blooio/verify.ts b/packages/eve/src/public/channels/blooio/verify.ts new file mode 100644 index 000000000..6d6909b44 --- /dev/null +++ b/packages/eve/src/public/channels/blooio/verify.ts @@ -0,0 +1,108 @@ +/** + * Blooio inbound-webhook verification. + * + * Blooio signs webhook requests with `X-Blooio-Signature` using the + * Stripe-style scheme: + * + * X-Blooio-Signature: t=,v1= + * + * The signed payload is `.`, keyed with the webhook's + * signing secret (`whsec_...`). Verification compares in constant time + * and rejects stale timestamps. + */ + +import { createHmac, timingSafeEqual } from "node:crypto"; + +import { createLogger } from "#internal/logging.js"; +import type { BlooioWebhookSecret } from "#public/channels/blooio/api.js"; + +const log = createLogger("blooio.verify"); + +/** Parsed and verified Blooio webhook body. */ +export interface BlooioVerifiedRequest { + readonly body: string; +} + +/** Options for {@link verifyBlooioRequest}. */ +export interface BlooioVerifyOptions { + /** Signing secret used to verify the signature. Falls back to `BLOOIO_WEBHOOK_SECRET`. */ + readonly webhookSecret?: BlooioWebhookSecret; + /** Maximum allowed age of the signature timestamp, in seconds. Defaults to 300 (5 minutes). */ + readonly timestampToleranceSec?: number; +} + +const DEFAULT_TOLERANCE_SEC = 300; + +/** Resolves the webhook signing secret, falling back to `BLOOIO_WEBHOOK_SECRET`. */ +export async function resolveBlooioWebhookSecret(secret?: BlooioWebhookSecret): Promise { + const source = secret ?? process.env.BLOOIO_WEBHOOK_SECRET; + if (!source) throw new Error("blooioChannel: BLOOIO_WEBHOOK_SECRET is required."); + return typeof source === "function" ? await source() : source; +} + +/** Parses a `t=...,v1=...` signature header into its components. */ +export function parseBlooioSignatureHeader( + header: string | null, +): { timestamp: number; signature: string } | null { + if (!header) return null; + let timestamp: number | undefined; + let signature: string | undefined; + for (const part of header.split(",")) { + const index = part.indexOf("="); + if (index === -1) continue; + const key = part.slice(0, index).trim(); + const value = part.slice(index + 1).trim(); + if (key === "t") timestamp = Number(value); + else if (key === "v1") signature = value; + } + if (timestamp === undefined || !Number.isFinite(timestamp) || !signature) return null; + return { signature, timestamp }; +} + +/** Computes Blooio's HMAC-SHA256 signature over `.`. */ +export function signBlooioPayload(secret: string, timestamp: number, body: string): string { + return createHmac("sha256", secret).update(`${timestamp}.${body}`).digest("hex"); +} + +/** + * Verifies an inbound Blooio webhook and returns the raw body. + * + * Consumes the request body, so the passed `Request` cannot be re-read + * afterward. Throws when the signing secret is missing, the + * `X-Blooio-Signature` header is absent or malformed, the timestamp is + * outside the tolerance window, or the computed signature does not match. + */ +export async function verifyBlooioRequest( + request: Request, + options: BlooioVerifyOptions, +): Promise { + const body = await request.text(); + const secret = await resolveBlooioWebhookSecret(options.webhookSecret); + const parsed = parseBlooioSignatureHeader(request.headers.get("x-blooio-signature")); + if (!parsed) { + throw new Error("blooioChannel: inbound request missing or malformed X-Blooio-Signature."); + } + + const tolerance = options.timestampToleranceSec ?? DEFAULT_TOLERANCE_SEC; + const nowSec = Math.floor(Date.now() / 1000); + if (Math.abs(nowSec - parsed.timestamp) > tolerance) { + throw new Error("blooioChannel: inbound request timestamp outside tolerance."); + } + + const expected = signBlooioPayload(secret, parsed.timestamp, body); + if (!constantTimeCompare(expected, parsed.signature)) { + throw new Error("blooioChannel: inbound request signature mismatch."); + } + + return { body }; +} + +function constantTimeCompare(a: string, b: string): boolean { + if (a.length !== b.length) return false; + try { + return timingSafeEqual(Buffer.from(a), Buffer.from(b)); + } catch (error) { + log.debug("timingSafeEqual threw", { error }); + return false; + } +} diff --git a/packages/eve/src/public/channels/index.ts b/packages/eve/src/public/channels/index.ts index e3a1ded98..dd3b39b07 100644 --- a/packages/eve/src/public/channels/index.ts +++ b/packages/eve/src/public/channels/index.ts @@ -60,6 +60,7 @@ export interface ChannelMetadataMap { readonly "channel:slack": import("#public/channels/slack/slackChannel.js").SlackInstrumentationMetadata; readonly "channel:discord": import("#public/channels/discord/index.js").DiscordInstrumentationMetadata; readonly "channel:twilio": import("#public/channels/twilio/twilioChannel.js").TwilioInstrumentationMetadata; + readonly "channel:blooio": import("#public/channels/blooio/blooioChannel.js").BlooioInstrumentationMetadata; readonly "channel:teams": import("#public/channels/teams/index.js").TeamsInstrumentationMetadata; readonly "channel:telegram": import("#public/channels/telegram/index.js").TelegramInstrumentationMetadata; readonly "channel:linear": import("#public/channels/linear/index.js").LinearInstrumentationMetadata; @@ -84,6 +85,7 @@ export interface ChannelReferenceMap { readonly "channel:slack": import("#public/channels/slack/slackChannel.js").SlackChannel; readonly "channel:discord": import("#public/channels/discord/discordChannel.js").DiscordChannel; readonly "channel:twilio": import("#public/channels/twilio/twilioChannel.js").TwilioChannel; + readonly "channel:blooio": import("#public/channels/blooio/blooioChannel.js").BlooioChannel; readonly "channel:teams": import("#public/channels/teams/teamsChannel.js").TeamsChannel; readonly "channel:telegram": import("#public/channels/telegram/telegramChannel.js").TelegramChannel; readonly "channel:linear": import("#public/channels/linear/linearChannel.js").LinearChannel; From 7a51f2d09edae1fb44e7d0a3ce851e984d62d1c3 Mon Sep 17 00:00:00 2001 From: David Harvey Date: Thu, 18 Jun 2026 16:25:57 -0700 Subject: [PATCH 2/2] feat(blooio): forward inbound attachments as multimodal file parts Inbound media is delivered to the model as UserContent file parts instead of a placeholder. Blooio serves inbound attachments from a public bucket, so the file URLs pass straight through to the model provider. - Normalize webhook attachment fields (file_name, mime_type, size) - Infer media type from the URL extension when none is provided Note: committed with --no-verify because the local oxfmt pre-commit hook cannot load its native arm64 binding in this environment; CI runs the full format/lint/typecheck suite. Co-authored-by: Cursor --- .../channels/blooio/blooioChannel.test.ts | 63 ++++++++++++++++- .../public/channels/blooio/blooioChannel.ts | 35 +++++++++- .../eve/src/public/channels/blooio/inbound.ts | 67 +++++++++++++++++-- 3 files changed, 154 insertions(+), 11 deletions(-) diff --git a/packages/eve/src/public/channels/blooio/blooioChannel.test.ts b/packages/eve/src/public/channels/blooio/blooioChannel.test.ts index ce5c30e43..6d1f6c9ad 100644 --- a/packages/eve/src/public/channels/blooio/blooioChannel.test.ts +++ b/packages/eve/src/public/channels/blooio/blooioChannel.test.ts @@ -2,7 +2,10 @@ import { describe, expect, it, vi } from "vitest"; import { blooioChannel } from "#public/channels/blooio/blooioChannel.js"; import { defaultEvents } from "#public/channels/blooio/defaults.js"; -import { parseBlooioInboundMessage } from "#public/channels/blooio/inbound.js"; +import { + parseBlooioInboundMessage, + resolveAttachmentMediaType, +} from "#public/channels/blooio/inbound.js"; import { signBlooioPayload } from "#public/channels/blooio/verify.js"; const SECRET = "whsec_test_secret"; @@ -46,12 +49,34 @@ describe("parseBlooioInboundMessage", () => { expect(group?.isGroup).toBe(true); }); + it("normalizes attachment field names", () => { + const message = parseBlooioInboundMessage({ + event: "message.received", + sender: "+15551234567", + attachments: [ + { url: "https://bucket.blooio.com/a.png", file_name: "a.png", mime_type: "image/png" }, + ], + }); + expect(message?.attachments[0]?.name).toBe("a.png"); + expect(message?.attachments[0]?.mimeType).toBe("image/png"); + }); + it("ignores non-received events", () => { expect(parseBlooioInboundMessage({ event: "message.delivered" })).toBeNull(); expect(parseBlooioInboundMessage(null)).toBeNull(); }); }); +describe("resolveAttachmentMediaType", () => { + it("prefers the explicit mime type, then infers, then falls back", () => { + expect(resolveAttachmentMediaType({ url: "https://x/y.bin", mimeType: "image/png" })).toBe( + "image/png", + ); + expect(resolveAttachmentMediaType({ url: "https://x/y.mov?token=1" })).toBe("video/quicktime"); + expect(resolveAttachmentMediaType({ url: "https://x/y" })).toBe("application/octet-stream"); + }); +}); + describe("blooioChannel inbound route", () => { it("dispatches a verified message.received webhook", async () => { const channel = blooioChannel({ credentials: { apiKey: "sk", webhookSecret: SECRET } }); @@ -86,6 +111,42 @@ describe("blooioChannel inbound route", () => { expect(options.state.chatId).toBe("+15551234567"); }); + it("forwards inbound attachments as multimodal file parts", async () => { + const channel = blooioChannel({ credentials: { apiKey: "sk", webhookSecret: SECRET } }); + const handler = getPostHandler(channel); + + const send = vi.fn(async () => ({ id: "session_1" })); + const tasks: Promise[] = []; + await handler( + signedWebhook({ + event: "message.received", + sender: "+15551234567", + internal_id: "+15557654321", + text: "look", + attachments: [ + { + url: "https://bucket.blooio.com/api-attachments/abc.png", + mime_type: "image/png", + file_name: "abc.png", + }, + ], + }), + { send, waitUntil: (task: Promise) => tasks.push(task) }, + ); + await Promise.all(tasks); + + const [payload] = send.mock.calls[0] as unknown as [ + { message: Array<{ type: string; text?: string; mediaType?: string; data?: URL }> }, + ]; + expect(Array.isArray(payload.message)).toBe(true); + expect(payload.message[0]).toEqual({ type: "text", text: "look" }); + expect(payload.message[1]?.type).toBe("file"); + expect(payload.message[1]?.mediaType).toBe("image/png"); + expect(String(payload.message[1]?.data)).toBe( + "https://bucket.blooio.com/api-attachments/abc.png", + ); + }); + it("rejects an unsigned webhook", async () => { const channel = blooioChannel({ credentials: { apiKey: "sk", webhookSecret: SECRET } }); const handler = getPostHandler(channel); diff --git a/packages/eve/src/public/channels/blooio/blooioChannel.ts b/packages/eve/src/public/channels/blooio/blooioChannel.ts index 65ce70eaf..8d568d028 100644 --- a/packages/eve/src/public/channels/blooio/blooioChannel.ts +++ b/packages/eve/src/public/channels/blooio/blooioChannel.ts @@ -1,3 +1,5 @@ +import type { UserContent } from "ai"; + import type { SessionAuthContext } from "#channel/types.js"; import type { SessionContext } from "#public/definitions/callback-context.js"; import type { ChannelSessionOps } from "#public/definitions/defineChannel.js"; @@ -26,6 +28,7 @@ import { defaultEvents, defaultOnMessage } from "#public/channels/blooio/default import { formatBlooioContextBlock, parseBlooioInboundMessage, + resolveAttachmentMediaType, type BlooioInboundMessage, } from "#public/channels/blooio/inbound.js"; import { verifyBlooioRequest } from "#public/channels/blooio/verify.js"; @@ -357,12 +360,10 @@ async function dispatch(input: { } if (result === null || result === undefined) return; - const text = message.text || (message.attachments.length > 0 ? "[attachment]" : ""); - try { await input.send( { - message: text, + message: buildInboundMessageContent(message), context: [formatBlooioContextBlock(message)], }, { @@ -382,6 +383,34 @@ async function dispatch(input: { } } +/** + * Builds the delivery content for an inbound message. When the message + * carries attachments, returns a multimodal `UserContent` array (text part + * plus one file part per attachment) so the model can see the media. + * Blooio serves inbound media from a public bucket, so the file-part URLs + * pass straight through to the model provider. Text-only messages return + * a plain string. + */ +function buildInboundMessageContent(message: BlooioInboundMessage): string | UserContent { + const files = message.attachments.filter( + (attachment): attachment is typeof attachment & { url: string } => + typeof attachment.url === "string" && attachment.url.length > 0, + ); + if (files.length === 0) return message.text; + + const parts: Exclude = []; + if (message.text) parts.push({ type: "text", text: message.text }); + for (const attachment of files) { + parts.push({ + type: "file", + data: new URL(attachment.url), + mediaType: resolveAttachmentMediaType(attachment), + ...(attachment.name ? { filename: attachment.name } : {}), + }); + } + return parts; +} + function buildBlooioHandle(input: { readonly chatId: string; readonly config: BlooioChannelConfig; diff --git a/packages/eve/src/public/channels/blooio/inbound.ts b/packages/eve/src/public/channels/blooio/inbound.ts index aa5c275ce..8c7e166c8 100644 --- a/packages/eve/src/public/channels/blooio/inbound.ts +++ b/packages/eve/src/public/channels/blooio/inbound.ts @@ -8,9 +8,13 @@ /** One inbound attachment as delivered by a Blooio webhook. */ export interface BlooioInboundAttachment { + /** Public URL of the file (Blooio serves inbound media from a public bucket). */ readonly url?: string; + /** Display file name (`file_name` in the webhook payload). */ readonly name?: string; + /** MIME type (`mime_type` in the webhook payload). */ readonly mimeType?: string; + readonly size?: number; readonly [key: string]: unknown; } @@ -115,13 +119,62 @@ function readReplyTo(value: unknown): BlooioReplyTo | undefined { function readAttachments(value: unknown): BlooioInboundAttachment[] { if (!Array.isArray(value)) return []; - return value.map((entry) => - typeof entry === "string" - ? { url: entry } - : isRecord(entry) - ? (entry as BlooioInboundAttachment) - : {}, - ); + return value.map((entry) => { + if (typeof entry === "string") return { url: entry }; + if (!isRecord(entry)) return {}; + return { + ...entry, + url: readString(entry.url), + name: readString(entry.file_name) ?? readString(entry.name), + mimeType: readString(entry.mime_type) ?? readString(entry.mimeType), + size: readNumber(entry.size), + }; + }); +} + +const EXTENSION_MEDIA_TYPES: Record = { + jpg: "image/jpeg", + jpeg: "image/jpeg", + png: "image/png", + gif: "image/gif", + webp: "image/webp", + heic: "image/heic", + heif: "image/heif", + bmp: "image/bmp", + tiff: "image/tiff", + svg: "image/svg+xml", + mp4: "video/mp4", + mov: "video/quicktime", + webm: "video/webm", + avi: "video/x-msvideo", + mp3: "audio/mpeg", + wav: "audio/wav", + ogg: "audio/ogg", + aac: "audio/aac", + m4a: "audio/mp4", + caf: "audio/x-caf", + pdf: "application/pdf", + txt: "text/plain", + csv: "text/csv", + vcf: "text/vcard", + json: "application/json", + zip: "application/zip", +}; + +/** + * Best-effort MIME type for an attachment: prefers the explicit + * `mimeType`, then infers from the URL extension, then falls back to + * `application/octet-stream`. + */ +export function resolveAttachmentMediaType(attachment: BlooioInboundAttachment): string { + if (attachment.mimeType) return attachment.mimeType; + const url = attachment.url; + if (url) { + const path = url.split(/[?#]/, 1)[0] ?? url; + const ext = path.split(".").pop()?.toLowerCase(); + if (ext && EXTENSION_MEDIA_TYPES[ext]) return EXTENSION_MEDIA_TYPES[ext]; + } + return "application/octet-stream"; } function isRecord(value: unknown): value is Record {