From 681266cfdabffcde880defcf56abee75a6d7023c Mon Sep 17 00:00:00 2001 From: owenkephart Date: Tue, 18 Aug 2026 23:59:05 +0000 Subject: [PATCH 1/8] feat(eve): add native Sendblue channel Signed-off-by: owenkephart --- .changeset/sendblue-native-channel.md | 5 + apps/docs/lib/integrations/data.ts | 45 +---- apps/docs/registry.json | 29 ++- .../registry/channels/chat-sdk-sendblue.ts | 25 +-- .../docs/scripts/validate-channel-registry.ts | 9 +- docs/channels/meta.json | 1 + docs/channels/overview.mdx | 3 +- docs/channels/sendblue.mdx | 46 +++++ packages/eve-catalog/src/index.ts | 4 +- packages/eve/package.json | 6 + .../vendor-compiled/chat-adapter-sendblue.mjs | 9 + .../eve/scripts/vendor-compiled/index.mjs | 2 + .../channels/sendblue/inboundContent.test.ts | 24 +++ .../channels/sendblue/inboundContent.ts | 11 ++ .../eve/src/public/channels/sendblue/index.ts | 8 + .../channels/sendblue/sendblueChannel.test.ts | 53 ++++++ .../channels/sendblue/sendblueChannel.ts | 145 +++++++++++++++ .../eve/src/setup/integrations/registry.ts | 2 + .../integrations/sendblue/connect.test.ts | 60 ++++++ .../setup/integrations/sendblue/connect.ts | 99 ++++++++++ .../setup/integrations/sendblue/setup.test.ts | 98 ++++++++++ .../src/setup/integrations/sendblue/setup.ts | 173 ++++++++++++++++++ pnpm-lock.yaml | 8 + 23 files changed, 779 insertions(+), 86 deletions(-) create mode 100644 .changeset/sendblue-native-channel.md create mode 100644 docs/channels/sendblue.mdx create mode 100644 packages/eve/scripts/vendor-compiled/chat-adapter-sendblue.mjs create mode 100644 packages/eve/src/public/channels/sendblue/inboundContent.test.ts create mode 100644 packages/eve/src/public/channels/sendblue/inboundContent.ts create mode 100644 packages/eve/src/public/channels/sendblue/index.ts create mode 100644 packages/eve/src/public/channels/sendblue/sendblueChannel.test.ts create mode 100644 packages/eve/src/public/channels/sendblue/sendblueChannel.ts create mode 100644 packages/eve/src/setup/integrations/sendblue/connect.test.ts create mode 100644 packages/eve/src/setup/integrations/sendblue/connect.ts create mode 100644 packages/eve/src/setup/integrations/sendblue/setup.test.ts create mode 100644 packages/eve/src/setup/integrations/sendblue/setup.ts diff --git a/.changeset/sendblue-native-channel.md b/.changeset/sendblue-native-channel.md new file mode 100644 index 0000000000..5162d219c4 --- /dev/null +++ b/.changeset/sendblue-native-channel.md @@ -0,0 +1,5 @@ +--- +"eve": patch +--- + +Add a first-class Sendblue channel with managed Vercel Connect and portable credential setup. diff --git a/apps/docs/lib/integrations/data.ts b/apps/docs/lib/integrations/data.ts index 01a20247c9..72495a329c 100644 --- a/apps/docs/lib/integrations/data.ts +++ b/apps/docs/lib/integrations/data.ts @@ -667,48 +667,17 @@ export default channel; See the [Velt adapter documentation](https://chat-sdk.dev/adapters/vendor-official/velt) for supported events, capabilities, and credentials.`, configure: `Create a Velt bot user and webhook, set \`VELT_API_KEY\` and \`VELT_WEBHOOK_SECRET\`, then send comment events to \`/eve/v1/velt\`. The adapter maps documents to channels, annotations to threads, and comments to messages. See the [Chat SDK channel docs](/docs/channels/chat-sdk) for eve session dispatch, state, streaming, and human-in-the-loop behavior.`, }, - "chat-sdk-sendblue": { + sendblue: { logo: "sendblue", - docsHref: "/docs/channels/chat-sdk", - badge: "Provider official", - keywords: ["chat sdk", "sendblue", "imessage", "sms", "rcs", "tapbacks", "phone"], - install: `Add this Chat SDK channel from eve's registry. This writes \`agent/channels/sendblue.ts\` and installs Chat SDK and its adapter dependencies: + docsHref: "/docs/channels/sendblue", + keywords: ["sendblue", "imessage", "sms", "rcs", "tapbacks", "phone"], + install: `Add Sendblue from eve's registry, then choose Vercel Connect or portable credentials: \`\`\`bash -eve add channel/chat-sdk-sendblue +eve add channel/sendblue \`\`\``, - quickStart: `Create \`agent/channels/sendblue.ts\`: - -\`\`\`ts -// agent/channels/sendblue.ts -import { createSendblueAdapter } from "chat-adapter-sendblue"; -import { createMemoryState } from "@chat-adapter/state-memory"; -import type { Message, Thread } from "chat"; -import { chatSdkChannel } from "eve/channels/chat-sdk"; - -export const { bot, channel, send } = chatSdkChannel({ - userName: "My Agent", - adapters: { - sendblue: createSendblueAdapter(), - }, - state: createMemoryState(), - streaming: false, -}); - -bot.onNewMention(async (thread: Thread, message: Message) => { - await thread.subscribe(); - await send(message.text, { thread }); -}); - -bot.onSubscribedMessage(async (thread: Thread, message: Message) => { - await send(message.text, { thread }); -}); - -export default channel; -\`\`\` - -See the [Sendblue adapter documentation](https://chat-sdk.dev/adapters/vendor-official/sendblue) for supported events, capabilities, and credentials.`, - configure: `Set \`SENDBLUE_API_KEY\`, \`SENDBLUE_API_SECRET\`, and \`SENDBLUE_FROM_NUMBER\`, then point Sendblue webhooks at \`/eve/v1/sendblue\`. The adapter also supports tapbacks, typing indicators, delivery callbacks, and number lookup. See the [Chat SDK channel docs](/docs/channels/chat-sdk) for eve session dispatch, state, streaming, and human-in-the-loop behavior.`, + quickStart: `The guided setup writes \`agent/channels/sendblue.ts\`. With Vercel Connect, it provisions a managed Sendblue account and phone number, routes verified webhooks to \`/eve/v1/sendblue\`, and resolves short-lived credentials at runtime.`, + configure: `For a host without Vercel Connect, choose **Use portable credentials**. After deploying, configure Sendblue webhooks for \`/eve/v1/sendblue\` and set \`SENDBLUE_API_KEY\`, \`SENDBLUE_API_SECRET\`, \`SENDBLUE_FROM_NUMBER\`, and \`SENDBLUE_WEBHOOK_SECRET\` as encrypted environment variables.`, }, "chat-sdk-novu": { logo: "novu", diff --git a/apps/docs/registry.json b/apps/docs/registry.json index 1015524232..0625cf27a0 100644 --- a/apps/docs/registry.json +++ b/apps/docs/registry.json @@ -1800,27 +1800,22 @@ } }, { - "name": "channel/chat-sdk-sendblue", + "name": "channel/sendblue", "type": "registry:item", "title": "Sendblue", - "description": "Send and receive iMessage, SMS, and RCS through Sendblue.", - "dependencies": ["chat", "chat-adapter-sendblue", "@chat-adapter/state-memory"], - "envVars": { - "SENDBLUE_API_KEY": "", - "SENDBLUE_API_SECRET": "", - "SENDBLUE_FROM_NUMBER": "" - }, - "files": [ - { - "path": "registry/channels/chat-sdk-sendblue.ts", - "type": "registry:file", - "target": "agent/channels/sendblue.ts" - } - ], + "description": "Connect an eve agent to iMessage, SMS, and RCS through Sendblue with guided setup.", + "dependencies": ["@vercel/connect@>=0.8.0"], "meta": { "eve": { - "docs": "/integrations/chat-sdk-sendblue#configure", - "implementation": "chat-sdk" + "setup": { + "command": "eve", + "package": "eve", + "bin": "eve", + "args": ["integration", "setup", "sendblue"] + }, + "requires": ">=0.31.4", + "docs": "/docs/channels/sendblue", + "implementation": "native" } } }, diff --git a/apps/docs/registry/channels/chat-sdk-sendblue.ts b/apps/docs/registry/channels/chat-sdk-sendblue.ts index 6fb1e8bfe4..e24ca2ffa7 100644 --- a/apps/docs/registry/channels/chat-sdk-sendblue.ts +++ b/apps/docs/registry/channels/chat-sdk-sendblue.ts @@ -1,24 +1 @@ -import { createSendblueAdapter } from "chat-adapter-sendblue"; -import { createMemoryState } from "@chat-adapter/state-memory"; -import type { Message, Thread } from "chat"; -import { chatSdkChannel } from "eve/channels/chat-sdk"; - -export const { bot, channel, send } = chatSdkChannel({ - userName: "My Agent", - adapters: { - sendblue: createSendblueAdapter(), - }, - state: createMemoryState(), - streaming: false, -}); - -bot.onNewMention(async (thread: Thread, message: Message) => { - await thread.subscribe(); - await send(message.text, { thread }); -}); - -bot.onSubscribedMessage(async (thread: Thread, message: Message) => { - await send(message.text, { thread }); -}); - -export default channel; +// Sendblue is scaffolded by `eve integration setup sendblue`. diff --git a/apps/docs/scripts/validate-channel-registry.ts b/apps/docs/scripts/validate-channel-registry.ts index a3b9866269..fd77e00fa2 100644 --- a/apps/docs/scripts/validate-channel-registry.ts +++ b/apps/docs/scripts/validate-channel-registry.ts @@ -39,6 +39,7 @@ const registrySlugsByCatalogSlug: Readonly> = { eve: "web", linq: "linq", photon: "photon-imessage", + sendblue: "sendblue", }; const setupKindsByCatalogSlug: Readonly> = { @@ -48,6 +49,7 @@ const setupKindsByCatalogSlug: Readonly> = { eve: "web", linq: "linq", photon: "photon", + sendblue: "sendblue", }; const adapterDependenciesByCatalogSlug: Readonly> = { @@ -57,7 +59,6 @@ const adapterDependenciesByCatalogSlug: Readonly> = { "chat-sdk-messenger": "@chat-adapter/messenger", "chat-sdk-zernio": "@zernio/chat-sdk-adapter", "chat-sdk-velt": "@veltdev/chat-sdk-adapter", - "chat-sdk-sendblue": "chat-adapter-sendblue", "chat-sdk-novu": "@novu/chat-sdk-adapter", "chat-sdk-liveblocks": "@liveblocks/chat-sdk-adapter", "chat-sdk-kapso": "@kapso/chat-adapter", @@ -76,7 +77,6 @@ const targetSlugsByCatalogSlug: Readonly> = { "chat-sdk-messenger": "messenger", "chat-sdk-zernio": "zernio", "chat-sdk-velt": "velt", - "chat-sdk-sendblue": "sendblue", "chat-sdk-novu": "novu", "chat-sdk-liveblocks": "liveblocks", "chat-sdk-kapso": "kapso", @@ -87,7 +87,7 @@ const targetSlugsByCatalogSlug: Readonly> = { "chat-sdk-resend": "resend", }; -const nonStreamingCatalogSlugs = new Set(["chat-sdk-sendblue"]); +const nonStreamingCatalogSlugs = new Set(); const docsRoot = join(import.meta.dirname, ".."); const registry = JSON.parse(await readFile(join(docsRoot, "registry.json"), "utf8")) as Registry; @@ -152,7 +152,8 @@ for (const [index, item] of items.entries()) { entry.slug === "linear-agent" || entry.slug === "eve" || entry.slug === "linq" || - entry.slug === "photon" + entry.slug === "photon" || + entry.slug === "sendblue" ) { const expectedArgs = [ "integration", diff --git a/docs/channels/meta.json b/docs/channels/meta.json index 0d17b68ddc..934122329d 100644 --- a/docs/channels/meta.json +++ b/docs/channels/meta.json @@ -8,6 +8,7 @@ "slack", "linq", "photon", + "sendblue", "discord", "teams", "telegram", diff --git a/docs/channels/overview.mdx b/docs/channels/overview.mdx index 5280d9c55e..60949a6f3b 100644 --- a/docs/channels/overview.mdx +++ b/docs/channels/overview.mdx @@ -47,7 +47,7 @@ agent/ intake.ts ``` -Install a channel from the registry with `eve add channel/`, such as `eve add channel/slack` or `eve add channel/web`. You can also author the file by hand. +Install a channel from the registry with `eve add channel/`, such as `eve add channel/sendblue`, `eve add channel/slack`, or `eve add channel/web`. You can also author the file by hand. ## The eve HTTP channel (default) @@ -75,6 +75,7 @@ eve's first-class channels use eve-owned runtimes for webhook handling, verifica | Slack mentions, DMs, buttons | [Slack](./slack) | | iMessage and SMS | [Linq](./linq) | | iMessage | [Photon](./photon) | +| iMessage, SMS, or RCS | [Sendblue](./sendblue) | | Discord slash commands, components | [Discord](./discord) | | Microsoft Teams messages + Adaptive Cards | [Teams](./teams) | | Telegram bot messages | [Telegram](./telegram) | diff --git a/docs/channels/sendblue.mdx b/docs/channels/sendblue.mdx new file mode 100644 index 0000000000..16beecc8e2 --- /dev/null +++ b/docs/channels/sendblue.mdx @@ -0,0 +1,46 @@ +--- +title: Sendblue +description: Connect an eve agent to iMessage, SMS, and RCS through Sendblue. +--- + +Use Sendblue to receive and reply to iMessage, SMS, and RCS conversations. Run +`eve add channel/sendblue` to add `agent/channels/sendblue.ts` through the guided +setup. + +Choose **Set up Vercel Connect** to provision a managed Sendblue account and +phone number. Connect stores the permanent Sendblue credentials, configures its +verified trigger at `/eve/v1/sendblue`, and issues short-lived credentials to +the deployed agent: + +```ts title="agent/channels/sendblue.ts" +import { connectSendblueChannel } from "@vercel/connect/eve"; +import { sendblueChannel } from "eve/channels/sendblue"; + +export default sendblueChannel({ + ...connectSendblueChannel("sendblue/my-agent"), +}); +``` + +The channel resolves a fresh Connect bearer token for each Sendblue API +operation. The provisioned `fromNumber` limits inbound events to that Sendblue +line, so unrelated account lines do not reach the agent. When no direct +webhook secret is configured, the channel verifies Connect-forwarded webhooks +with Vercel OIDC. + +## Other hosts + +Choose **Use portable credentials** during `eve add channel/sendblue` when the +agent is not deployed through Vercel Connect. eve writes the API key, secret, +and sending number to `.env.local`, then scaffolds a channel that reads them +from the environment. + +After deploying the agent: + +1. Configure Sendblue webhooks for `https:///eve/v1/sendblue`. +2. Configure a signing secret for the webhook in Sendblue. +3. Set `SENDBLUE_API_KEY`, `SENDBLUE_API_SECRET`, `SENDBLUE_FROM_NUMBER`, and + `SENDBLUE_WEBHOOK_SECRET` as encrypted environment variables on the host. + +For direct Sendblue webhooks, `SENDBLUE_WEBHOOK_SECRET` authenticates the +`sb-signing-secret` header. Use a `webhookVerifier` only when a trusted proxy +verifies inbound Sendblue events instead. diff --git a/packages/eve-catalog/src/index.ts b/packages/eve-catalog/src/index.ts index 5ff1dfb4d9..e3b8ed653d 100644 --- a/packages/eve-catalog/src/index.ts +++ b/packages/eve-catalog/src/index.ts @@ -193,10 +193,10 @@ export const INTEGRATIONS: readonly IntegrationEntry[] = [ surfaces: { scaffoldable: false, registry: true, gallery: true }, }, { - slug: "chat-sdk-sendblue", + slug: "sendblue", name: "Sendblue", kind: "channel", - tagline: "Send and receive iMessage, SMS, and RCS through Sendblue.", + tagline: "Send and receive iMessage, SMS, and RCS through Sendblue, with guided setup.", surfaces: { scaffoldable: false, registry: true, gallery: true }, }, { diff --git a/packages/eve/package.json b/packages/eve/package.json index 85b52baaca..b1b6269cfc 100644 --- a/packages/eve/package.json +++ b/packages/eve/package.json @@ -296,6 +296,11 @@ "import": "./dist/src/public/channels/linq/index.js", "default": "./dist/src/public/channels/linq/index.js" }, + "./channels/sendblue": { + "types": "./dist/src/public/channels/sendblue/index.d.ts", + "import": "./dist/src/public/channels/sendblue/index.js", + "default": "./dist/src/public/channels/sendblue/index.js" + }, "./channels/github": { "types": "./dist/src/public/channels/github/index.d.ts", "import": "./dist/src/public/channels/github/index.js", @@ -410,6 +415,7 @@ "ai": "catalog:", "autoevals": "0.0.132", "chat": "4.34.0", + "chat-adapter-sendblue": "0.2.0", "chokidar": "5.0.0", "commander": "14.0.3", "emulate": "0.6.0", diff --git a/packages/eve/scripts/vendor-compiled/chat-adapter-sendblue.mjs b/packages/eve/scripts/vendor-compiled/chat-adapter-sendblue.mjs new file mode 100644 index 0000000000..9d86d422fd --- /dev/null +++ b/packages/eve/scripts/vendor-compiled/chat-adapter-sendblue.mjs @@ -0,0 +1,9 @@ +import { createDeclarationCopier } from "./_shared.mjs"; + +export default { + packageName: "chat-adapter-sendblue", + compiledPath: "chat-adapter-sendblue", + copyDeclarations: createDeclarationCopier({ + rewrites: { chat: { kind: "vendored", compiledPath: "chat" } }, + }), +}; diff --git a/packages/eve/scripts/vendor-compiled/index.mjs b/packages/eve/scripts/vendor-compiled/index.mjs index 3177ce4b04..b0b8249264 100644 --- a/packages/eve/scripts/vendor-compiled/index.mjs +++ b/packages/eve/scripts/vendor-compiled/index.mjs @@ -13,6 +13,7 @@ import provider from "./@ai-sdk/provider.mjs"; import providerUtils from "./@ai-sdk/provider-utils.mjs"; import chatAdapterSlack from "./@chat-adapter/slack.mjs"; +import chatAdapterSendblue from "./chat-adapter-sendblue.mjs"; import chatAdapterStateMemory from "./@chat-adapter/state-memory.mjs"; import chatAdapterTwilio from "./@chat-adapter/twilio.mjs"; import photonChatAdapterIMessage from "./@photon-ai/chat-adapter-imessage.mjs"; @@ -57,6 +58,7 @@ export const MODULES = [ anthropic, chat, chatAdapterSlack, + chatAdapterSendblue, chatAdapterStateMemory, chatAdapterTwilio, chokidar, diff --git a/packages/eve/src/public/channels/sendblue/inboundContent.test.ts b/packages/eve/src/public/channels/sendblue/inboundContent.test.ts new file mode 100644 index 0000000000..fe104ec661 --- /dev/null +++ b/packages/eve/src/public/channels/sendblue/inboundContent.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vitest"; + +import { Message } from "#compiled/chat/index.js"; +import { sendblueInboundContent } from "#public/channels/sendblue/inboundContent.js"; + +function message(text: string): Message { + return new Message({ + author: { isBot: false, isMe: false, userId: "user", userName: "user" }, + id: "message-id", + raw: {}, + text, + threadId: "thread-id", + }); +} + +describe("sendblueInboundContent", () => { + it("returns plain text", () => { + expect(sendblueInboundContent(message("hello"))).toBe("hello"); + }); + + it("drops blank messages", () => { + expect(sendblueInboundContent(message(" \n"))).toBeUndefined(); + }); +}); diff --git a/packages/eve/src/public/channels/sendblue/inboundContent.ts b/packages/eve/src/public/channels/sendblue/inboundContent.ts new file mode 100644 index 0000000000..3b51729525 --- /dev/null +++ b/packages/eve/src/public/channels/sendblue/inboundContent.ts @@ -0,0 +1,11 @@ +import type { UserContent } from "ai"; + +import type { Message } from "#compiled/chat/index.js"; +import { messageToUserContent } from "#public/channels/chat-sdk/index.js"; + +/** Returns model-visible Sendblue content, or `undefined` for blank messages. */ +export function sendblueInboundContent(message: Message): string | UserContent | undefined { + const content = messageToUserContent(message); + if (typeof content === "string") return content.trim().length > 0 ? content : undefined; + return content.length > 0 ? content : undefined; +} diff --git a/packages/eve/src/public/channels/sendblue/index.ts b/packages/eve/src/public/channels/sendblue/index.ts new file mode 100644 index 0000000000..27ba3f62d2 --- /dev/null +++ b/packages/eve/src/public/channels/sendblue/index.ts @@ -0,0 +1,8 @@ +export { + sendblueChannel, + type SendblueChannel, + type SendblueChannelConfig, + type SendblueInboundMessageContext, + type SendblueInboundResult, + type SendblueInboundResultOrPromise, +} from "#public/channels/sendblue/sendblueChannel.js"; diff --git a/packages/eve/src/public/channels/sendblue/sendblueChannel.test.ts b/packages/eve/src/public/channels/sendblue/sendblueChannel.test.ts new file mode 100644 index 0000000000..ac586a446c --- /dev/null +++ b/packages/eve/src/public/channels/sendblue/sendblueChannel.test.ts @@ -0,0 +1,53 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { markRead, mention, send } = vi.hoisted(() => ({ + markRead: vi.fn(), + mention: vi.fn(), + send: vi.fn(), +})); + +vi.mock("#public/channels/chat-sdk/index.js", () => ({ + chatSdkChannel: () => ({ + bot: { getAdapter: () => ({ markRead }), onNewMention: mention }, + channel: { routes: [] }, + send, + }), + messageToUserContent: (message: Message) => message.text, +})); +vi.mock("#compiled/@chat-adapter/state-memory/index.js", () => ({ createMemoryState: vi.fn() })); +vi.mock("#compiled/chat-adapter-sendblue/index.js", () => ({ + createSendblueAdapter: vi.fn(() => ({ markRead })), +})); +vi.mock("#public/channels/auth.js", () => ({ vercelOidc: vi.fn() })); + +import { Message } from "#compiled/chat/index.js"; +import { sendblueChannel } from "#public/channels/sendblue/sendblueChannel.js"; + +describe("sendblueChannel", () => { + beforeEach(() => vi.clearAllMocks()); + + it("dispatches Sendblue mentions without subscribing the thread", async () => { + sendblueChannel({ + credentials: async () => ({ accessToken: "token" }), + fromNumber: "+15551234567", + }); + const handler = mention.mock.calls[0]?.[0]; + if (handler === undefined) throw new Error("Expected an inbound mention handler."); + const thread = { id: "thread-id" }; + const message = new Message({ + author: { isBot: false, isMe: false, userId: "user", userName: "user" }, + id: "message-id", + raw: {}, + text: "Hello Sendblue", + threadId: thread.id, + }); + + await handler(thread, message); + + expect(markRead).toHaveBeenCalledWith(thread.id); + expect(send).toHaveBeenCalledWith( + { context: [], message: "Hello Sendblue" }, + { auth: null, thread, turnPolicy: "experimental-steer" }, + ); + }); +}); diff --git a/packages/eve/src/public/channels/sendblue/sendblueChannel.ts b/packages/eve/src/public/channels/sendblue/sendblueChannel.ts new file mode 100644 index 0000000000..16ca3337ed --- /dev/null +++ b/packages/eve/src/public/channels/sendblue/sendblueChannel.ts @@ -0,0 +1,145 @@ +import type { SessionAuthContext } from "#channel/types.js"; +import { vercelOidc } from "#public/channels/auth.js"; +import { + chatSdkChannel, + type ChatSdkChannel, + type ChatSdkChannelBridge, + type ChatSdkChannelEvents, +} from "#public/channels/chat-sdk/index.js"; +import { createMemoryState } from "#compiled/@chat-adapter/state-memory/index.js"; +import { createSendblueAdapter } from "#compiled/chat-adapter-sendblue/index.js"; +import type { Message, Thread } from "#compiled/chat/index.js"; +import { sendblueInboundContent } from "#public/channels/sendblue/inboundContent.js"; + +type SendblueCredentialsProvider = () => + | { apiKey: string; apiSecret: string } + | { accessToken: string } + | Promise<{ apiKey: string; apiSecret: string } | { accessToken: string }>; +type SendblueWebhookVerifier = (request: Request, rawBody: string) => unknown | Promise; +type SendblueAdapter = { markRead(threadId: string): Promise }; +type SendblueFromNumber = string | (() => Promise); +type SendblueAdapterFactory = (config: { + allowedFromNumbers: readonly string[] | (() => Promise); + credentials: SendblueCredentialsProvider; + defaultFromNumber: SendblueFromNumber; + webhookSecret?: string; + webhookVerifier?: SendblueWebhookVerifier; +}) => SendblueAdapter; +const createAdapter = createSendblueAdapter as unknown as SendblueAdapterFactory; + +/** Context passed to {@link SendblueChannelConfig.onMessage}. */ +export interface SendblueInboundMessageContext { + /** Low-level Chat SDK thread for Sendblue-specific operations. */ + readonly thread: Thread; +} + +/** Result of {@link SendblueChannelConfig.onMessage}. Return `null` to drop the message. */ +export type SendblueInboundResult = { + readonly auth: SessionAuthContext | null; + readonly context?: readonly string[]; +} | null; + +/** Sync or async {@link SendblueInboundResult}. */ +export type SendblueInboundResultOrPromise = SendblueInboundResult | Promise; + +/** Configuration for {@link sendblueChannel}. */ +export interface SendblueChannelConfig { + /** Lazy Sendblue credentials, such as `connectSendblueCredentials(...)`. */ + readonly credentials: SendblueCredentialsProvider; + /** Sendblue line used for outbound messages and accepted inbound webhooks. */ + readonly fromNumber: SendblueFromNumber; + /** Per-event overrides for the underlying Chat SDK channel. */ + readonly events?: ChatSdkChannelEvents<{ sendblue: SendblueAdapter }>; + /** Inbound message policy. Defaults to dispatching every Sendblue message with no user auth. */ + readonly onMessage?: ( + ctx: SendblueInboundMessageContext, + message: Message, + ) => SendblueInboundResultOrPromise; + /** Override the default webhook route (`/eve/v1/sendblue`). */ + readonly route?: string; + /** Sendblue webhook signing secret for direct provider delivery. */ + readonly webhookSecret?: string; + /** Trusted webhook verifier. Takes precedence over `webhookSecret`. */ + readonly webhookVerifier?: SendblueWebhookVerifier; + /** Display name used by the Chat SDK runtime. Defaults to `"eve"`. */ + readonly userName?: string; +} + +/** First-class eve channel backed by Sendblue iMessage, SMS, and RCS. */ +export interface SendblueChannel extends ChatSdkChannel {} + +/** + * Creates an eve channel for Sendblue conversations. + * + * @example + * ```ts + * import { connectSendblueChannel } from "@vercel/connect/eve"; + * import { sendblueChannel } from "eve/channels/sendblue"; + * + * export default sendblueChannel({ + * ...connectSendblueChannel("sendblue/my-agent"), + * }); + * ``` + */ +export function sendblueChannel(config: SendblueChannelConfig): SendblueChannel { + const webhookSecret = config.webhookSecret ?? process.env.SENDBLUE_WEBHOOK_SECRET; + const fromNumber = config.fromNumber; + const sendblue = createAdapter({ + allowedFromNumbers: + typeof fromNumber === "string" ? [fromNumber] : async () => [await fromNumber()], + credentials: config.credentials, + defaultFromNumber: fromNumber, + ...(config.webhookVerifier + ? { webhookVerifier: config.webhookVerifier } + : webhookSecret + ? { webhookSecret } + : { webhookVerifier: vercelOidc() }), + }); + const bridge = chatSdkChannel({ + adapters: { sendblue }, + concurrency: "concurrent", + events: config.events, + routes: { sendblue: config.route ?? "/eve/v1/sendblue" }, + state: createMemoryState(), + streaming: false, + userName: config.userName ?? "eve", + }); + const onMessage = config.onMessage ?? defaultOnMessage; + + // Sendblue marks every inbound message as a mention. A phone conversation is + // already one durable eve session, so it intentionally has no Chat SDK subscription state. + bridge.bot.onNewMention(async (thread: Thread, message: Message) => { + await dispatchMessage(bridge, onMessage, thread, message); + }); + + return bridge.channel; +} + +async function defaultOnMessage(): Promise { + return { auth: null }; +} + +async function dispatchMessage( + bridge: ChatSdkChannelBridge<{ sendblue: SendblueAdapter }>, + onMessage: NonNullable, + thread: Thread, + message: Message, +): Promise { + const result = await onMessage({ thread }, message); + if (result === null) return; + await markReadBestEffort(bridge.bot.getAdapter("sendblue"), thread); + const content = sendblueInboundContent(message); + if (content === undefined) return; + await bridge.send( + { context: [...(result.context ?? [])], message: content }, + { auth: result.auth, thread, turnPolicy: "experimental-steer" }, + ); +} + +async function markReadBestEffort(adapter: SendblueAdapter, thread: Thread): Promise { + try { + await adapter.markRead(thread.id); + } catch { + // A read receipt should never prevent the user's message from reaching eve. + } +} diff --git a/packages/eve/src/setup/integrations/registry.ts b/packages/eve/src/setup/integrations/registry.ts index fddfbeb1a7..fa95496164 100644 --- a/packages/eve/src/setup/integrations/registry.ts +++ b/packages/eve/src/setup/integrations/registry.ts @@ -3,6 +3,7 @@ import { GITHUB_SETUP } from "./github/setup.js"; import { LINEAR_SETUP } from "./linear/setup.js"; import { LINQ_SETUP } from "./linq/setup.js"; import { PHOTON_SETUP } from "./photon/setup.js"; +import { SENDBLUE_SETUP } from "./sendblue/setup.js"; import { SLACK_SETUP } from "./slack/setup.js"; import type { SetupIntegration } from "./types.js"; import { WEB_SETUP } from "./web/setup.js"; @@ -16,6 +17,7 @@ export const SETUP_INTEGRATIONS: readonly SetupIntegration[] = [ LINEAR_SETUP, LINQ_SETUP, PHOTON_SETUP, + SENDBLUE_SETUP, ]; /** Resolves one built-in setup integration by its registry setup name. */ diff --git a/packages/eve/src/setup/integrations/sendblue/connect.test.ts b/packages/eve/src/setup/integrations/sendblue/connect.test.ts new file mode 100644 index 0000000000..d8ae738212 --- /dev/null +++ b/packages/eve/src/setup/integrations/sendblue/connect.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, test, vi } from "vitest"; + +import type { ChannelSetupLog } from "#setup/cli/index.js"; +import { parseCreatedSendblueConnector, provisionSendblueConnector } from "./connect.js"; + +function log(): ChannelSetupLog { + return { + message: vi.fn(), + info: vi.fn(), + success: vi.fn(), + warning: vi.fn(), + error: vi.fn(), + commandOutput: vi.fn(), + }; +} + +const created = { id: "scl_sendblue", uid: "sendblue/agent" }; + +describe("Sendblue Connect provisioning", () => { + test("parses a managed app-scoped connector", () => { + expect( + parseCreatedSendblueConnector(JSON.stringify({ ...created, supportedSubjectTypes: ["app"] })), + ).toEqual(created); + }); + + test("creates a managed account and routes its trigger", async () => { + const runVercelCaptureStdout = vi.fn().mockResolvedValue({ + ok: true as const, + stdout: JSON.stringify({ ...created, supportedSubjectTypes: ["app"] }), + }); + + await expect( + provisionSendblueConnector({ + log: log(), + project: { orgId: "team_123", projectId: "prj_123" }, + projectRoot: "/tmp/agent", + slug: "agent", + deps: { runVercelCaptureStdout }, + }), + ).resolves.toEqual(created); + + expect(runVercelCaptureStdout).toHaveBeenCalledWith( + [ + "connect", + "create", + "sendblue", + "--name", + "agent", + "--triggers", + "--trigger-path", + "/eve/v1/sendblue", + "-F", + "json", + "--scope", + "team_123", + ], + expect.objectContaining({ cwd: "/tmp/agent", nonInteractive: true }), + ); + }); +}); diff --git a/packages/eve/src/setup/integrations/sendblue/connect.ts b/packages/eve/src/setup/integrations/sendblue/connect.ts new file mode 100644 index 0000000000..b4659cf36b --- /dev/null +++ b/packages/eve/src/setup/integrations/sendblue/connect.ts @@ -0,0 +1,99 @@ +import type { ChannelSetupLog } from "#setup/cli/index.js"; +import { createPromptCommandOutput, withPhase } from "#setup/cli/index.js"; +import type { VercelProjectReference } from "#setup/project-resolution.js"; +import { + runVercelCaptureStdout, + type RunVercelCaptureResult, +} from "#setup/primitives/run-vercel.js"; +import { z } from "zod"; + +export const SENDBLUE_CONNECT_SERVICE = "sendblue"; +export const SENDBLUE_TRIGGER_PATH = "/eve/v1/sendblue"; + +export interface SendblueConnectorRef { + id: string; + uid: string; +} + +export interface ProvisionSendblueConnectorDeps { + runVercelCaptureStdout: typeof runVercelCaptureStdout; +} + +export interface ProvisionSendblueConnectorOptions { + log: ChannelSetupLog; + project: VercelProjectReference; + projectRoot: string; + slug: string; + signal?: AbortSignal; + deps?: ProvisionSendblueConnectorDeps; +} + +const CreatedSendblueConnectorSchema = z.object({ + id: z.string().min(1), + uid: z.string().min(1), + supportedSubjectTypes: z.array(z.string()).refine((types) => types.includes("app")), +}); +type CreatedSendblueConnector = SendblueConnectorRef; + +export function parseCreatedSendblueConnector( + stdout: string, +): CreatedSendblueConnector | undefined { + try { + const parsed = CreatedSendblueConnectorSchema.safeParse(JSON.parse(stdout)); + return parsed.success ? { id: parsed.data.id, uid: parsed.data.uid } : undefined; + } catch { + return undefined; + } +} + +function requireCreatedConnector(result: RunVercelCaptureResult): CreatedSendblueConnector { + if (!result.ok) { + const detail = [result.stderr, result.stdout] + .map((output) => output?.trim()) + .find((output): output is string => output !== undefined && output.length > 0); + throw new Error( + detail + ? `Sendblue connector creation failed:\n${detail}` + : "Sendblue connector creation failed.", + ); + } + const connector = parseCreatedSendblueConnector(result.stdout); + if (connector === undefined) throw new Error("Vercel returned an invalid Sendblue connector."); + return connector; +} + +/** Creates a managed Sendblue account and routes its verified events to eve. */ +export async function provisionSendblueConnector( + options: ProvisionSendblueConnectorOptions, +): Promise { + const deps = options.deps ?? { runVercelCaptureStdout }; + const onOutput = createPromptCommandOutput(options.log); + const created = requireCreatedConnector( + await withPhase(options.log, "Creating Sendblue account...", () => + deps.runVercelCaptureStdout( + [ + "connect", + "create", + SENDBLUE_CONNECT_SERVICE, + "--name", + options.slug, + "--triggers", + "--trigger-path", + SENDBLUE_TRIGGER_PATH, + "-F", + "json", + "--scope", + options.project.orgId, + ], + { + cwd: options.projectRoot, + nonInteractive: true, + onOutput, + signal: options.signal, + }, + ), + ), + ); + options.signal?.throwIfAborted(); + return created; +} diff --git a/packages/eve/src/setup/integrations/sendblue/setup.test.ts b/packages/eve/src/setup/integrations/sendblue/setup.test.ts new file mode 100644 index 0000000000..78d88a4b1d --- /dev/null +++ b/packages/eve/src/setup/integrations/sendblue/setup.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it, vi } from "vitest"; + +import { createFakePrompter } from "#internal/testing/fake-prompter.js"; +import { headlessAsker, withAnswers } from "#setup/ask.js"; + +import { integrationSetupEnvironment } from "../shared/environment.js"; +import { createSetupContexts } from "../shared/ui.js"; +import { applySendblueSetup, prepareSendblueSetup, type SendblueSetupDeps } from "./setup.js"; + +const PORTABLE_ANSWERS = { + "sendblue-credentials": "portable", + "sendblue-api-key": "api-key", + "sendblue-api-secret": "api-secret", + "sendblue-from-number": "+15551234567", +}; + +function deps(): SendblueSetupDeps { + return { + appendEnv: vi.fn(async () => ({ written: [], skipped: [] })), + deriveConnectorSlug: vi.fn(async () => "agent" as never), + provisionConnector: vi.fn(async () => ({ + id: "connector", + uid: "sendblue/agent", + })), + writeTextFile: vi.fn(async () => {}), + }; +} + +function contexts( + answers: Record, + auth: "authenticated" | "cli-missing" = "authenticated", + resolveVercelProject = vi.fn(async () => ({ orgId: "team", projectId: "project" })), +) { + return createSetupContexts({ + appRoot: "/project", + asker: withAnswers(answers)(headlessAsker()), + environment: integrationSetupEnvironment(auth, { kind: "unresolved" }), + prompter: createFakePrompter().prompter, + resolveVercelProject, + }); +} + +describe("Sendblue setup", () => { + it("collects portable credentials before mutation", async () => { + const effects = deps(); + await expect( + prepareSendblueSetup(contexts({ "sendblue-credentials": "portable" }).prepare), + ).rejects.toThrow("SENDBLUE_API_KEY"); + expect(effects.provisionConnector).not.toHaveBeenCalled(); + expect(effects.writeTextFile).not.toHaveBeenCalled(); + }); + + it("applies a portable plan", async () => { + const effects = deps(); + const ctx = contexts(PORTABLE_ANSWERS, "cli-missing"); + const plan = await prepareSendblueSetup(ctx.prepare); + await applySendblueSetup(plan, ctx.apply, effects); + + expect(effects.appendEnv).toHaveBeenCalledWith("/project/.env.local", { + SENDBLUE_API_KEY: "api-key", + SENDBLUE_API_SECRET: "api-secret", + SENDBLUE_FROM_NUMBER: "+15551234567", + }); + }); + + it("requires a linked project for Connect without asking for Sendblue credentials", async () => { + const effects = deps(); + const resolveVercelProject = vi.fn(async () => { + throw new Error("eve link"); + }); + await expect( + prepareSendblueSetup( + contexts({ "sendblue-credentials": "vercel" }, "authenticated", resolveVercelProject) + .prepare, + ), + ).rejects.toThrow("eve link"); + expect(effects.provisionConnector).not.toHaveBeenCalled(); + }); + + it("scaffolds the managed Connect account and provisioned line", async () => { + const effects = deps(); + const ctx = contexts({ "sendblue-credentials": "vercel" }); + const plan = await prepareSendblueSetup(ctx.prepare); + await applySendblueSetup(plan, ctx.apply, effects); + + expect(effects.provisionConnector).toHaveBeenCalledWith( + expect.objectContaining({ + project: { orgId: "team", projectId: "project" }, + slug: "agent", + }), + ); + expect(effects.writeTextFile).toHaveBeenCalledWith( + "/project/agent/channels/sendblue.ts", + expect.stringContaining('connectSendblueChannel("sendblue/agent")'), + { force: undefined }, + ); + }); +}); diff --git a/packages/eve/src/setup/integrations/sendblue/setup.ts b/packages/eve/src/setup/integrations/sendblue/setup.ts new file mode 100644 index 0000000000..8aa63c9df4 --- /dev/null +++ b/packages/eve/src/setup/integrations/sendblue/setup.ts @@ -0,0 +1,173 @@ +import { basename, join } from "node:path"; + +import { select, text } from "#setup/ask.js"; +import { appendEnv } from "#setup/append-env.js"; +import type { VercelProjectReference } from "#setup/project-resolution.js"; +import { deriveSlackConnectorSlug } from "#setup/scaffold/index.js"; +import { writeTextFile } from "#setup/scaffold/files.js"; + +import { + defineSetupIntegration, + type SetupApplyContext, + type SetupPrepareContext, +} from "../types.js"; +import { provisionSendblueConnector } from "./connect.js"; + +interface PortableSendbluePlan { + credentials: "environment"; + sendblue: { apiKey: string; apiSecret: string; fromNumber: string }; +} + +interface ConnectSendbluePlan { + credentials: "vercel-connect"; + vercelProject: VercelProjectReference; +} + +export type SendblueSetupPlan = PortableSendbluePlan | ConnectSendbluePlan; + +export interface SendblueSetupDeps { + appendEnv: typeof appendEnv; + deriveConnectorSlug: typeof deriveSlackConnectorSlug; + provisionConnector: typeof provisionSendblueConnector; + writeTextFile: typeof writeTextFile; +} + +const defaultDeps: SendblueSetupDeps = { + appendEnv, + deriveConnectorSlug: deriveSlackConnectorSlug, + provisionConnector: provisionSendblueConnector, + writeTextFile, +}; + +const PORTABLE_TEMPLATE = `import { sendblueChannel } from "eve/channels/sendblue"; + +export default sendblueChannel({ + async credentials() { + const apiKey = process.env.SENDBLUE_API_KEY; + const apiSecret = process.env.SENDBLUE_API_SECRET; + if (!apiKey || !apiSecret) throw new Error("Sendblue API credentials are required."); + return { apiKey, apiSecret }; + }, + fromNumber: process.env.SENDBLUE_FROM_NUMBER!, + webhookSecret: process.env.SENDBLUE_WEBHOOK_SECRET, +}); +`; + +function connectTemplate(uid: string): string { + return `import { connectSendblueChannel } from "@vercel/connect/eve"; +import { sendblueChannel } from "eve/channels/sendblue"; + +export default sendblueChannel({ + ...connectSendblueChannel(${JSON.stringify(uid)}), +}); +`; +} + +export async function prepareSendblueSetup( + context: SetupPrepareContext, +): Promise { + const credentials = await context.asker.ask( + select({ + key: "sendblue-credentials", + message: "How would you like to configure Sendblue?", + options: [ + { + id: "vercel", + value: "vercel-connect" as const, + label: "Set up Vercel Connect", + hint: "Create a managed Sendblue account and phone number", + }, + { + id: "portable", + value: "environment" as const, + label: "Use portable credentials", + hint: "Configure Sendblue webhooks manually after deployment", + }, + ], + recommended: + context.environment.vercel.kind === "available" + ? ("vercel-connect" as const) + : ("environment" as const), + required: true, + }), + ); + if (credentials === "vercel-connect") { + if (context.environment.vercel.kind === "unavailable") { + throw new Error( + "Vercel Connect requires an authenticated Vercel CLI. Run `vercel login`, then retry Sendblue setup.", + ); + } + return { credentials, vercelProject: await context.resolveVercelProject("Sendblue") }; + } + const apiKey = await context.asker.ask( + text({ + key: "sendblue-api-key", + message: "Sendblue API key", + required: true, + sensitive: true, + environment: "SENDBLUE_API_KEY", + }), + ); + const apiSecret = await context.asker.ask( + text({ + key: "sendblue-api-secret", + message: "Sendblue API secret", + required: true, + sensitive: true, + environment: "SENDBLUE_API_SECRET", + }), + ); + const fromNumber = await context.asker.ask( + text({ + key: "sendblue-from-number", + message: "Sendblue sending phone number", + placeholder: "+15551234567", + required: true, + }), + ); + return { + credentials, + sendblue: { apiKey: apiKey.trim(), apiSecret: apiSecret.trim(), fromNumber: fromNumber.trim() }, + }; +} + +export async function applySendblueSetup( + plan: SendblueSetupPlan, + context: SetupApplyContext, + deps: SendblueSetupDeps = defaultDeps, +) { + const channelPath = join(context.appRoot, "agent/channels/sendblue.ts"); + if (plan.credentials === "vercel-connect") { + const connector = await deps.provisionConnector({ + log: context.presenter.log, + project: plan.vercelProject, + projectRoot: context.appRoot, + slug: await deps.deriveConnectorSlug(context.appRoot, basename(context.appRoot)), + signal: context.signal, + }); + await deps.writeTextFile(channelPath, connectTemplate(connector.uid), { + force: context.force, + }); + } else { + await deps.appendEnv(join(context.appRoot, ".env.local"), { + SENDBLUE_API_KEY: plan.sendblue.apiKey, + SENDBLUE_API_SECRET: plan.sendblue.apiSecret, + SENDBLUE_FROM_NUMBER: plan.sendblue.fromNumber, + }); + await deps.writeTextFile(channelPath, PORTABLE_TEMPLATE, { force: context.force }); + context.presenter.nextSteps([ + "Deploy the agent, then create Sendblue webhooks pointing to https:///eve/v1/sendblue.", + "Set SENDBLUE_API_KEY, SENDBLUE_API_SECRET, SENDBLUE_FROM_NUMBER, and SENDBLUE_WEBHOOK_SECRET in your host's encrypted environment variables.", + ]); + } + context.presenter.log.success("Scaffolded channel: sendblue"); + return { facts: [], deploymentRequired: true as const }; +} + +export const SENDBLUE_SETUP = defineSetupIntegration({ + kind: "sendblue", + label: "Sendblue", + hint: "Messages through Sendblue", + prepare: prepareSendblueSetup, + apply: applySendblueSetup, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8306fd0bbe..7cc7efd462 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1337,6 +1337,9 @@ importers: chat: specifier: 4.34.0 version: 4.34.0(ai@7.0.58(zod@4.4.3))(supports-color@10.2.2)(zod@4.4.3) + chat-adapter-sendblue: + specifier: 0.2.0 + version: 0.2.0(chat@4.34.0(ai@7.0.58(zod@4.4.3))(supports-color@10.2.2)(zod@4.4.3)) chokidar: specifier: 5.0.0 version: 5.0.0 @@ -23800,6 +23803,11 @@ snapshots: chat: 4.34.0(ai@6.0.191(zod@4.4.3))(supports-color@10.2.2)(zod@4.4.3) sendblue: 3.10.1 + chat-adapter-sendblue@0.2.0(chat@4.34.0(ai@7.0.58(zod@4.4.3))(supports-color@10.2.2)(zod@4.4.3)): + dependencies: + chat: 4.34.0(ai@7.0.58(zod@4.4.3))(supports-color@10.2.2)(zod@4.4.3) + sendblue: 3.10.1 + chat@4.30.0(ai@6.0.191(zod@4.4.3))(supports-color@10.2.2)(zod@4.4.3): dependencies: '@workflow/serde': 4.1.0-beta.2 From adf6a538a35e7e48438f665a671314d812ccf28d Mon Sep 17 00:00:00 2001 From: owenkephart Date: Wed, 19 Aug 2026 05:07:24 +0000 Subject: [PATCH 2/8] fix(eve): use standard Sendblue credentials helper Signed-off-by: owenkephart --- packages/eve/src/setup/integrations/sendblue/setup.test.ts | 2 +- packages/eve/src/setup/integrations/sendblue/setup.ts | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/eve/src/setup/integrations/sendblue/setup.test.ts b/packages/eve/src/setup/integrations/sendblue/setup.test.ts index 78d88a4b1d..d08ab99969 100644 --- a/packages/eve/src/setup/integrations/sendblue/setup.test.ts +++ b/packages/eve/src/setup/integrations/sendblue/setup.test.ts @@ -91,7 +91,7 @@ describe("Sendblue setup", () => { ); expect(effects.writeTextFile).toHaveBeenCalledWith( "/project/agent/channels/sendblue.ts", - expect.stringContaining('connectSendblueChannel("sendblue/agent")'), + expect.stringContaining('connectSendblueCredentials("sendblue/agent")'), { force: undefined }, ); }); diff --git a/packages/eve/src/setup/integrations/sendblue/setup.ts b/packages/eve/src/setup/integrations/sendblue/setup.ts index 8aa63c9df4..b184bec69b 100644 --- a/packages/eve/src/setup/integrations/sendblue/setup.ts +++ b/packages/eve/src/setup/integrations/sendblue/setup.ts @@ -54,11 +54,12 @@ export default sendblueChannel({ `; function connectTemplate(uid: string): string { - return `import { connectSendblueChannel } from "@vercel/connect/eve"; + return `import { connectSendblueCredentials, connectSendblueFromNumber } from "@vercel/connect/eve"; import { sendblueChannel } from "eve/channels/sendblue"; export default sendblueChannel({ - ...connectSendblueChannel(${JSON.stringify(uid)}), + credentials: connectSendblueCredentials(${JSON.stringify(uid)}), + fromNumber: connectSendblueFromNumber(${JSON.stringify(uid)}), }); `; } From d8d34b24fcfce19bef75c6d8d5f0196c3babb1e3 Mon Sep 17 00:00:00 2001 From: owenkephart Date: Wed, 19 Aug 2026 05:23:18 +0000 Subject: [PATCH 3/8] fix(eve): infer Sendblue line from credentials Signed-off-by: owenkephart --- docs/channels/sendblue.mdx | 14 ++++++------ .../channels/sendblue/sendblueChannel.ts | 22 +++++++++++++------ .../src/setup/integrations/sendblue/setup.ts | 3 +-- 3 files changed, 23 insertions(+), 16 deletions(-) diff --git a/docs/channels/sendblue.mdx b/docs/channels/sendblue.mdx index 16beecc8e2..3c38863f53 100644 --- a/docs/channels/sendblue.mdx +++ b/docs/channels/sendblue.mdx @@ -13,19 +13,19 @@ verified trigger at `/eve/v1/sendblue`, and issues short-lived credentials to the deployed agent: ```ts title="agent/channels/sendblue.ts" -import { connectSendblueChannel } from "@vercel/connect/eve"; +import { connectSendblueCredentials } from "@vercel/connect/eve"; import { sendblueChannel } from "eve/channels/sendblue"; export default sendblueChannel({ - ...connectSendblueChannel("sendblue/my-agent"), + credentials: connectSendblueCredentials("sendblue/my-agent"), }); ``` -The channel resolves a fresh Connect bearer token for each Sendblue API -operation. The provisioned `fromNumber` limits inbound events to that Sendblue -line, so unrelated account lines do not reach the agent. When no direct -webhook secret is configured, the channel verifies Connect-forwarded webhooks -with Vercel OIDC. +The channel resolves a fresh Connect bearer token and its sole provisioned +Sendblue line. That line limits inbound events, so unrelated account lines do +not reach the agent. For a connector with multiple lines, pass `fromNumber` to +`connectSendblueCredentials(...)` to select one. When no direct webhook secret +is configured, the channel verifies Connect-forwarded webhooks with Vercel OIDC. ## Other hosts diff --git a/packages/eve/src/public/channels/sendblue/sendblueChannel.ts b/packages/eve/src/public/channels/sendblue/sendblueChannel.ts index 16ca3337ed..b344099fd2 100644 --- a/packages/eve/src/public/channels/sendblue/sendblueChannel.ts +++ b/packages/eve/src/public/channels/sendblue/sendblueChannel.ts @@ -11,10 +11,13 @@ import { createSendblueAdapter } from "#compiled/chat-adapter-sendblue/index.js" import type { Message, Thread } from "#compiled/chat/index.js"; import { sendblueInboundContent } from "#public/channels/sendblue/inboundContent.js"; -type SendblueCredentialsProvider = () => +type SendblueCredentialsProvider = (() => | { apiKey: string; apiSecret: string } | { accessToken: string } - | Promise<{ apiKey: string; apiSecret: string } | { accessToken: string }>; + | Promise<{ apiKey: string; apiSecret: string } | { accessToken: string }>) & { + fromNumber?: () => Promise; + webhookVerifier?: SendblueWebhookVerifier; +}; type SendblueWebhookVerifier = (request: Request, rawBody: string) => unknown | Promise; type SendblueAdapter = { markRead(threadId: string): Promise }; type SendblueFromNumber = string | (() => Promise); @@ -47,7 +50,7 @@ export interface SendblueChannelConfig { /** Lazy Sendblue credentials, such as `connectSendblueCredentials(...)`. */ readonly credentials: SendblueCredentialsProvider; /** Sendblue line used for outbound messages and accepted inbound webhooks. */ - readonly fromNumber: SendblueFromNumber; + readonly fromNumber?: SendblueFromNumber; /** Per-event overrides for the underlying Chat SDK channel. */ readonly events?: ChatSdkChannelEvents<{ sendblue: SendblueAdapter }>; /** Inbound message policy. Defaults to dispatching every Sendblue message with no user auth. */ @@ -73,17 +76,22 @@ export interface SendblueChannel extends ChatSdkChannel {} * * @example * ```ts - * import { connectSendblueChannel } from "@vercel/connect/eve"; + * import { connectSendblueCredentials } from "@vercel/connect/eve"; * import { sendblueChannel } from "eve/channels/sendblue"; * * export default sendblueChannel({ - * ...connectSendblueChannel("sendblue/my-agent"), + * credentials: connectSendblueCredentials("sendblue/my-agent"), * }); * ``` */ export function sendblueChannel(config: SendblueChannelConfig): SendblueChannel { const webhookSecret = config.webhookSecret ?? process.env.SENDBLUE_WEBHOOK_SECRET; - const fromNumber = config.fromNumber; + const fromNumber = config.fromNumber ?? config.credentials.fromNumber; + if (fromNumber === undefined) { + throw new Error( + "Sendblue fromNumber is required. Pass it in config or return it from credentials.", + ); + } const sendblue = createAdapter({ allowedFromNumbers: typeof fromNumber === "string" ? [fromNumber] : async () => [await fromNumber()], @@ -93,7 +101,7 @@ export function sendblueChannel(config: SendblueChannelConfig): SendblueChannel ? { webhookVerifier: config.webhookVerifier } : webhookSecret ? { webhookSecret } - : { webhookVerifier: vercelOidc() }), + : { webhookVerifier: config.credentials.webhookVerifier ?? vercelOidc() }), }); const bridge = chatSdkChannel({ adapters: { sendblue }, diff --git a/packages/eve/src/setup/integrations/sendblue/setup.ts b/packages/eve/src/setup/integrations/sendblue/setup.ts index b184bec69b..d280e4d260 100644 --- a/packages/eve/src/setup/integrations/sendblue/setup.ts +++ b/packages/eve/src/setup/integrations/sendblue/setup.ts @@ -54,12 +54,11 @@ export default sendblueChannel({ `; function connectTemplate(uid: string): string { - return `import { connectSendblueCredentials, connectSendblueFromNumber } from "@vercel/connect/eve"; + return `import { connectSendblueCredentials } from "@vercel/connect/eve"; import { sendblueChannel } from "eve/channels/sendblue"; export default sendblueChannel({ credentials: connectSendblueCredentials(${JSON.stringify(uid)}), - fromNumber: connectSendblueFromNumber(${JSON.stringify(uid)}), }); `; } From d7ac8a20aa75309d7bb606a0dba33ece105cd73a Mon Sep 17 00:00:00 2001 From: owenkephart Date: Thu, 20 Aug 2026 04:08:11 +0000 Subject: [PATCH 4/8] fix(eve): configure portable Sendblue credentials Signed-off-by: owenkephart --- docs/channels/sendblue.mdx | 15 ++- .../eve/src/public/channels/sendblue/index.ts | 2 + .../channels/sendblue/sendblueChannel.test.ts | 49 +++++++- .../channels/sendblue/sendblueChannel.ts | 109 +++++++++++------- .../src/setup/integrations/sendblue/setup.ts | 12 +- 5 files changed, 131 insertions(+), 56 deletions(-) diff --git a/docs/channels/sendblue.mdx b/docs/channels/sendblue.mdx index 3c38863f53..ccdd6ee730 100644 --- a/docs/channels/sendblue.mdx +++ b/docs/channels/sendblue.mdx @@ -32,7 +32,20 @@ is configured, the channel verifies Connect-forwarded webhooks with Vercel OIDC. Choose **Use portable credentials** during `eve add channel/sendblue` when the agent is not deployed through Vercel Connect. eve writes the API key, secret, and sending number to `.env.local`, then scaffolds a channel that reads them -from the environment. +from the environment: + +```ts title="agent/channels/sendblue.ts" +import { sendblueChannel } from "eve/channels/sendblue"; + +export default sendblueChannel({ + credentials: { + apiKey: process.env.SENDBLUE_API_KEY!, + apiSecret: process.env.SENDBLUE_API_SECRET!, + fromNumber: process.env.SENDBLUE_FROM_NUMBER!, + webhookSecret: process.env.SENDBLUE_WEBHOOK_SECRET, + }, +}); +``` After deploying the agent: diff --git a/packages/eve/src/public/channels/sendblue/index.ts b/packages/eve/src/public/channels/sendblue/index.ts index 27ba3f62d2..c20b99f721 100644 --- a/packages/eve/src/public/channels/sendblue/index.ts +++ b/packages/eve/src/public/channels/sendblue/index.ts @@ -2,6 +2,8 @@ export { sendblueChannel, type SendblueChannel, type SendblueChannelConfig, + type SendblueChannelCredentials, + type SendblueChannelCredentialsProvider, type SendblueInboundMessageContext, type SendblueInboundResult, type SendblueInboundResultOrPromise, diff --git a/packages/eve/src/public/channels/sendblue/sendblueChannel.test.ts b/packages/eve/src/public/channels/sendblue/sendblueChannel.test.ts index ac586a446c..be9abe95ba 100644 --- a/packages/eve/src/public/channels/sendblue/sendblueChannel.test.ts +++ b/packages/eve/src/public/channels/sendblue/sendblueChannel.test.ts @@ -1,9 +1,11 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -const { markRead, mention, send } = vi.hoisted(() => ({ +const { createAdapter, markRead, mention, send, vercelOidc } = vi.hoisted(() => ({ + createAdapter: vi.fn(), markRead: vi.fn(), mention: vi.fn(), send: vi.fn(), + vercelOidc: vi.fn(() => vi.fn()), })); vi.mock("#public/channels/chat-sdk/index.js", () => ({ @@ -16,9 +18,9 @@ vi.mock("#public/channels/chat-sdk/index.js", () => ({ })); vi.mock("#compiled/@chat-adapter/state-memory/index.js", () => ({ createMemoryState: vi.fn() })); vi.mock("#compiled/chat-adapter-sendblue/index.js", () => ({ - createSendblueAdapter: vi.fn(() => ({ markRead })), + createSendblueAdapter: createAdapter.mockReturnValue({ markRead }), })); -vi.mock("#public/channels/auth.js", () => ({ vercelOidc: vi.fn() })); +vi.mock("#public/channels/auth.js", () => ({ vercelOidc })); import { Message } from "#compiled/chat/index.js"; import { sendblueChannel } from "#public/channels/sendblue/sendblueChannel.js"; @@ -28,8 +30,7 @@ describe("sendblueChannel", () => { it("dispatches Sendblue mentions without subscribing the thread", async () => { sendblueChannel({ - credentials: async () => ({ accessToken: "token" }), - fromNumber: "+15551234567", + credentials: { accessToken: "token", fromNumber: "+15551234567" }, }); const handler = mention.mock.calls[0]?.[0]; if (handler === undefined) throw new Error("Expected an inbound mention handler."); @@ -50,4 +51,42 @@ describe("sendblueChannel", () => { { auth: null, thread, turnPolicy: "experimental-steer" }, ); }); + + it("passes direct credentials and webhook configuration to the adapter", async () => { + sendblueChannel({ + credentials: { + apiKey: "api-key", + apiSecret: "api-secret", + fromNumber: "+15551234567", + webhookSecret: "webhook-secret", + }, + }); + + const adapterConfig = createAdapter.mock.calls[0]?.[0]; + await expect(adapterConfig.defaultFromNumber()).resolves.toBe("+15551234567"); + await expect(adapterConfig.credentials()).resolves.toEqual({ + apiKey: "api-key", + apiSecret: "api-secret", + }); + expect(adapterConfig.webhookSecret).toBe("webhook-secret"); + }); + + it("uses managed credentials for its line and webhook verification", async () => { + const webhookVerifier = vi.fn(); + const credentials = vi.fn(async () => ({ + accessToken: "token", + fromNumber: "+15551234567", + webhookVerifier, + })); + + sendblueChannel({ credentials }); + + const adapterConfig = createAdapter.mock.calls[0]?.[0]; + expect(adapterConfig.defaultFromNumber).toEqual(expect.any(Function)); + await expect(adapterConfig.defaultFromNumber()).resolves.toBe("+15551234567"); + await expect(adapterConfig.credentials()).resolves.toEqual({ accessToken: "token" }); + expect(adapterConfig.webhookVerifier).toEqual(expect.any(Function)); + await adapterConfig.webhookVerifier(new Request("https://example.com"), "body"); + expect(webhookVerifier).toHaveBeenCalledWith(expect.any(Request), "body"); + }); }); diff --git a/packages/eve/src/public/channels/sendblue/sendblueChannel.ts b/packages/eve/src/public/channels/sendblue/sendblueChannel.ts index b344099fd2..e15c66bf5e 100644 --- a/packages/eve/src/public/channels/sendblue/sendblueChannel.ts +++ b/packages/eve/src/public/channels/sendblue/sendblueChannel.ts @@ -7,28 +7,29 @@ import { type ChatSdkChannelEvents, } from "#public/channels/chat-sdk/index.js"; import { createMemoryState } from "#compiled/@chat-adapter/state-memory/index.js"; -import { createSendblueAdapter } from "#compiled/chat-adapter-sendblue/index.js"; +import { + createSendblueAdapter, + type SendblueAdapter, + type SendblueCredentials, + type SendblueWebhookVerifier, +} from "#compiled/chat-adapter-sendblue/index.js"; import type { Message, Thread } from "#compiled/chat/index.js"; import { sendblueInboundContent } from "#public/channels/sendblue/inboundContent.js"; -type SendblueCredentialsProvider = (() => - | { apiKey: string; apiSecret: string } - | { accessToken: string } - | Promise<{ apiKey: string; apiSecret: string } | { accessToken: string }>) & { - fromNumber?: () => Promise; - webhookVerifier?: SendblueWebhookVerifier; +/** Credentials and webhook settings for a Sendblue channel. */ +export type SendblueChannelCredentials = SendblueCredentials & { + /** Sendblue line used for outbound messages and accepted inbound webhooks. */ + readonly fromNumber: string; + /** Sendblue webhook signing secret for direct provider delivery. */ + readonly webhookSecret?: string; + /** Trusted verifier for webhook delivery, such as Vercel Connect's OIDC verifier. */ + readonly webhookVerifier?: SendblueWebhookVerifier; }; -type SendblueWebhookVerifier = (request: Request, rawBody: string) => unknown | Promise; -type SendblueAdapter = { markRead(threadId: string): Promise }; -type SendblueFromNumber = string | (() => Promise); -type SendblueAdapterFactory = (config: { - allowedFromNumbers: readonly string[] | (() => Promise); - credentials: SendblueCredentialsProvider; - defaultFromNumber: SendblueFromNumber; - webhookSecret?: string; - webhookVerifier?: SendblueWebhookVerifier; -}) => SendblueAdapter; -const createAdapter = createSendblueAdapter as unknown as SendblueAdapterFactory; + +/** Resolves Sendblue credentials for integrations that rotate them. */ +export type SendblueChannelCredentialsProvider = () => + | SendblueChannelCredentials + | Promise; /** Context passed to {@link SendblueChannelConfig.onMessage}. */ export interface SendblueInboundMessageContext { @@ -47,10 +48,8 @@ export type SendblueInboundResultOrPromise = SendblueInboundResult | Promise; /** Inbound message policy. Defaults to dispatching every Sendblue message with no user auth. */ @@ -60,10 +59,6 @@ export interface SendblueChannelConfig { ) => SendblueInboundResultOrPromise; /** Override the default webhook route (`/eve/v1/sendblue`). */ readonly route?: string; - /** Sendblue webhook signing secret for direct provider delivery. */ - readonly webhookSecret?: string; - /** Trusted webhook verifier. Takes precedence over `webhookSecret`. */ - readonly webhookVerifier?: SendblueWebhookVerifier; /** Display name used by the Chat SDK runtime. Defaults to `"eve"`. */ readonly userName?: string; } @@ -85,23 +80,19 @@ export interface SendblueChannel extends ChatSdkChannel {} * ``` */ export function sendblueChannel(config: SendblueChannelConfig): SendblueChannel { - const webhookSecret = config.webhookSecret ?? process.env.SENDBLUE_WEBHOOK_SECRET; - const fromNumber = config.fromNumber ?? config.credentials.fromNumber; - if (fromNumber === undefined) { - throw new Error( - "Sendblue fromNumber is required. Pass it in config or return it from credentials.", - ); - } - const sendblue = createAdapter({ - allowedFromNumbers: - typeof fromNumber === "string" ? [fromNumber] : async () => [await fromNumber()], - credentials: config.credentials, - defaultFromNumber: fromNumber, - ...(config.webhookVerifier - ? { webhookVerifier: config.webhookVerifier } - : webhookSecret - ? { webhookSecret } - : { webhookVerifier: config.credentials.webhookVerifier ?? vercelOidc() }), + const credentials = toCredentialsProvider(config.credentials); + const webhookSecret = + (typeof config.credentials === "function" ? undefined : config.credentials.webhookSecret) ?? + process.env.SENDBLUE_WEBHOOK_SECRET; + const fallbackWebhookVerifier = webhookSecret === undefined ? vercelOidc() : undefined; + const sendblue = createSendblueAdapter({ + credentials: () => resolveAdapterCredentials(credentials), + defaultFromNumber: () => resolveFromNumber(credentials), + ...(webhookSecret + ? { webhookSecret } + : { + webhookVerifier: createCredentialWebhookVerifier(credentials, fallbackWebhookVerifier!), + }), }); const bridge = chatSdkChannel({ adapters: { sendblue }, @@ -123,6 +114,38 @@ export function sendblueChannel(config: SendblueChannelConfig): SendblueChannel return bridge.channel; } +function toCredentialsProvider( + credentials: SendblueChannelConfig["credentials"], +): SendblueChannelCredentialsProvider { + return typeof credentials === "function" ? credentials : () => credentials; +} + +async function resolveAdapterCredentials( + credentials: SendblueChannelCredentialsProvider, +): Promise { + const { + fromNumber: _fromNumber, + webhookSecret: _webhookSecret, + webhookVerifier: _webhookVerifier, + ...adapterCredentials + } = await credentials(); + return adapterCredentials; +} + +async function resolveFromNumber(credentials: SendblueChannelCredentialsProvider): Promise { + return (await credentials()).fromNumber; +} + +function createCredentialWebhookVerifier( + credentials: SendblueChannelCredentialsProvider, + fallbackVerifier: SendblueWebhookVerifier, +): SendblueWebhookVerifier { + return async (request: Request, rawBody: string) => { + const verifier = (await credentials()).webhookVerifier ?? fallbackVerifier; + return verifier(request, rawBody); + }; +} + async function defaultOnMessage(): Promise { return { auth: null }; } diff --git a/packages/eve/src/setup/integrations/sendblue/setup.ts b/packages/eve/src/setup/integrations/sendblue/setup.ts index d280e4d260..529fc20005 100644 --- a/packages/eve/src/setup/integrations/sendblue/setup.ts +++ b/packages/eve/src/setup/integrations/sendblue/setup.ts @@ -42,14 +42,12 @@ const defaultDeps: SendblueSetupDeps = { const PORTABLE_TEMPLATE = `import { sendblueChannel } from "eve/channels/sendblue"; export default sendblueChannel({ - async credentials() { - const apiKey = process.env.SENDBLUE_API_KEY; - const apiSecret = process.env.SENDBLUE_API_SECRET; - if (!apiKey || !apiSecret) throw new Error("Sendblue API credentials are required."); - return { apiKey, apiSecret }; + credentials: { + apiKey: process.env.SENDBLUE_API_KEY!, + apiSecret: process.env.SENDBLUE_API_SECRET!, + fromNumber: process.env.SENDBLUE_FROM_NUMBER!, + webhookSecret: process.env.SENDBLUE_WEBHOOK_SECRET, }, - fromNumber: process.env.SENDBLUE_FROM_NUMBER!, - webhookSecret: process.env.SENDBLUE_WEBHOOK_SECRET, }); `; From 0e94d0b33531958d1c2aaa2c0792ae48d3ff47cb Mon Sep 17 00:00:00 2001 From: owenkephart Date: Thu, 20 Aug 2026 14:07:15 +0000 Subject: [PATCH 5/8] refactor(eve): hide Sendblue Chat SDK channel type Signed-off-by: owenkephart --- packages/eve/src/public/channels/sendblue/sendblueChannel.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/eve/src/public/channels/sendblue/sendblueChannel.ts b/packages/eve/src/public/channels/sendblue/sendblueChannel.ts index e15c66bf5e..9916ff38b1 100644 --- a/packages/eve/src/public/channels/sendblue/sendblueChannel.ts +++ b/packages/eve/src/public/channels/sendblue/sendblueChannel.ts @@ -1,8 +1,8 @@ import type { SessionAuthContext } from "#channel/types.js"; import { vercelOidc } from "#public/channels/auth.js"; +import type { Channel } from "#public/definitions/channel.js"; import { chatSdkChannel, - type ChatSdkChannel, type ChatSdkChannelBridge, type ChatSdkChannelEvents, } from "#public/channels/chat-sdk/index.js"; @@ -64,7 +64,7 @@ export interface SendblueChannelConfig { } /** First-class eve channel backed by Sendblue iMessage, SMS, and RCS. */ -export interface SendblueChannel extends ChatSdkChannel {} +export interface SendblueChannel extends Channel {} /** * Creates an eve channel for Sendblue conversations. From 5f88b379ec0b159a27413e90d421cef09e56b0cd Mon Sep 17 00:00:00 2001 From: owenkephart Date: Mon, 24 Aug 2026 01:17:04 +0000 Subject: [PATCH 6/8] refactor(eve): narrow Sendblue channel surface Signed-off-by: owenkephart --- .../eve/src/public/channels/sendblue/index.ts | 3 -- .../channels/sendblue/sendblueChannel.ts | 47 ++----------------- .../setup/integrations/sendblue/setup.test.ts | 9 +--- 3 files changed, 7 insertions(+), 52 deletions(-) diff --git a/packages/eve/src/public/channels/sendblue/index.ts b/packages/eve/src/public/channels/sendblue/index.ts index c20b99f721..4778d9895e 100644 --- a/packages/eve/src/public/channels/sendblue/index.ts +++ b/packages/eve/src/public/channels/sendblue/index.ts @@ -4,7 +4,4 @@ export { type SendblueChannelConfig, type SendblueChannelCredentials, type SendblueChannelCredentialsProvider, - type SendblueInboundMessageContext, - type SendblueInboundResult, - type SendblueInboundResultOrPromise, } from "#public/channels/sendblue/sendblueChannel.js"; diff --git a/packages/eve/src/public/channels/sendblue/sendblueChannel.ts b/packages/eve/src/public/channels/sendblue/sendblueChannel.ts index 9916ff38b1..bba7daa5ef 100644 --- a/packages/eve/src/public/channels/sendblue/sendblueChannel.ts +++ b/packages/eve/src/public/channels/sendblue/sendblueChannel.ts @@ -1,11 +1,6 @@ -import type { SessionAuthContext } from "#channel/types.js"; import { vercelOidc } from "#public/channels/auth.js"; import type { Channel } from "#public/definitions/channel.js"; -import { - chatSdkChannel, - type ChatSdkChannelBridge, - type ChatSdkChannelEvents, -} from "#public/channels/chat-sdk/index.js"; +import { chatSdkChannel, type ChatSdkChannelBridge } from "#public/channels/chat-sdk/index.js"; import { createMemoryState } from "#compiled/@chat-adapter/state-memory/index.js"; import { createSendblueAdapter, @@ -31,32 +26,10 @@ export type SendblueChannelCredentialsProvider = () => | SendblueChannelCredentials | Promise; -/** Context passed to {@link SendblueChannelConfig.onMessage}. */ -export interface SendblueInboundMessageContext { - /** Low-level Chat SDK thread for Sendblue-specific operations. */ - readonly thread: Thread; -} - -/** Result of {@link SendblueChannelConfig.onMessage}. Return `null` to drop the message. */ -export type SendblueInboundResult = { - readonly auth: SessionAuthContext | null; - readonly context?: readonly string[]; -} | null; - -/** Sync or async {@link SendblueInboundResult}. */ -export type SendblueInboundResultOrPromise = SendblueInboundResult | Promise; - /** Configuration for {@link sendblueChannel}. */ export interface SendblueChannelConfig { /** Direct Sendblue credentials or a lazy provider such as `connectSendblueCredentials(...)`. */ readonly credentials: SendblueChannelCredentials | SendblueChannelCredentialsProvider; - /** Per-event overrides for the underlying Chat SDK channel. */ - readonly events?: ChatSdkChannelEvents<{ sendblue: SendblueAdapter }>; - /** Inbound message policy. Defaults to dispatching every Sendblue message with no user auth. */ - readonly onMessage?: ( - ctx: SendblueInboundMessageContext, - message: Message, - ) => SendblueInboundResultOrPromise; /** Override the default webhook route (`/eve/v1/sendblue`). */ readonly route?: string; /** Display name used by the Chat SDK runtime. Defaults to `"eve"`. */ @@ -64,7 +37,7 @@ export interface SendblueChannelConfig { } /** First-class eve channel backed by Sendblue iMessage, SMS, and RCS. */ -export interface SendblueChannel extends Channel {} +export interface SendblueChannel extends Channel {} /** * Creates an eve channel for Sendblue conversations. @@ -97,18 +70,15 @@ export function sendblueChannel(config: SendblueChannelConfig): SendblueChannel const bridge = chatSdkChannel({ adapters: { sendblue }, concurrency: "concurrent", - events: config.events, routes: { sendblue: config.route ?? "/eve/v1/sendblue" }, state: createMemoryState(), streaming: false, userName: config.userName ?? "eve", }); - const onMessage = config.onMessage ?? defaultOnMessage; - // Sendblue marks every inbound message as a mention. A phone conversation is // already one durable eve session, so it intentionally has no Chat SDK subscription state. bridge.bot.onNewMention(async (thread: Thread, message: Message) => { - await dispatchMessage(bridge, onMessage, thread, message); + await dispatchMessage(bridge, thread, message); }); return bridge.channel; @@ -146,24 +116,17 @@ function createCredentialWebhookVerifier( }; } -async function defaultOnMessage(): Promise { - return { auth: null }; -} - async function dispatchMessage( bridge: ChatSdkChannelBridge<{ sendblue: SendblueAdapter }>, - onMessage: NonNullable, thread: Thread, message: Message, ): Promise { - const result = await onMessage({ thread }, message); - if (result === null) return; await markReadBestEffort(bridge.bot.getAdapter("sendblue"), thread); const content = sendblueInboundContent(message); if (content === undefined) return; await bridge.send( - { context: [...(result.context ?? [])], message: content }, - { auth: result.auth, thread, turnPolicy: "experimental-steer" }, + { context: [], message: content }, + { auth: null, thread, turnPolicy: "experimental-steer" }, ); } diff --git a/packages/eve/src/setup/integrations/sendblue/setup.test.ts b/packages/eve/src/setup/integrations/sendblue/setup.test.ts index d08ab99969..7d34912b1b 100644 --- a/packages/eve/src/setup/integrations/sendblue/setup.test.ts +++ b/packages/eve/src/setup/integrations/sendblue/setup.test.ts @@ -41,13 +41,10 @@ function contexts( } describe("Sendblue setup", () => { - it("collects portable credentials before mutation", async () => { - const effects = deps(); + it("requires portable credentials", async () => { await expect( prepareSendblueSetup(contexts({ "sendblue-credentials": "portable" }).prepare), ).rejects.toThrow("SENDBLUE_API_KEY"); - expect(effects.provisionConnector).not.toHaveBeenCalled(); - expect(effects.writeTextFile).not.toHaveBeenCalled(); }); it("applies a portable plan", async () => { @@ -63,8 +60,7 @@ describe("Sendblue setup", () => { }); }); - it("requires a linked project for Connect without asking for Sendblue credentials", async () => { - const effects = deps(); + it("requires a linked project for Connect", async () => { const resolveVercelProject = vi.fn(async () => { throw new Error("eve link"); }); @@ -74,7 +70,6 @@ describe("Sendblue setup", () => { .prepare, ), ).rejects.toThrow("eve link"); - expect(effects.provisionConnector).not.toHaveBeenCalled(); }); it("scaffolds the managed Connect account and provisioned line", async () => { From 9c1ce0f65a7140a4dc6ab386291710ae3df93bb4 Mon Sep 17 00:00:00 2001 From: owenkephart Date: Mon, 24 Aug 2026 22:00:56 +0000 Subject: [PATCH 7/8] refactor(eve): align Sendblue channel credentials Signed-off-by: owenkephart --- .../eve/src/public/channels/sendblue/index.ts | 1 - .../channels/sendblue/sendblueChannel.test.ts | 34 ++++---- .../channels/sendblue/sendblueChannel.ts | 87 ++++++------------- 3 files changed, 40 insertions(+), 82 deletions(-) diff --git a/packages/eve/src/public/channels/sendblue/index.ts b/packages/eve/src/public/channels/sendblue/index.ts index 4778d9895e..e3168819bc 100644 --- a/packages/eve/src/public/channels/sendblue/index.ts +++ b/packages/eve/src/public/channels/sendblue/index.ts @@ -3,5 +3,4 @@ export { type SendblueChannel, type SendblueChannelConfig, type SendblueChannelCredentials, - type SendblueChannelCredentialsProvider, } from "#public/channels/sendblue/sendblueChannel.js"; diff --git a/packages/eve/src/public/channels/sendblue/sendblueChannel.test.ts b/packages/eve/src/public/channels/sendblue/sendblueChannel.test.ts index be9abe95ba..8f032fd75b 100644 --- a/packages/eve/src/public/channels/sendblue/sendblueChannel.test.ts +++ b/packages/eve/src/public/channels/sendblue/sendblueChannel.test.ts @@ -30,7 +30,7 @@ describe("sendblueChannel", () => { it("dispatches Sendblue mentions without subscribing the thread", async () => { sendblueChannel({ - credentials: { accessToken: "token", fromNumber: "+15551234567" }, + credentials: { accessToken: "token", defaultFromNumber: "+15551234567" }, }); const handler = mention.mock.calls[0]?.[0]; if (handler === undefined) throw new Error("Expected an inbound mention handler."); @@ -57,36 +57,32 @@ describe("sendblueChannel", () => { credentials: { apiKey: "api-key", apiSecret: "api-secret", - fromNumber: "+15551234567", + defaultFromNumber: "+15551234567", webhookSecret: "webhook-secret", }, }); const adapterConfig = createAdapter.mock.calls[0]?.[0]; - await expect(adapterConfig.defaultFromNumber()).resolves.toBe("+15551234567"); - await expect(adapterConfig.credentials()).resolves.toEqual({ - apiKey: "api-key", - apiSecret: "api-secret", - }); + expect(adapterConfig.defaultFromNumber).toBe("+15551234567"); + expect(adapterConfig.apiKey).toBe("api-key"); + expect(adapterConfig.apiSecret).toBe("api-secret"); expect(adapterConfig.webhookSecret).toBe("webhook-secret"); }); it("uses managed credentials for its line and webhook verification", async () => { const webhookVerifier = vi.fn(); - const credentials = vi.fn(async () => ({ - accessToken: "token", - fromNumber: "+15551234567", - webhookVerifier, - })); + const accessToken = vi.fn(async () => "token"); + const defaultFromNumber = vi.fn(async () => "+15551234567"); + const allowedFromNumbers = vi.fn(async () => ["+15551234567"]); - sendblueChannel({ credentials }); + sendblueChannel({ + credentials: { accessToken, defaultFromNumber, allowedFromNumbers, webhookVerifier }, + }); const adapterConfig = createAdapter.mock.calls[0]?.[0]; - expect(adapterConfig.defaultFromNumber).toEqual(expect.any(Function)); - await expect(adapterConfig.defaultFromNumber()).resolves.toBe("+15551234567"); - await expect(adapterConfig.credentials()).resolves.toEqual({ accessToken: "token" }); - expect(adapterConfig.webhookVerifier).toEqual(expect.any(Function)); - await adapterConfig.webhookVerifier(new Request("https://example.com"), "body"); - expect(webhookVerifier).toHaveBeenCalledWith(expect.any(Request), "body"); + expect(adapterConfig.accessToken).toBe(accessToken); + expect(adapterConfig.defaultFromNumber).toBe(defaultFromNumber); + expect(adapterConfig.allowedFromNumbers).toBe(allowedFromNumbers); + expect(adapterConfig.webhookVerifier).toBe(webhookVerifier); }); }); diff --git a/packages/eve/src/public/channels/sendblue/sendblueChannel.ts b/packages/eve/src/public/channels/sendblue/sendblueChannel.ts index bba7daa5ef..0449853022 100644 --- a/packages/eve/src/public/channels/sendblue/sendblueChannel.ts +++ b/packages/eve/src/public/channels/sendblue/sendblueChannel.ts @@ -5,31 +5,34 @@ import { createMemoryState } from "#compiled/@chat-adapter/state-memory/index.js import { createSendblueAdapter, type SendblueAdapter, - type SendblueCredentials, - type SendblueWebhookVerifier, + type SendblueAdapterConfig, } from "#compiled/chat-adapter-sendblue/index.js"; import type { Message, Thread } from "#compiled/chat/index.js"; import { sendblueInboundContent } from "#public/channels/sendblue/inboundContent.js"; -/** Credentials and webhook settings for a Sendblue channel. */ -export type SendblueChannelCredentials = SendblueCredentials & { - /** Sendblue line used for outbound messages and accepted inbound webhooks. */ - readonly fromNumber: string; - /** Sendblue webhook signing secret for direct provider delivery. */ - readonly webhookSecret?: string; - /** Trusted verifier for webhook delivery, such as Vercel Connect's OIDC verifier. */ - readonly webhookVerifier?: SendblueWebhookVerifier; -}; - -/** Resolves Sendblue credentials for integrations that rotate them. */ -export type SendblueChannelCredentialsProvider = () => - | SendblueChannelCredentials - | Promise; +/** + * Sendblue adapter credentials and line configuration. + * + * Pass direct API credentials, or `connectSendblueCredentials(...)` from + * `@vercel/connect/eve` for a rotating bearer token and managed line. + */ +export type SendblueChannelCredentials = Partial< + Pick< + SendblueAdapterConfig, + | "accessToken" + | "allowedFromNumbers" + | "apiKey" + | "apiSecret" + | "defaultFromNumber" + | "webhookSecret" + | "webhookVerifier" + > +>; /** Configuration for {@link sendblueChannel}. */ export interface SendblueChannelConfig { - /** Direct Sendblue credentials or a lazy provider such as `connectSendblueCredentials(...)`. */ - readonly credentials: SendblueChannelCredentials | SendblueChannelCredentialsProvider; + /** Direct Sendblue credentials or `connectSendblueCredentials(...)`. */ + readonly credentials: SendblueChannelCredentials; /** Override the default webhook route (`/eve/v1/sendblue`). */ readonly route?: string; /** Display name used by the Chat SDK runtime. Defaults to `"eve"`. */ @@ -53,19 +56,11 @@ export interface SendblueChannel extends Channel {} * ``` */ export function sendblueChannel(config: SendblueChannelConfig): SendblueChannel { - const credentials = toCredentialsProvider(config.credentials); - const webhookSecret = - (typeof config.credentials === "function" ? undefined : config.credentials.webhookSecret) ?? - process.env.SENDBLUE_WEBHOOK_SECRET; - const fallbackWebhookVerifier = webhookSecret === undefined ? vercelOidc() : undefined; const sendblue = createSendblueAdapter({ - credentials: () => resolveAdapterCredentials(credentials), - defaultFromNumber: () => resolveFromNumber(credentials), - ...(webhookSecret - ? { webhookSecret } - : { - webhookVerifier: createCredentialWebhookVerifier(credentials, fallbackWebhookVerifier!), - }), + ...config.credentials, + ...(config.credentials.webhookSecret || config.credentials.webhookVerifier + ? {} + : { webhookVerifier: vercelOidc() }), }); const bridge = chatSdkChannel({ adapters: { sendblue }, @@ -84,38 +79,6 @@ export function sendblueChannel(config: SendblueChannelConfig): SendblueChannel return bridge.channel; } -function toCredentialsProvider( - credentials: SendblueChannelConfig["credentials"], -): SendblueChannelCredentialsProvider { - return typeof credentials === "function" ? credentials : () => credentials; -} - -async function resolveAdapterCredentials( - credentials: SendblueChannelCredentialsProvider, -): Promise { - const { - fromNumber: _fromNumber, - webhookSecret: _webhookSecret, - webhookVerifier: _webhookVerifier, - ...adapterCredentials - } = await credentials(); - return adapterCredentials; -} - -async function resolveFromNumber(credentials: SendblueChannelCredentialsProvider): Promise { - return (await credentials()).fromNumber; -} - -function createCredentialWebhookVerifier( - credentials: SendblueChannelCredentialsProvider, - fallbackVerifier: SendblueWebhookVerifier, -): SendblueWebhookVerifier { - return async (request: Request, rawBody: string) => { - const verifier = (await credentials()).webhookVerifier ?? fallbackVerifier; - return verifier(request, rawBody); - }; -} - async function dispatchMessage( bridge: ChatSdkChannelBridge<{ sendblue: SendblueAdapter }>, thread: Thread, From c86bc69c05155da5f2443e20b4a0f1e7a25d5f94 Mon Sep 17 00:00:00 2001 From: owenkephart Date: Tue, 25 Aug 2026 13:25:27 +0000 Subject: [PATCH 8/8] fix(eve): derive Sendblue user auth Signed-off-by: owenkephart --- .changeset/sendblue-native-channel.md | 2 +- docs/channels/sendblue.mdx | 5 +++-- .../eve/src/public/channels/sendblue/index.ts | 1 + .../channels/sendblue/sendblueChannel.test.ts | 13 ++++++++++++- .../public/channels/sendblue/sendblueChannel.ts | 17 ++++++++++++++++- 5 files changed, 33 insertions(+), 5 deletions(-) diff --git a/.changeset/sendblue-native-channel.md b/.changeset/sendblue-native-channel.md index 5162d219c4..a070efc0b2 100644 --- a/.changeset/sendblue-native-channel.md +++ b/.changeset/sendblue-native-channel.md @@ -2,4 +2,4 @@ "eve": patch --- -Add a first-class Sendblue channel with managed Vercel Connect and portable credential setup. +Add a first-class Sendblue channel with managed Vercel Connect and portable credential setup. Inbound messages now derive user auth from their Sendblue authors by default. diff --git a/docs/channels/sendblue.mdx b/docs/channels/sendblue.mdx index ccdd6ee730..2177562e43 100644 --- a/docs/channels/sendblue.mdx +++ b/docs/channels/sendblue.mdx @@ -24,8 +24,9 @@ export default sendblueChannel({ The channel resolves a fresh Connect bearer token and its sole provisioned Sendblue line. That line limits inbound events, so unrelated account lines do not reach the agent. For a connector with multiple lines, pass `fromNumber` to -`connectSendblueCredentials(...)` to select one. When no direct webhook secret -is configured, the channel verifies Connect-forwarded webhooks with Vercel OIDC. +`connectSendblueCredentials(...)` to select one. The channel derives user auth +from each message author. When no direct webhook secret is configured, the +channel verifies Connect-forwarded webhooks with Vercel OIDC. ## Other hosts diff --git a/packages/eve/src/public/channels/sendblue/index.ts b/packages/eve/src/public/channels/sendblue/index.ts index e3168819bc..5ae9921c20 100644 --- a/packages/eve/src/public/channels/sendblue/index.ts +++ b/packages/eve/src/public/channels/sendblue/index.ts @@ -1,4 +1,5 @@ export { + defaultSendblueAuth, sendblueChannel, type SendblueChannel, type SendblueChannelConfig, diff --git a/packages/eve/src/public/channels/sendblue/sendblueChannel.test.ts b/packages/eve/src/public/channels/sendblue/sendblueChannel.test.ts index 8f032fd75b..65df110657 100644 --- a/packages/eve/src/public/channels/sendblue/sendblueChannel.test.ts +++ b/packages/eve/src/public/channels/sendblue/sendblueChannel.test.ts @@ -48,7 +48,18 @@ describe("sendblueChannel", () => { expect(markRead).toHaveBeenCalledWith(thread.id); expect(send).toHaveBeenCalledWith( { context: [], message: "Hello Sendblue" }, - { auth: null, thread, turnPolicy: "experimental-steer" }, + { + auth: { + attributes: { user_name: "user" }, + authenticator: "sendblue-message", + issuer: "sendblue", + principalId: "sendblue:user", + principalType: "user", + subject: "user", + }, + thread, + turnPolicy: "steer", + }, ); }); diff --git a/packages/eve/src/public/channels/sendblue/sendblueChannel.ts b/packages/eve/src/public/channels/sendblue/sendblueChannel.ts index 0449853022..5262d1639f 100644 --- a/packages/eve/src/public/channels/sendblue/sendblueChannel.ts +++ b/packages/eve/src/public/channels/sendblue/sendblueChannel.ts @@ -1,4 +1,5 @@ import { vercelOidc } from "#public/channels/auth.js"; +import type { SessionAuthContext } from "#channel/types.js"; import type { Channel } from "#public/definitions/channel.js"; import { chatSdkChannel, type ChatSdkChannelBridge } from "#public/channels/chat-sdk/index.js"; import { createMemoryState } from "#compiled/@chat-adapter/state-memory/index.js"; @@ -89,10 +90,24 @@ async function dispatchMessage( if (content === undefined) return; await bridge.send( { context: [], message: content }, - { auth: null, thread, turnPolicy: "experimental-steer" }, + { auth: defaultSendblueAuth(message), thread, turnPolicy: "steer" }, ); } +/** Default Sendblue auth projection for inbound Chat SDK message authors. */ +export function defaultSendblueAuth(message: Message): SessionAuthContext { + const attributes: Record = {}; + if (message.author.userName !== undefined) attributes.user_name = message.author.userName; + return { + attributes, + authenticator: "sendblue-message", + issuer: "sendblue", + principalId: `sendblue:${message.author.userId}`, + principalType: message.author.isBot ? "service" : "user", + subject: message.author.userId, + }; +} + async function markReadBestEffort(adapter: SendblueAdapter, thread: Thread): Promise { try { await adapter.markRead(thread.id);