From 912e126de94f38e64ce673917b1af42eaf5559cb Mon Sep 17 00:00:00 2001 From: owenkephart Date: Tue, 28 Jul 2026 10:19:20 -0700 Subject: [PATCH 1/6] feat(eve): add first-class Photon channel Signed-off-by: owenkephart --- .changeset/friendly-photons-wave.md | 5 + docs/channels/meta.json | 1 + docs/channels/photon.mdx | 39 ++++++ packages/eve/package.json | 6 + .../@photon-ai/chat-adapter-imessage.mjs | 28 ++++ .../eve/scripts/vendor-compiled/index.mjs | 2 + .../eve/src/public/channels/photon/index.ts | 9 ++ .../channels/photon/photonChannel.test.ts | 41 ++++++ .../public/channels/photon/photonChannel.ts | 126 ++++++++++++++++++ pnpm-lock.yaml | 3 + pnpm-workspace.yaml | 1 + 11 files changed, 261 insertions(+) create mode 100644 .changeset/friendly-photons-wave.md create mode 100644 docs/channels/photon.mdx create mode 100644 packages/eve/scripts/vendor-compiled/@photon-ai/chat-adapter-imessage.mjs create mode 100644 packages/eve/src/public/channels/photon/index.ts create mode 100644 packages/eve/src/public/channels/photon/photonChannel.test.ts create mode 100644 packages/eve/src/public/channels/photon/photonChannel.ts diff --git a/.changeset/friendly-photons-wave.md b/.changeset/friendly-photons-wave.md new file mode 100644 index 000000000..1188816e0 --- /dev/null +++ b/.changeset/friendly-photons-wave.md @@ -0,0 +1,5 @@ +--- +"eve": patch +--- + +Add `photonChannel`, a first-class Photon iMessage channel with lazy credentials, Vercel OIDC webhook verification, and automatic eve session routing. diff --git a/docs/channels/meta.json b/docs/channels/meta.json index c0bcc03d2..c8119fbce 100644 --- a/docs/channels/meta.json +++ b/docs/channels/meta.json @@ -4,6 +4,7 @@ "overview", "eve", "slack", + "photon", "discord", "teams", "telegram", diff --git a/docs/channels/photon.mdx b/docs/channels/photon.mdx new file mode 100644 index 000000000..26039307e --- /dev/null +++ b/docs/channels/photon.mdx @@ -0,0 +1,39 @@ +--- +title: Photon +description: Connect an eve agent to iMessage through Photon. +--- + +Use `photonChannel` to receive and reply to iMessages through a Photon project. +With Vercel Connect, the channel resolves credentials lazily when the adapter +first initializes: + +```ts title="agent/channels/photon.ts" +import { connectPhotonCredentials } from "@vercel/connect/eve"; +import { photonChannel } from "eve/channels/photon"; + +export default photonChannel({ + credentials: connectPhotonCredentials(process.env.PHOTON_CONNECTOR_ID!), +}); +``` + +The default webhook route is `/eve/v1/photon`. The channel verifies forwarded +webhooks with same-project Vercel OIDC by default, subscribes the first inbound +message thread, and continues the same eve session for later messages. + +Customize inbound dispatch with `onMessage`. Return `null` to ignore a message: + +```ts +export default photonChannel({ + credentials: connectPhotonCredentials(process.env.PHOTON_CONNECTOR_ID!), + async onMessage(ctx, message) { + if (message.author.isBot) return null; + return { + auth: null, + context: ctx.isSubscribed() ? [] : [`The sender is ${message.author.fullName}.`], + }; + }, +}); +``` + +Set `route` to override the webhook path or `webhookVerifier` to use a different +trusted-forwarder verifier. diff --git a/packages/eve/package.json b/packages/eve/package.json index 731aa6b1f..747936b86 100644 --- a/packages/eve/package.json +++ b/packages/eve/package.json @@ -236,6 +236,11 @@ "import": "./dist/src/public/channels/chat-sdk/index.js", "default": "./dist/src/public/channels/chat-sdk/index.js" }, + "./channels/photon": { + "types": "./dist/src/public/channels/photon/index.d.ts", + "import": "./dist/src/public/channels/photon/index.js", + "default": "./dist/src/public/channels/photon/index.js" + }, "./channels/github": { "types": "./dist/src/public/channels/github/index.d.ts", "import": "./dist/src/public/channels/github/index.js", @@ -323,6 +328,7 @@ "@opentelemetry/context-async-hooks": "catalog:", "@opentelemetry/otlp-transformer": "0.214.0", "@opentelemetry/sdk-trace-base": "catalog:", + "@photon-ai/chat-adapter-imessage": "3.2.0", "@standard-schema/spec": "1.1.0", "@sveltejs/kit": "^2.0.0", "@types/json-schema": "7.0.15", diff --git a/packages/eve/scripts/vendor-compiled/@photon-ai/chat-adapter-imessage.mjs b/packages/eve/scripts/vendor-compiled/@photon-ai/chat-adapter-imessage.mjs new file mode 100644 index 000000000..ea70cda0d --- /dev/null +++ b/packages/eve/scripts/vendor-compiled/@photon-ai/chat-adapter-imessage.mjs @@ -0,0 +1,28 @@ +import { createDeclarationCopier } from "../_shared.mjs"; + +export default { + packageName: "@photon-ai/chat-adapter-imessage", + compiledPath: "@photon-ai/chat-adapter-imessage", + copyDeclarations: createDeclarationCopier({ + rewrites: { + chat: { kind: "vendored", compiledPath: "chat" }, + "@chat-adapter/shared": { + kind: "stub", + stubBaseName: "_chat-adapter-shared", + build: () => "export {};\n", + }, + "@spectrum-ts/core": { + kind: "stub", + stubBaseName: "_spectrum-core", + build: () => + "export type AppUrl = unknown;\nexport type ContentBuilder = unknown;\nexport type SpectrumInstance = unknown;\n", + }, + "@spectrum-ts/imessage": { + kind: "stub", + stubBaseName: "_spectrum-imessage", + build: () => + "export type CustomizedMiniAppInput = unknown;\nexport type IMessageMessageEffect = string;\n", + }, + }, + }), +}; diff --git a/packages/eve/scripts/vendor-compiled/index.mjs b/packages/eve/scripts/vendor-compiled/index.mjs index 2b74bfce7..90a35ac9b 100644 --- a/packages/eve/scripts/vendor-compiled/index.mjs +++ b/packages/eve/scripts/vendor-compiled/index.mjs @@ -14,6 +14,7 @@ import providerUtils from "./@ai-sdk/provider-utils.mjs"; import chatAdapterSlack from "./@chat-adapter/slack.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"; import opentelemetryApi from "./@opentelemetry/api.mjs"; import opentelemetryOtlpTransformer from "./@opentelemetry/otlp-transformer.mjs"; @@ -69,6 +70,7 @@ export const MODULES = [ opentelemetryApi, opentelemetryOtlpTransformer, otel, + photonChatAdapterIMessage, picocolors, provider, providerUtils, diff --git a/packages/eve/src/public/channels/photon/index.ts b/packages/eve/src/public/channels/photon/index.ts new file mode 100644 index 000000000..c76cbdeef --- /dev/null +++ b/packages/eve/src/public/channels/photon/index.ts @@ -0,0 +1,9 @@ +export { + photonChannel, + type PhotonChannel, + type PhotonChannelConfig, + type PhotonChannelCredentials, + type PhotonInboundMessageContext, + type PhotonInboundResult, + type PhotonInboundResultOrPromise, +} from "#public/channels/photon/photonChannel.js"; diff --git a/packages/eve/src/public/channels/photon/photonChannel.test.ts b/packages/eve/src/public/channels/photon/photonChannel.test.ts new file mode 100644 index 000000000..b200dd4c2 --- /dev/null +++ b/packages/eve/src/public/channels/photon/photonChannel.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it, vi } from "vitest"; + +import { isCompiledChannel } from "#channel/compiled-channel.js"; +import { photonChannel } from "#public/channels/photon/photonChannel.js"; + +function routes(channel: unknown): Array<{ method: string; path: string }> { + if (!isCompiledChannel(channel)) throw new Error("Expected compiled channel."); + return channel.routes.map((route) => ({ method: route.method, path: route.path })); +} + +describe("photonChannel", () => { + it("creates the default Photon webhook without eagerly resolving credentials", () => { + const credentials = vi.fn(async () => ({ + projectId: "project-id", + projectSecret: "project-secret", + })); + + const channel = photonChannel({ credentials }); + + expect(credentials).not.toHaveBeenCalled(); + expect(routes(channel)).toEqual([ + { method: "GET", path: "/eve/v1/photon" }, + { method: "POST", path: "/eve/v1/photon" }, + ]); + }); + + it("supports a custom webhook route", () => { + const channel = photonChannel({ + credentials: async () => ({ + projectId: "project-id", + projectSecret: "project-secret", + }), + route: "/hooks/imessage", + }); + + expect(routes(channel)).toEqual([ + { method: "GET", path: "/hooks/imessage" }, + { method: "POST", path: "/hooks/imessage" }, + ]); + }); +}); diff --git a/packages/eve/src/public/channels/photon/photonChannel.ts b/packages/eve/src/public/channels/photon/photonChannel.ts new file mode 100644 index 000000000..0379f7896 --- /dev/null +++ b/packages/eve/src/public/channels/photon/photonChannel.ts @@ -0,0 +1,126 @@ +import type { SessionAuthContext } from "#channel/types.js"; +import { vercelOidc } from "#public/channels/auth.js"; +import { + chatSdkChannel, + messageToUserContent, + type ChatSdkChannel, + type ChatSdkChannelBridge, + type ChatSdkChannelEvents, +} from "#public/channels/chat-sdk/index.js"; +import type { Message, Thread } from "#compiled/chat/index.js"; +import { createMemoryState } from "#compiled/@chat-adapter/state-memory/index.js"; +import { + createiMessageAdapter, + type iMessageAdapter, + type iMessageCredentialProvider, + type iMessageWebhookVerifier, +} from "#compiled/@photon-ai/chat-adapter-imessage/index.js"; + +/** Photon project credentials used by {@link photonChannel}. */ +export type PhotonChannelCredentials = iMessageCredentialProvider; + +/** Context passed to {@link PhotonChannelConfig.onMessage}. */ +export interface PhotonInboundMessageContext { + /** Returns whether this iMessage thread already has an active eve session. */ + isSubscribed(): Promise; + /** Low-level Chat SDK thread for iMessage-specific operations. */ + readonly thread: Thread; +} + +/** Result of {@link PhotonChannelConfig.onMessage}. Return `null` to drop the message. */ +export type PhotonInboundResult = { + readonly auth: SessionAuthContext | null; + readonly context?: readonly string[]; +} | null; + +/** Sync or async {@link PhotonInboundResult}. */ +export type PhotonInboundResultOrPromise = PhotonInboundResult | Promise; + +/** Configuration for {@link photonChannel}. */ +export interface PhotonChannelConfig { + /** Lazy Photon project credentials, such as `connectPhotonCredentials(...)`. */ + readonly credentials: PhotonChannelCredentials; + /** Per-event overrides for the underlying Chat SDK channel. */ + readonly events?: ChatSdkChannelEvents<{ imessage: iMessageAdapter }>; + /** Inbound message policy. Defaults to dispatching every message with no user auth. */ + readonly onMessage?: ( + ctx: PhotonInboundMessageContext, + message: Message, + ) => PhotonInboundResultOrPromise; + /** Override the default webhook route (`/eve/v1/photon`). */ + readonly route?: string; + /** Display name used by the Chat SDK runtime. Defaults to `"eve"`. */ + readonly userName?: string; + /** Trusted webhook verifier. Defaults to same-project Vercel OIDC. */ + readonly webhookVerifier?: iMessageWebhookVerifier; +} + +/** First-class eve channel backed by Photon iMessage. */ +export interface PhotonChannel extends ChatSdkChannel {} + +/** + * Creates an eve channel for Photon-powered iMessage. + * + * @example + * ```ts + * import { connectPhotonCredentials } from "@vercel/connect/eve"; + * import { photonChannel } from "eve/channels/photon"; + * + * export default photonChannel({ + * credentials: connectPhotonCredentials(process.env.PHOTON_CONNECTOR_ID!), + * }); + * ``` + */ +export function photonChannel(config: PhotonChannelConfig): PhotonChannel { + const imessage = createiMessageAdapter({ + credentials: config.credentials, + webhookVerifier: config.webhookVerifier ?? vercelOidc(), + }); + const bridge = chatSdkChannel({ + adapters: { imessage }, + events: config.events, + routes: { imessage: config.route ?? "/eve/v1/photon" }, + state: createMemoryState(), + streaming: false, + userName: config.userName ?? "eve", + }); + const onMessage = config.onMessage ?? defaultOnMessage; + + bridge.bot.onNewMention(async (thread: Thread, message: Message) => { + await dispatchMessage(bridge, onMessage, thread, message, true); + }); + bridge.bot.onSubscribedMessage(async (thread: Thread, message: Message) => { + await dispatchMessage(bridge, onMessage, thread, message, false); + }); + + return bridge.channel; +} + +async function defaultOnMessage(): Promise { + return { auth: null }; +} + +async function dispatchMessage( + bridge: ChatSdkChannelBridge<{ imessage: iMessageAdapter }>, + onMessage: NonNullable, + thread: Thread, + message: Message, + subscribe: boolean, +): Promise { + const result = await onMessage( + { + isSubscribed: () => thread.isSubscribed(), + thread, + }, + message, + ); + if (result === null) return; + if (subscribe) await thread.subscribe(); + await bridge.send( + { + context: [...(result.context ?? [])], + message: messageToUserContent(message), + }, + { auth: result.auth, thread }, + ); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 736b8879b..509e6b608 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1258,6 +1258,9 @@ importers: '@opentelemetry/sdk-trace-base': specifier: 'catalog:' version: 2.6.1(@opentelemetry/api@1.9.1) + '@photon-ai/chat-adapter-imessage': + specifier: 3.2.0 + version: 3.2.0(ai@7.0.38(zod@4.4.3))(chat@4.34.0(ai@7.0.38(zod@4.4.3))(supports-color@10.2.2)(zod@4.4.3))(supports-color@10.2.2)(typescript@7.0.2)(zod@4.4.3) '@standard-schema/spec': specifier: 1.1.0 version: 1.1.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 42ad44eb8..094e16b51 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -79,6 +79,7 @@ minimumReleaseAgeExclude: - chat - chat-adapter-sendblue - "@ai-sdk/*" + - "@photon-ai/chat-adapter-imessage" - "@rolldown/*" - "@typescript/typescript-*" - "@turbo/*" From 16bdd4e9259e6985a14ba93a6de4e67977dc8cf0 Mon Sep 17 00:00:00 2001 From: owenkephart Date: Tue, 28 Jul 2026 13:24:17 -0700 Subject: [PATCH 2/6] fix(eve): complete Photon channel defaults Signed-off-by: owenkephart --- docs/channels/photon.mdx | 13 +- .../public/channels/photon/photonChannel.ts | 34 ++++-- pnpm-lock.yaml | 112 +++++++++++++++++- 3 files changed, 142 insertions(+), 17 deletions(-) diff --git a/docs/channels/photon.mdx b/docs/channels/photon.mdx index 26039307e..9dec08b18 100644 --- a/docs/channels/photon.mdx +++ b/docs/channels/photon.mdx @@ -17,23 +17,26 @@ export default photonChannel({ ``` The default webhook route is `/eve/v1/photon`. The channel verifies forwarded -webhooks with same-project Vercel OIDC by default, subscribes the first inbound -message thread, and continues the same eve session for later messages. +webhooks with same-project Vercel OIDC by default, marks accepted messages as +read, subscribes the first inbound message thread, and continues the same eve +session for later messages. Customize inbound dispatch with `onMessage`. Return `null` to ignore a message: ```ts export default photonChannel({ credentials: connectPhotonCredentials(process.env.PHOTON_CONNECTOR_ID!), - async onMessage(ctx, message) { + onMessage(_ctx, message) { if (message.author.isBot) return null; return { auth: null, - context: ctx.isSubscribed() ? [] : [`The sender is ${message.author.fullName}.`], + context: [`The sender is ${message.author.fullName}.`], }; }, }); ``` Set `route` to override the webhook path or `webhookVerifier` to use a different -trusted-forwarder verifier. +trusted-forwarder verifier. For direct Photon webhooks, pass `webhookSecret` or +set `IMESSAGE_WEBHOOK_SECRET`; the signing secret takes precedence over the +default OIDC verifier. diff --git a/packages/eve/src/public/channels/photon/photonChannel.ts b/packages/eve/src/public/channels/photon/photonChannel.ts index 0379f7896..0e9db2e90 100644 --- a/packages/eve/src/public/channels/photon/photonChannel.ts +++ b/packages/eve/src/public/channels/photon/photonChannel.ts @@ -21,8 +21,6 @@ export type PhotonChannelCredentials = iMessageCredentialProvider; /** Context passed to {@link PhotonChannelConfig.onMessage}. */ export interface PhotonInboundMessageContext { - /** Returns whether this iMessage thread already has an active eve session. */ - isSubscribed(): Promise; /** Low-level Chat SDK thread for iMessage-specific operations. */ readonly thread: Thread; } @@ -51,7 +49,9 @@ export interface PhotonChannelConfig { readonly route?: string; /** Display name used by the Chat SDK runtime. Defaults to `"eve"`. */ readonly userName?: string; - /** Trusted webhook verifier. Defaults to same-project Vercel OIDC. */ + /** Photon webhook signing secret. Falls back to `IMESSAGE_WEBHOOK_SECRET`. */ + readonly webhookSecret?: string; + /** Trusted webhook verifier. Takes precedence over `webhookSecret`. */ readonly webhookVerifier?: iMessageWebhookVerifier; } @@ -72,9 +72,14 @@ export interface PhotonChannel extends ChatSdkChannel {} * ``` */ export function photonChannel(config: PhotonChannelConfig): PhotonChannel { + const webhookSecret = config.webhookSecret ?? process.env.IMESSAGE_WEBHOOK_SECRET; const imessage = createiMessageAdapter({ credentials: config.credentials, - webhookVerifier: config.webhookVerifier ?? vercelOidc(), + ...(config.webhookVerifier + ? { webhookVerifier: config.webhookVerifier } + : webhookSecret + ? { webhookSecret } + : { webhookVerifier: vercelOidc() }), }); const bridge = chatSdkChannel({ adapters: { imessage }, @@ -107,14 +112,9 @@ async function dispatchMessage( message: Message, subscribe: boolean, ): Promise { - const result = await onMessage( - { - isSubscribed: () => thread.isSubscribed(), - thread, - }, - message, - ); + const result = await onMessage({ thread }, message); if (result === null) return; + await markReadBestEffort(bridge.bot.getAdapter("imessage"), thread, message); if (subscribe) await thread.subscribe(); await bridge.send( { @@ -124,3 +124,15 @@ async function dispatchMessage( { auth: result.auth, thread }, ); } + +async function markReadBestEffort( + adapter: iMessageAdapter, + thread: Thread, + message: Message, +): Promise { + try { + await adapter.markRead(thread.id, message.id); + } catch { + // A read receipt should never prevent the user's message from reaching eve. + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 509e6b608..d5dfacd61 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1377,7 +1377,7 @@ importers: version: 7.8.4 shadcn: specifier: 4.16.0 - version: 4.16.0(supports-color@10.2.2)(typescript@6.0.3) + version: 4.16.0(supports-color@10.2.2)(typescript@7.0.2) svelte: specifier: ^5.0.0 version: 5.56.1(@typescript-eslint/types@8.59.4) @@ -18427,6 +18427,20 @@ snapshots: - typescript - zod + '@photon-ai/chat-adapter-imessage@3.2.0(ai@7.0.38(zod@4.4.3))(chat@4.34.0(ai@7.0.38(zod@4.4.3))(supports-color@10.2.2)(zod@4.4.3))(supports-color@10.2.2)(typescript@7.0.2)(zod@4.4.3)': + dependencies: + '@chat-adapter/shared': 4.34.0(ai@7.0.38(zod@4.4.3))(supports-color@10.2.2)(zod@4.4.3) + '@spectrum-ts/core': 10.0.0(supports-color@10.2.2)(typescript@7.0.2) + '@spectrum-ts/imessage': 10.0.0(@spectrum-ts/core@10.0.0(supports-color@10.2.2)(typescript@7.0.2))(supports-color@10.2.2)(typescript@7.0.2) + chat: 4.34.0(ai@7.0.38(zod@4.4.3))(supports-color@10.2.2)(zod@4.4.3) + mime-types: 3.0.2 + transitivePeerDependencies: + - ai + - ffmpeg-static + - supports-color + - typescript + - zod + '@photon-ai/otel@3.3.0(supports-color@10.2.2)(typescript@6.0.3)': dependencies: '@opentelemetry/api': 1.9.1 @@ -18448,6 +18462,27 @@ snapshots: transitivePeerDependencies: - supports-color + '@photon-ai/otel@3.3.0(supports-color@10.2.2)(typescript@7.0.2)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.218.0 + '@opentelemetry/context-async-hooks': 2.10.0(@opentelemetry/api@1.9.1) + '@opentelemetry/core': 2.10.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-logs-otlp-http': 0.218.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-metrics-otlp-http': 0.218.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-trace-otlp-http': 0.218.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.10.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-logs': 0.218.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-metrics': 2.10.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': 2.10.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.43.0 + typescript: 7.0.2 + optionalDependencies: + '@opentelemetry/instrumentation': 0.219.0(@opentelemetry/api@1.9.1)(supports-color@10.2.2) + '@opentelemetry/instrumentation-undici': 0.29.0(@opentelemetry/api@1.9.1)(supports-color@10.2.2) + transitivePeerDependencies: + - supports-color + '@photon-ai/proto@0.2.4': dependencies: '@bufbuild/protobuf': 2.13.0 @@ -19835,6 +19870,20 @@ snapshots: transitivePeerDependencies: - supports-color + '@spectrum-ts/core@10.0.0(supports-color@10.2.2)(typescript@7.0.2)': + dependencies: + '@photon-ai/otel': 3.3.0(supports-color@10.2.2)(typescript@7.0.2) + '@photon-ai/proto': 0.2.4 + '@repeaterjs/repeater': 3.1.0 + marked: 18.0.7 + mime-types: 3.0.2 + open-graph-scraper: 6.12.0 + typescript: 7.0.2 + vcf: 2.1.2 + zod: 4.4.3 + transitivePeerDependencies: + - supports-color + '@spectrum-ts/imessage@10.0.0(@spectrum-ts/core@10.0.0(supports-color@10.2.2)(typescript@6.0.3))(supports-color@10.2.2)(typescript@6.0.3)': dependencies: '@photon-ai/advanced-imessage': 1.0.0 @@ -19847,6 +19896,18 @@ snapshots: transitivePeerDependencies: - supports-color + '@spectrum-ts/imessage@10.0.0(@spectrum-ts/core@10.0.0(supports-color@10.2.2)(typescript@7.0.2))(supports-color@10.2.2)(typescript@7.0.2)': + dependencies: + '@photon-ai/advanced-imessage': 1.0.0 + '@photon-ai/otel': 3.3.0(supports-color@10.2.2)(typescript@7.0.2) + '@spectrum-ts/core': 10.0.0(supports-color@10.2.2)(typescript@7.0.2) + lru-cache: 11.5.2 + marked: 18.0.7 + typescript: 7.0.2 + zod: 4.4.3 + transitivePeerDependencies: + - supports-color + '@speed-highlight/core@1.2.17': {} '@stablelib/base64@1.0.1': {} @@ -22586,6 +22647,15 @@ snapshots: optionalDependencies: typescript: 6.0.3 + cosmiconfig@9.0.2(typescript@7.0.2): + dependencies: + env-paths: 2.2.1 + import-fresh: 3.3.1 + js-yaml: 4.1.1 + parse-json: 5.2.0 + optionalDependencies: + typescript: 7.0.2 + crc-32@1.2.2: {} crc32-stream@6.0.0: @@ -29276,6 +29346,46 @@ snapshots: - supports-color - typescript + shadcn@4.16.0(supports-color@10.2.2)(typescript@7.0.2): + dependencies: + '@babel/core': 7.29.0(supports-color@10.2.2) + '@babel/parser': 7.29.7 + '@babel/plugin-transform-typescript': 7.29.7(@babel/core@7.29.0(supports-color@10.2.2))(supports-color@10.2.2) + '@babel/preset-typescript': 7.29.7(@babel/core@7.29.0(supports-color@10.2.2))(supports-color@10.2.2) + '@dotenvx/dotenvx': 1.75.1 + '@modelcontextprotocol/sdk': 1.29.0(supports-color@10.2.2)(zod@3.25.76) + '@types/validate-npm-package-name': 4.0.2 + browserslist: 4.28.6 + commander: 14.0.3 + cosmiconfig: 9.0.2(typescript@7.0.2) + dedent: 1.7.2 + deepmerge: 4.3.1 + diff: 8.0.4 + execa: 9.6.1 + fast-glob: 3.3.3 + fs-extra: 11.3.6 + fuzzysort: 3.1.0 + kleur: 4.1.5 + open: 11.0.0 + ora: 8.2.0 + postcss: 8.5.15 + postcss-selector-parser: 7.1.4 + prompts: 2.4.2 + recast: 0.23.12 + stringify-object: 5.0.0 + tailwind-merge: 3.6.0 + ts-morph: 26.0.0 + tsconfig-paths: 4.2.0 + undici: 7.28.0 + validate-npm-package-name: 7.0.2 + zod: 3.25.76 + zod-to-json-schema: 3.25.2(zod@3.25.76) + transitivePeerDependencies: + - '@cfworker/json-schema' + - babel-plugin-macros + - supports-color + - typescript + sharp@0.34.5: dependencies: '@img/colour': 1.1.0 From 39fa36d5aa47a38c27c0c466af40fd2a55c11162 Mon Sep 17 00:00:00 2001 From: owenkephart Date: Thu, 30 Jul 2026 14:12:11 -0700 Subject: [PATCH 3/6] fix(eve): stabilize Photon inbound messages Signed-off-by: owenkephart --- .changeset/calm-photons-queue.md | 5 +++ .../channels/photon/inboundContent.test.ts | 40 +++++++++++++++++++ .../public/channels/photon/inboundContent.ts | 13 ++++++ .../public/channels/photon/photonChannel.ts | 7 +++- 4 files changed, 63 insertions(+), 2 deletions(-) create mode 100644 .changeset/calm-photons-queue.md create mode 100644 packages/eve/src/public/channels/photon/inboundContent.test.ts create mode 100644 packages/eve/src/public/channels/photon/inboundContent.ts diff --git a/.changeset/calm-photons-queue.md b/.changeset/calm-photons-queue.md new file mode 100644 index 000000000..5e2fb9ef2 --- /dev/null +++ b/.changeset/calm-photons-queue.md @@ -0,0 +1,5 @@ +--- +"eve": patch +--- + +Queue overlapping Photon messages instead of dropping them, and ignore inbound iMessage events that contain no model-visible content. diff --git a/packages/eve/src/public/channels/photon/inboundContent.test.ts b/packages/eve/src/public/channels/photon/inboundContent.test.ts new file mode 100644 index 000000000..c031c075e --- /dev/null +++ b/packages/eve/src/public/channels/photon/inboundContent.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vitest"; + +import { Message } from "#compiled/chat/index.js"; +import { photonInboundContent } from "#public/channels/photon/inboundContent.js"; + +function message(text: string, attachments: Message["attachments"] = []): Message { + return new Message({ + attachments, + author: { isBot: false, isMe: false, userId: "user", userName: "user" }, + id: "message-id", + raw: {}, + text, + threadId: "thread-id", + }); +} + +describe("photonInboundContent", () => { + it("returns plain text", () => { + expect(photonInboundContent(message("hello"))).toBe("hello"); + }); + + it("drops blank messages", () => { + expect(photonInboundContent(message(" \n"))).toBeUndefined(); + }); + + it("drops attachment-only messages when Photon provides no attachment URL", () => { + expect( + photonInboundContent( + message("", [ + { + mimeType: "image/jpeg", + name: "photo.jpg", + size: 10, + type: "image", + }, + ]), + ), + ).toBeUndefined(); + }); +}); diff --git a/packages/eve/src/public/channels/photon/inboundContent.ts b/packages/eve/src/public/channels/photon/inboundContent.ts new file mode 100644 index 000000000..6f5dec1c6 --- /dev/null +++ b/packages/eve/src/public/channels/photon/inboundContent.ts @@ -0,0 +1,13 @@ +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 Photon content, or `undefined` for non-message events. */ +export function photonInboundContent(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/photon/photonChannel.ts b/packages/eve/src/public/channels/photon/photonChannel.ts index 0e9db2e90..4374c01b5 100644 --- a/packages/eve/src/public/channels/photon/photonChannel.ts +++ b/packages/eve/src/public/channels/photon/photonChannel.ts @@ -2,7 +2,6 @@ import type { SessionAuthContext } from "#channel/types.js"; import { vercelOidc } from "#public/channels/auth.js"; import { chatSdkChannel, - messageToUserContent, type ChatSdkChannel, type ChatSdkChannelBridge, type ChatSdkChannelEvents, @@ -15,6 +14,7 @@ import { type iMessageCredentialProvider, type iMessageWebhookVerifier, } from "#compiled/@photon-ai/chat-adapter-imessage/index.js"; +import { photonInboundContent } from "#public/channels/photon/inboundContent.js"; /** Photon project credentials used by {@link photonChannel}. */ export type PhotonChannelCredentials = iMessageCredentialProvider; @@ -83,6 +83,7 @@ export function photonChannel(config: PhotonChannelConfig): PhotonChannel { }); const bridge = chatSdkChannel({ adapters: { imessage }, + concurrency: "queue", events: config.events, routes: { imessage: config.route ?? "/eve/v1/photon" }, state: createMemoryState(), @@ -115,11 +116,13 @@ async function dispatchMessage( const result = await onMessage({ thread }, message); if (result === null) return; await markReadBestEffort(bridge.bot.getAdapter("imessage"), thread, message); + const content = photonInboundContent(message); + if (content === undefined) return; if (subscribe) await thread.subscribe(); await bridge.send( { context: [...(result.context ?? [])], - message: messageToUserContent(message), + message: content, }, { auth: result.auth, thread }, ); From 0afdb83bffd97f022f4a7adcb48532c1853b954c Mon Sep 17 00:00:00 2001 From: owenkephart Date: Thu, 30 Jul 2026 14:12:29 -0700 Subject: [PATCH 4/6] feat(eve): name the Photon iMessage channel explicitly Signed-off-by: owenkephart --- docs/channels/photon.mdx | 12 ++++----- .../eve/src/public/channels/photon/index.ts | 10 +++---- ....test.ts => photonIMessageChannel.test.ts} | 8 +++--- ...tonChannel.ts => photonIMessageChannel.ts} | 26 +++++++++---------- 4 files changed, 28 insertions(+), 28 deletions(-) rename packages/eve/src/public/channels/photon/{photonChannel.test.ts => photonIMessageChannel.test.ts} (82%) rename packages/eve/src/public/channels/photon/{photonChannel.ts => photonIMessageChannel.ts} (82%) diff --git a/docs/channels/photon.mdx b/docs/channels/photon.mdx index 9dec08b18..eb6ac34b2 100644 --- a/docs/channels/photon.mdx +++ b/docs/channels/photon.mdx @@ -3,16 +3,16 @@ title: Photon description: Connect an eve agent to iMessage through Photon. --- -Use `photonChannel` to receive and reply to iMessages through a Photon project. +Use `photonIMessageChannel` to receive and reply to iMessages through a Photon project. With Vercel Connect, the channel resolves credentials lazily when the adapter first initializes: ```ts title="agent/channels/photon.ts" import { connectPhotonCredentials } from "@vercel/connect/eve"; -import { photonChannel } from "eve/channels/photon"; +import { photonIMessageChannel } from "eve/channels/photon"; -export default photonChannel({ - credentials: connectPhotonCredentials(process.env.PHOTON_CONNECTOR_ID!), +export default photonIMessageChannel({ + credentials: connectPhotonCredentials("photon/my-agent"), }); ``` @@ -24,8 +24,8 @@ session for later messages. Customize inbound dispatch with `onMessage`. Return `null` to ignore a message: ```ts -export default photonChannel({ - credentials: connectPhotonCredentials(process.env.PHOTON_CONNECTOR_ID!), +export default photonIMessageChannel({ + credentials: connectPhotonCredentials("photon/my-agent"), onMessage(_ctx, message) { if (message.author.isBot) return null; return { diff --git a/packages/eve/src/public/channels/photon/index.ts b/packages/eve/src/public/channels/photon/index.ts index c76cbdeef..249b38e0c 100644 --- a/packages/eve/src/public/channels/photon/index.ts +++ b/packages/eve/src/public/channels/photon/index.ts @@ -1,9 +1,9 @@ export { - photonChannel, - type PhotonChannel, - type PhotonChannelConfig, - type PhotonChannelCredentials, + photonIMessageChannel, + type PhotonIMessageChannel, + type PhotonIMessageChannelConfig, + type PhotonIMessageChannelCredentials, type PhotonInboundMessageContext, type PhotonInboundResult, type PhotonInboundResultOrPromise, -} from "#public/channels/photon/photonChannel.js"; +} from "#public/channels/photon/photonIMessageChannel.js"; diff --git a/packages/eve/src/public/channels/photon/photonChannel.test.ts b/packages/eve/src/public/channels/photon/photonIMessageChannel.test.ts similarity index 82% rename from packages/eve/src/public/channels/photon/photonChannel.test.ts rename to packages/eve/src/public/channels/photon/photonIMessageChannel.test.ts index b200dd4c2..c1cfafad5 100644 --- a/packages/eve/src/public/channels/photon/photonChannel.test.ts +++ b/packages/eve/src/public/channels/photon/photonIMessageChannel.test.ts @@ -1,21 +1,21 @@ import { describe, expect, it, vi } from "vitest"; import { isCompiledChannel } from "#channel/compiled-channel.js"; -import { photonChannel } from "#public/channels/photon/photonChannel.js"; +import { photonIMessageChannel } from "#public/channels/photon/photonIMessageChannel.js"; function routes(channel: unknown): Array<{ method: string; path: string }> { if (!isCompiledChannel(channel)) throw new Error("Expected compiled channel."); return channel.routes.map((route) => ({ method: route.method, path: route.path })); } -describe("photonChannel", () => { +describe("photonIMessageChannel", () => { it("creates the default Photon webhook without eagerly resolving credentials", () => { const credentials = vi.fn(async () => ({ projectId: "project-id", projectSecret: "project-secret", })); - const channel = photonChannel({ credentials }); + const channel = photonIMessageChannel({ credentials }); expect(credentials).not.toHaveBeenCalled(); expect(routes(channel)).toEqual([ @@ -25,7 +25,7 @@ describe("photonChannel", () => { }); it("supports a custom webhook route", () => { - const channel = photonChannel({ + const channel = photonIMessageChannel({ credentials: async () => ({ projectId: "project-id", projectSecret: "project-secret", diff --git a/packages/eve/src/public/channels/photon/photonChannel.ts b/packages/eve/src/public/channels/photon/photonIMessageChannel.ts similarity index 82% rename from packages/eve/src/public/channels/photon/photonChannel.ts rename to packages/eve/src/public/channels/photon/photonIMessageChannel.ts index 4374c01b5..69bdcebc6 100644 --- a/packages/eve/src/public/channels/photon/photonChannel.ts +++ b/packages/eve/src/public/channels/photon/photonIMessageChannel.ts @@ -16,16 +16,16 @@ import { } from "#compiled/@photon-ai/chat-adapter-imessage/index.js"; import { photonInboundContent } from "#public/channels/photon/inboundContent.js"; -/** Photon project credentials used by {@link photonChannel}. */ -export type PhotonChannelCredentials = iMessageCredentialProvider; +/** Photon project credentials used by {@link photonIMessageChannel}. */ +export type PhotonIMessageChannelCredentials = iMessageCredentialProvider; -/** Context passed to {@link PhotonChannelConfig.onMessage}. */ +/** Context passed to {@link PhotonIMessageChannelConfig.onMessage}. */ export interface PhotonInboundMessageContext { /** Low-level Chat SDK thread for iMessage-specific operations. */ readonly thread: Thread; } -/** Result of {@link PhotonChannelConfig.onMessage}. Return `null` to drop the message. */ +/** Result of {@link PhotonIMessageChannelConfig.onMessage}. Return `null` to drop the message. */ export type PhotonInboundResult = { readonly auth: SessionAuthContext | null; readonly context?: readonly string[]; @@ -34,10 +34,10 @@ export type PhotonInboundResult = { /** Sync or async {@link PhotonInboundResult}. */ export type PhotonInboundResultOrPromise = PhotonInboundResult | Promise; -/** Configuration for {@link photonChannel}. */ -export interface PhotonChannelConfig { +/** Configuration for {@link photonIMessageChannel}. */ +export interface PhotonIMessageChannelConfig { /** Lazy Photon project credentials, such as `connectPhotonCredentials(...)`. */ - readonly credentials: PhotonChannelCredentials; + readonly credentials: PhotonIMessageChannelCredentials; /** Per-event overrides for the underlying Chat SDK channel. */ readonly events?: ChatSdkChannelEvents<{ imessage: iMessageAdapter }>; /** Inbound message policy. Defaults to dispatching every message with no user auth. */ @@ -56,7 +56,7 @@ export interface PhotonChannelConfig { } /** First-class eve channel backed by Photon iMessage. */ -export interface PhotonChannel extends ChatSdkChannel {} +export interface PhotonIMessageChannel extends ChatSdkChannel {} /** * Creates an eve channel for Photon-powered iMessage. @@ -64,14 +64,14 @@ export interface PhotonChannel extends ChatSdkChannel {} * @example * ```ts * import { connectPhotonCredentials } from "@vercel/connect/eve"; - * import { photonChannel } from "eve/channels/photon"; + * import { photonIMessageChannel } from "eve/channels/photon"; * - * export default photonChannel({ - * credentials: connectPhotonCredentials(process.env.PHOTON_CONNECTOR_ID!), + * export default photonIMessageChannel({ + * credentials: connectPhotonCredentials("photon/my-agent"), * }); * ``` */ -export function photonChannel(config: PhotonChannelConfig): PhotonChannel { +export function photonIMessageChannel(config: PhotonIMessageChannelConfig): PhotonIMessageChannel { const webhookSecret = config.webhookSecret ?? process.env.IMESSAGE_WEBHOOK_SECRET; const imessage = createiMessageAdapter({ credentials: config.credentials, @@ -108,7 +108,7 @@ async function defaultOnMessage(): Promise { async function dispatchMessage( bridge: ChatSdkChannelBridge<{ imessage: iMessageAdapter }>, - onMessage: NonNullable, + onMessage: NonNullable, thread: Thread, message: Message, subscribe: boolean, From 6fd999bb54ab64666a4a3804ccdae3c88c43d110 Mon Sep 17 00:00:00 2001 From: owenkephart Date: Thu, 30 Jul 2026 14:51:27 -0700 Subject: [PATCH 5/6] fix(eve): subscribe on empty Photon mentions Signed-off-by: owenkephart --- .../photon/photonIMessageChannel.test.ts | 76 +++++++++++-------- .../channels/photon/photonIMessageChannel.ts | 2 +- 2 files changed, 45 insertions(+), 33 deletions(-) diff --git a/packages/eve/src/public/channels/photon/photonIMessageChannel.test.ts b/packages/eve/src/public/channels/photon/photonIMessageChannel.test.ts index c1cfafad5..206ca2ee9 100644 --- a/packages/eve/src/public/channels/photon/photonIMessageChannel.test.ts +++ b/packages/eve/src/public/channels/photon/photonIMessageChannel.test.ts @@ -1,41 +1,53 @@ import { describe, expect, it, vi } from "vitest"; -import { isCompiledChannel } from "#channel/compiled-channel.js"; -import { photonIMessageChannel } from "#public/channels/photon/photonIMessageChannel.js"; - -function routes(channel: unknown): Array<{ method: string; path: string }> { - if (!isCompiledChannel(channel)) throw new Error("Expected compiled channel."); - return channel.routes.map((route) => ({ method: route.method, path: route.path })); -} +const { newMention, send, subscribe } = vi.hoisted(() => ({ + newMention: vi.fn(), + send: vi.fn(), + subscribe: vi.fn(), +})); -describe("photonIMessageChannel", () => { - it("creates the default Photon webhook without eagerly resolving credentials", () => { - const credentials = vi.fn(async () => ({ - projectId: "project-id", - projectSecret: "project-secret", - })); +vi.mock("#public/channels/chat-sdk/index.js", () => ({ + chatSdkChannel: () => ({ + bot: { + getAdapter: () => ({ markRead: vi.fn() }), + onNewMention: newMention, + onSubscribedMessage: vi.fn(), + }, + channel: { routes: [] }, + send, + }), + messageToUserContent: (message: Message) => message.text, +})); +vi.mock("#compiled/@chat-adapter/state-memory/index.js", () => ({ + createMemoryState: vi.fn(), +})); +vi.mock("#compiled/@photon-ai/chat-adapter-imessage/index.js", () => ({ + createiMessageAdapter: vi.fn(), +})); +vi.mock("#public/channels/auth.js", () => ({ vercelOidc: vi.fn() })); - const channel = photonIMessageChannel({ credentials }); - - expect(credentials).not.toHaveBeenCalled(); - expect(routes(channel)).toEqual([ - { method: "GET", path: "/eve/v1/photon" }, - { method: "POST", path: "/eve/v1/photon" }, - ]); - }); +import { photonIMessageChannel } from "#public/channels/photon/photonIMessageChannel.js"; +import { Message } from "#compiled/chat/index.js"; - it("supports a custom webhook route", () => { - const channel = photonIMessageChannel({ - credentials: async () => ({ - projectId: "project-id", - projectSecret: "project-secret", - }), - route: "/hooks/imessage", +describe("photonIMessageChannel", () => { + it("subscribes to a blank first mention before dropping it", async () => { + photonIMessageChannel({ + credentials: async () => ({ projectId: "project-id", projectSecret: "project-secret" }), }); + const handler = newMention.mock.calls[0]?.[0]; + if (handler === undefined) throw new Error("Expected an inbound mention handler."); + const thread = { id: "thread-id", subscribe }; + const message = new Message({ + author: { isBot: false, isMe: false, userId: "user", userName: "user" }, + id: "message-id", + raw: {}, + text: " \n", + threadId: thread.id, + }); + + await handler(thread, message); - expect(routes(channel)).toEqual([ - { method: "GET", path: "/hooks/imessage" }, - { method: "POST", path: "/hooks/imessage" }, - ]); + expect(subscribe).toHaveBeenCalledOnce(); + expect(send).not.toHaveBeenCalled(); }); }); diff --git a/packages/eve/src/public/channels/photon/photonIMessageChannel.ts b/packages/eve/src/public/channels/photon/photonIMessageChannel.ts index 69bdcebc6..3761a071f 100644 --- a/packages/eve/src/public/channels/photon/photonIMessageChannel.ts +++ b/packages/eve/src/public/channels/photon/photonIMessageChannel.ts @@ -115,10 +115,10 @@ async function dispatchMessage( ): Promise { const result = await onMessage({ thread }, message); if (result === null) return; + if (subscribe) await thread.subscribe(); await markReadBestEffort(bridge.bot.getAdapter("imessage"), thread, message); const content = photonInboundContent(message); if (content === undefined) return; - if (subscribe) await thread.subscribe(); await bridge.send( { context: [...(result.context ?? [])], From f559aa8ed8a064a51824f3ca052fa8a94a1b3f34 Mon Sep 17 00:00:00 2001 From: owenkephart Date: Thu, 30 Jul 2026 15:37:21 -0700 Subject: [PATCH 6/6] docs(eve): name the Photon iMessage channel in changesets Signed-off-by: owenkephart --- .changeset/calm-photons-queue.md | 5 -- .changeset/friendly-photons-wave.md | 2 +- docs/channels/photon.mdx | 5 +- .../photon/photonIMessageChannel.test.ts | 74 ++++++++++++++++--- .../channels/photon/photonIMessageChannel.ts | 14 ++-- 5 files changed, 73 insertions(+), 27 deletions(-) delete mode 100644 .changeset/calm-photons-queue.md diff --git a/.changeset/calm-photons-queue.md b/.changeset/calm-photons-queue.md deleted file mode 100644 index 5e2fb9ef2..000000000 --- a/.changeset/calm-photons-queue.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"eve": patch ---- - -Queue overlapping Photon messages instead of dropping them, and ignore inbound iMessage events that contain no model-visible content. diff --git a/.changeset/friendly-photons-wave.md b/.changeset/friendly-photons-wave.md index 1188816e0..a649b994a 100644 --- a/.changeset/friendly-photons-wave.md +++ b/.changeset/friendly-photons-wave.md @@ -2,4 +2,4 @@ "eve": patch --- -Add `photonChannel`, a first-class Photon iMessage channel with lazy credentials, Vercel OIDC webhook verification, and automatic eve session routing. +Add `photonIMessageChannel`, a first-class Photon iMessage channel with lazy credentials, Vercel OIDC webhook verification, and automatic eve session routing. diff --git a/docs/channels/photon.mdx b/docs/channels/photon.mdx index eb6ac34b2..04645a156 100644 --- a/docs/channels/photon.mdx +++ b/docs/channels/photon.mdx @@ -18,8 +18,9 @@ export default photonIMessageChannel({ The default webhook route is `/eve/v1/photon`. The channel verifies forwarded webhooks with same-project Vercel OIDC by default, marks accepted messages as -read, subscribes the first inbound message thread, and continues the same eve -session for later messages. +read, and continues the same eve session for every message in an iMessage +conversation. A new accepted message cooperatively cancels an active turn and +steers its replacement turn. Customize inbound dispatch with `onMessage`. Return `null` to ignore a message: diff --git a/packages/eve/src/public/channels/photon/photonIMessageChannel.test.ts b/packages/eve/src/public/channels/photon/photonIMessageChannel.test.ts index 206ca2ee9..e52be32b9 100644 --- a/packages/eve/src/public/channels/photon/photonIMessageChannel.test.ts +++ b/packages/eve/src/public/channels/photon/photonIMessageChannel.test.ts @@ -1,17 +1,17 @@ -import { describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; -const { newMention, send, subscribe } = vi.hoisted(() => ({ - newMention: vi.fn(), +const { directMessage, newMessage, send } = vi.hoisted(() => ({ + directMessage: vi.fn(), + newMessage: vi.fn(), send: vi.fn(), - subscribe: vi.fn(), })); vi.mock("#public/channels/chat-sdk/index.js", () => ({ chatSdkChannel: () => ({ bot: { getAdapter: () => ({ markRead: vi.fn() }), - onNewMention: newMention, - onSubscribedMessage: vi.fn(), + onDirectMessage: directMessage, + onNewMessage: newMessage, }, channel: { routes: [] }, send, @@ -30,13 +30,40 @@ import { photonIMessageChannel } from "#public/channels/photon/photonIMessageCha import { Message } from "#compiled/chat/index.js"; describe("photonIMessageChannel", () => { - it("subscribes to a blank first mention before dropping it", async () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("cancels the active Eve turn before steering a direct message into its thread", async () => { + photonIMessageChannel({ + credentials: async () => ({ projectId: "project-id", projectSecret: "project-secret" }), + }); + const handler = directMessage.mock.calls[0]?.[0]; + if (handler === undefined) throw new Error("Expected an inbound direct-message handler."); + const thread = { id: "thread-id" }; + const message = new Message({ + author: { isBot: false, isMe: false, userId: "user", userName: "user" }, + id: "message-id", + raw: {}, + text: "Steer this response", + threadId: thread.id, + }); + + await handler(thread, message); + + expect(send).toHaveBeenCalledWith( + { context: [], message: "Steer this response" }, + { auth: null, thread, turnPolicy: "experimental-steer" }, + ); + }); + + it("drops blank inbound messages without cancelling or sending", async () => { photonIMessageChannel({ credentials: async () => ({ projectId: "project-id", projectSecret: "project-secret" }), }); - const handler = newMention.mock.calls[0]?.[0]; - if (handler === undefined) throw new Error("Expected an inbound mention handler."); - const thread = { id: "thread-id", subscribe }; + const handler = directMessage.mock.calls[0]?.[0]; + if (handler === undefined) throw new Error("Expected an inbound direct-message handler."); + const thread = { id: "thread-id" }; const message = new Message({ author: { isBot: false, isMe: false, userId: "user", userName: "user" }, id: "message-id", @@ -47,7 +74,32 @@ describe("photonIMessageChannel", () => { await handler(thread, message); - expect(subscribe).toHaveBeenCalledOnce(); expect(send).not.toHaveBeenCalled(); }); + + it("routes group messages without a Chat SDK subscription", async () => { + photonIMessageChannel({ + credentials: async () => ({ projectId: "project-id", projectSecret: "project-secret" }), + }); + const [pattern, handler] = newMessage.mock.calls[0] ?? []; + if (!(pattern instanceof RegExp) || handler === undefined) { + throw new Error("Expected an inbound group-message handler."); + } + const thread = { id: "group-thread-id" }; + const message = new Message({ + author: { isBot: false, isMe: false, userId: "user", userName: "user" }, + id: "message-id", + raw: {}, + text: "Hello group", + threadId: thread.id, + }); + + expect(pattern.test(message.text)).toBe(true); + await handler(thread, message); + + expect(send).toHaveBeenCalledWith( + { context: [], message: "Hello group" }, + { auth: null, thread, turnPolicy: "experimental-steer" }, + ); + }); }); diff --git a/packages/eve/src/public/channels/photon/photonIMessageChannel.ts b/packages/eve/src/public/channels/photon/photonIMessageChannel.ts index 3761a071f..f3f93c3c8 100644 --- a/packages/eve/src/public/channels/photon/photonIMessageChannel.ts +++ b/packages/eve/src/public/channels/photon/photonIMessageChannel.ts @@ -83,7 +83,7 @@ export function photonIMessageChannel(config: PhotonIMessageChannelConfig): Phot }); const bridge = chatSdkChannel({ adapters: { imessage }, - concurrency: "queue", + concurrency: "concurrent", events: config.events, routes: { imessage: config.route ?? "/eve/v1/photon" }, state: createMemoryState(), @@ -92,11 +92,11 @@ export function photonIMessageChannel(config: PhotonIMessageChannelConfig): Phot }); const onMessage = config.onMessage ?? defaultOnMessage; - bridge.bot.onNewMention(async (thread: Thread, message: Message) => { - await dispatchMessage(bridge, onMessage, thread, message, true); + bridge.bot.onDirectMessage(async (thread: Thread, message: Message) => { + await dispatchMessage(bridge, onMessage, thread, message); }); - bridge.bot.onSubscribedMessage(async (thread: Thread, message: Message) => { - await dispatchMessage(bridge, onMessage, thread, message, false); + bridge.bot.onNewMessage(/[\s\S]*/, async (thread: Thread, message: Message) => { + await dispatchMessage(bridge, onMessage, thread, message); }); return bridge.channel; @@ -111,11 +111,9 @@ async function dispatchMessage( onMessage: NonNullable, thread: Thread, message: Message, - subscribe: boolean, ): Promise { const result = await onMessage({ thread }, message); if (result === null) return; - if (subscribe) await thread.subscribe(); await markReadBestEffort(bridge.bot.getAdapter("imessage"), thread, message); const content = photonInboundContent(message); if (content === undefined) return; @@ -124,7 +122,7 @@ async function dispatchMessage( context: [...(result.context ?? [])], message: content, }, - { auth: result.auth, thread }, + { auth: result.auth, thread, turnPolicy: "experimental-steer" }, ); }