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 8d0708d788..79395103e4 100644 --- a/apps/docs/lib/integrations/data.ts +++ b/apps/docs/lib/integrations/data.ts @@ -661,48 +661,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 a74d6a20d5..760f03bc28 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": "/docs/channels/chat-sdk", - "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 782879d0c3..d6413c107f 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..ccdd6ee730 --- /dev/null +++ b/docs/channels/sendblue.mdx @@ -0,0 +1,59 @@ +--- +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 { connectSendblueCredentials } from "@vercel/connect/eve"; +import { sendblueChannel } from "eve/channels/sendblue"; + +export default sendblueChannel({ + credentials: connectSendblueCredentials("sendblue/my-agent"), +}); +``` + +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 + +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: + +```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: + +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 6e1de51213..3444f60237 100644 --- a/packages/eve/package.json +++ b/packages/eve/package.json @@ -256,6 +256,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", @@ -370,6 +375,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..4778d9895e --- /dev/null +++ b/packages/eve/src/public/channels/sendblue/index.ts @@ -0,0 +1,7 @@ +export { + sendblueChannel, + 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 new file mode 100644 index 0000000000..be9abe95ba --- /dev/null +++ b/packages/eve/src/public/channels/sendblue/sendblueChannel.test.ts @@ -0,0 +1,92 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +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", () => ({ + 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: createAdapter.mockReturnValue({ markRead }), +})); +vi.mock("#public/channels/auth.js", () => ({ vercelOidc })); + +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: { 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" }, + ); + }); + + 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 new file mode 100644 index 0000000000..bba7daa5ef --- /dev/null +++ b/packages/eve/src/public/channels/sendblue/sendblueChannel.ts @@ -0,0 +1,139 @@ +import { vercelOidc } from "#public/channels/auth.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"; +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"; + +/** 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; + +/** Configuration for {@link sendblueChannel}. */ +export interface SendblueChannelConfig { + /** Direct Sendblue credentials or a lazy provider such as `connectSendblueCredentials(...)`. */ + readonly credentials: SendblueChannelCredentials | SendblueChannelCredentialsProvider; + /** Override the default webhook route (`/eve/v1/sendblue`). */ + readonly route?: string; + /** 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 Channel {} + +/** + * Creates an eve channel for Sendblue conversations. + * + * @example + * ```ts + * import { connectSendblueCredentials } from "@vercel/connect/eve"; + * import { sendblueChannel } from "eve/channels/sendblue"; + * + * export default sendblueChannel({ + * credentials: connectSendblueCredentials("sendblue/my-agent"), + * }); + * ``` + */ +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!), + }), + }); + const bridge = chatSdkChannel({ + adapters: { sendblue }, + concurrency: "concurrent", + routes: { sendblue: config.route ?? "/eve/v1/sendblue" }, + state: createMemoryState(), + streaming: false, + userName: config.userName ?? "eve", + }); + // 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, thread, message); + }); + + 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, + message: Message, +): Promise { + await markReadBestEffort(bridge.bot.getAdapter("sendblue"), thread); + const content = sendblueInboundContent(message); + if (content === undefined) return; + await bridge.send( + { context: [], message: content }, + { auth: null, 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..7d34912b1b --- /dev/null +++ b/packages/eve/src/setup/integrations/sendblue/setup.test.ts @@ -0,0 +1,93 @@ +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("requires portable credentials", async () => { + await expect( + prepareSendblueSetup(contexts({ "sendblue-credentials": "portable" }).prepare), + ).rejects.toThrow("SENDBLUE_API_KEY"); + }); + + 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", async () => { + const resolveVercelProject = vi.fn(async () => { + throw new Error("eve link"); + }); + await expect( + prepareSendblueSetup( + contexts({ "sendblue-credentials": "vercel" }, "authenticated", resolveVercelProject) + .prepare, + ), + ).rejects.toThrow("eve link"); + }); + + 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('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 new file mode 100644 index 0000000000..529fc20005 --- /dev/null +++ b/packages/eve/src/setup/integrations/sendblue/setup.ts @@ -0,0 +1,171 @@ +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({ + 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, + }, +}); +`; + +function connectTemplate(uid: string): string { + return `import { connectSendblueCredentials } from "@vercel/connect/eve"; +import { sendblueChannel } from "eve/channels/sendblue"; + +export default sendblueChannel({ + credentials: connectSendblueCredentials(${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 9b19ef4f88..89e883fcb2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1334,6 +1334,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 @@ -23746,6 +23749,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