Skip to content
Merged
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 `photonIMessageChannel`, 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
43 changes: 43 additions & 0 deletions docs/channels/photon.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
---
title: Photon
description: Connect an eve agent to iMessage through Photon.
---

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 { photonIMessageChannel } from "eve/channels/photon";

export default photonIMessageChannel({
credentials: connectPhotonCredentials("photon/my-agent"),
});
```

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, 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:

```ts
export default photonIMessageChannel({
credentials: connectPhotonCredentials("photon/my-agent"),
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 @@ -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",
Expand Down Expand Up @@ -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",
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
40 changes: 40 additions & 0 deletions packages/eve/src/public/channels/photon/inboundContent.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
13 changes: 13 additions & 0 deletions packages/eve/src/public/channels/photon/inboundContent.ts
Original file line number Diff line number Diff line change
@@ -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;
}
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 {
photonIMessageChannel,
type PhotonIMessageChannel,
type PhotonIMessageChannelConfig,
type PhotonIMessageChannelCredentials,
type PhotonInboundMessageContext,
type PhotonInboundResult,
type PhotonInboundResultOrPromise,
} from "#public/channels/photon/photonIMessageChannel.js";
105 changes: 105 additions & 0 deletions packages/eve/src/public/channels/photon/photonIMessageChannel.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import { beforeEach, describe, expect, it, vi } from "vitest";

const { directMessage, newMessage, send } = vi.hoisted(() => ({
directMessage: vi.fn(),
newMessage: vi.fn(),
send: vi.fn(),
}));

vi.mock("#public/channels/chat-sdk/index.js", () => ({
chatSdkChannel: () => ({
bot: {
getAdapter: () => ({ markRead: vi.fn() }),
onDirectMessage: directMessage,
onNewMessage: newMessage,
},
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() }));

import { photonIMessageChannel } from "#public/channels/photon/photonIMessageChannel.js";
import { Message } from "#compiled/chat/index.js";

describe("photonIMessageChannel", () => {
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 = 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: " \n",
threadId: thread.id,
});

await handler(thread, message);

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" },
);
});
});
Loading
Loading