Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/friendly-photons-wave.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions docs/channels/meta.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
"overview",
"eve",
"slack",
"photon",
"discord",
"teams",
"telegram",
Expand Down
42 changes: 42 additions & 0 deletions docs/channels/photon.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
---
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, 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!),
onMessage(_ctx, message) {
if (message.author.isBot) return null;
return {
auth: null,
context: [`The sender is ${message.author.fullName}.`],
};
},
});
```

Set `route` to override the webhook path or `webhookVerifier` to use a different
trusted-forwarder verifier. For direct Photon webhooks, pass `webhookSecret` or
set `IMESSAGE_WEBHOOK_SECRET`; the signing secret takes precedence over the
default OIDC verifier.
6 changes: 6 additions & 0 deletions packages/eve/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,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",
Expand Down Expand Up @@ -317,6 +322,7 @@
"@nuxt/kit": "^4.0.0",
"@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",
Expand Down
Original file line number Diff line number Diff line change
@@ -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",
},
},
}),
};
2 changes: 2 additions & 0 deletions packages/eve/scripts/vendor-compiled/index.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -69,6 +70,7 @@ export const MODULES = [
opentelemetryApi,
opentelemetryOtlpTransformer,
otel,
photonChatAdapterIMessage,
picocolors,
provider,
providerUtils,
Expand Down
9 changes: 9 additions & 0 deletions packages/eve/src/public/channels/photon/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
export {
photonChannel,
type PhotonChannel,
type PhotonChannelConfig,
type PhotonChannelCredentials,
type PhotonInboundMessageContext,
type PhotonInboundResult,
type PhotonInboundResultOrPromise,
} from "#public/channels/photon/photonChannel.js";
41 changes: 41 additions & 0 deletions packages/eve/src/public/channels/photon/photonChannel.test.ts
Original file line number Diff line number Diff line change
@@ -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" },
]);
});
});
138 changes: 138 additions & 0 deletions packages/eve/src/public/channels/photon/photonChannel.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
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 {
/** 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<PhotonInboundResult>;

/** 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;
/** Photon webhook signing secret. Falls back to `IMESSAGE_WEBHOOK_SECRET`. */
readonly webhookSecret?: string;
/** Trusted webhook verifier. Takes precedence over `webhookSecret`. */
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 webhookSecret = config.webhookSecret ?? process.env.IMESSAGE_WEBHOOK_SECRET;
const imessage = createiMessageAdapter({
credentials: config.credentials,
...(config.webhookVerifier
? { webhookVerifier: config.webhookVerifier }
: webhookSecret
? { webhookSecret }
: { 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<PhotonInboundResult> {
return { auth: null };
}

async function dispatchMessage(
bridge: ChatSdkChannelBridge<{ imessage: iMessageAdapter }>,
onMessage: NonNullable<PhotonChannelConfig["onMessage"]>,
thread: Thread,
message: Message,
subscribe: boolean,
): Promise<void> {
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(
{
context: [...(result.context ?? [])],
message: messageToUserContent(message),
},
{ auth: result.auth, thread },
);
}

async function markReadBestEffort(
adapter: iMessageAdapter,
thread: Thread,
message: Message,
): Promise<void> {
try {
await adapter.markRead(thread.id, message.id);
} catch {
// A read receipt should never prevent the user's message from reaching eve.
}
}
Loading
Loading