Skip to content

Commit adbf562

Browse files
committed
fix(integrations): address Photon iMessage review findings
Restores type-check: `downloadServableFileFromStorage` made `maxBytes` required upstream, and the merge at a3e3f43 left the Photon media helper calling it with three arguments. Passing the 100MB ceiling also bounds the upload read — the declared size is checked before any bytes move — closing the upload-side twin of the download issue reviewers flagged. Outbound deliveries no longer reach a trigger. Photon echoes our own sends back on the same webhook tagged `outbound`, so a reply workflow could be re-triggered by its own reply and loop. `shouldSkipEvent` drops them before routing, testing for an explicit `outbound` rather than for `inbound` so a delivery that omits the optional field keeps firing as it does today. Eviction can no longer close a connection mid-operation. The hand-rolled Map LRU becomes an `LRUCache` (per .claude/rules/sim-caching.md) whose entries carry a lease count: eviction only marks an entry, and `stop()` runs when the last in-flight operation releases it. All 20 operations run through `withPhotonContext`, which holds the lease in a `finally`. The entry is published before the first await, so concurrent callers for one project share a single construction instead of racing two connections. Also: `getPhotonMessage` surfaces native voice memos as attachments (with `size`), matching the webhook's `collectAttachments`, so Download Attachment can be driven from its output; oversized attachments are rejected on their declared size before any bytes are fetched, with the post-read check kept as a backstop; only an `UnsupportedError` maps to the dedicated-line message, so auth, network, and invalid-handle failures keep their own cause; the webhook fixtures drop 11 `as any` casts for the real handler context types; and the local `isRecord` gives way to `isRecordLike` from `@sim/utils/object`. Adds 11 tests: pooling reuse, the lease-vs-eviction race, failed-construction eviction, voice attachments through reply/group wrappers, both size-limit paths, both group-create error paths, and the direction filter.
1 parent a3e3f43 commit adbf562

12 files changed

Lines changed: 747 additions & 309 deletions

File tree

apps/docs/content/docs/en/integrations/photon_imessage.mdx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -401,7 +401,7 @@ Fetch a single message with its text, sender, and attachment metadata
401401
| `contentType` | string | Content kind \(text, attachment, reaction, and so on\) |
402402
| `senderId` | string | Sender handle |
403403
| `timestamp` | string | ISO 8601 send time |
404-
| `attachments` | json | Attachment metadata as an array of \{ id, name, mimeType \} |
404+
| `attachments` | json | Attachment metadata as an array of \{ id, name, mimeType, size \}, covering media and voice memos |
405405

406406
### Download Attachment
407407

@@ -475,7 +475,7 @@ A **Trigger** is a block that starts a workflow when an event happens in this se
475475

476476
### Photon Any Event
477477

478-
Trigger workflow on every Photon delivery: messages, tapbacks, and read receipts
478+
Trigger workflow on every inbound Photon delivery: messages, tapbacks, and read receipts
479479

480480
#### Configuration
481481

apps/sim/app/api/tools/photon_imessage/route-helpers.ts

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { createLogger } from '@sim/logger'
22
import { getErrorMessage } from '@sim/utils/errors'
33
import { NextResponse } from 'next/server'
4+
import { isPayloadSizeLimitError } from '@/lib/core/utils/stream-limits'
45
import { processFilesToUserFiles, type RawFileInput } from '@/lib/uploads/utils/file-utils'
56
import { downloadServableFileFromStorage } from '@/lib/uploads/utils/file-utils.server'
67
import { docNotReadyResponse } from '@/lib/uploads/utils/servable-file-response'
@@ -16,6 +17,16 @@ export interface PhotonRouteContext {
1617
/** Attachments ride the gRPC stream; cap uploads the same way Linq caps its pre-upload. */
1718
const MAX_UPLOAD_BYTES = 100 * 1024 * 1024
1819

20+
function fileTooLargeError(sizeBytes: number): NextResponse {
21+
return NextResponse.json(
22+
{
23+
success: false,
24+
error: `File exceeds the 100MB attachment limit (${(sizeBytes / (1024 * 1024)).toFixed(2)}MB)`,
25+
},
26+
{ status: 400 }
27+
)
28+
}
29+
1930
export interface MaterializedFile {
2031
buffer: Buffer
2132
fileName: string
@@ -59,14 +70,19 @@ export async function materializePhotonFile(
5970
const denied = await assertToolFileAccess(userFile.key, ctx.userId, ctx.requestId, logger)
6071
if (denied) return denied
6172
try {
62-
const resolved = await downloadServableFileFromStorage(userFile, ctx.requestId, logger)
73+
const resolved = await downloadServableFileFromStorage(userFile, ctx.requestId, logger, {
74+
maxBytes: MAX_UPLOAD_BYTES,
75+
})
6376
buffer = resolved.buffer
6477
if (!resolvedContentType) {
6578
resolvedContentType = resolved.contentType || userFile.type || 'application/octet-stream'
6679
}
6780
} catch (error) {
6881
const notReady = docNotReadyResponse(error)
6982
if (notReady) return notReady
83+
if (isPayloadSizeLimitError(error)) {
84+
return fileTooLargeError(error.observedBytes ?? userFile.size)
85+
}
7086
logger.error(`[${ctx.requestId}] Failed to download Photon media file:`, error)
7187
return NextResponse.json(
7288
{ success: false, error: getErrorMessage(error, 'Unknown error occurred') },
@@ -86,13 +102,7 @@ export async function materializePhotonFile(
86102
return NextResponse.json({ success: false, error: 'File is empty' }, { status: 400 })
87103
}
88104
if (buffer.length > MAX_UPLOAD_BYTES) {
89-
return NextResponse.json(
90-
{
91-
success: false,
92-
error: `File exceeds the 100MB attachment limit (${(buffer.length / (1024 * 1024)).toFixed(2)}MB)`,
93-
},
94-
{ status: 400 }
95-
)
105+
return fileTooLargeError(buffer.length)
96106
}
97107

98108
return { buffer, fileName: resolvedFilename, mimeType: resolvedContentType }
Lines changed: 282 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,282 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { beforeEach, describe, expect, it, vi } from 'vitest'
5+
6+
/**
7+
* The real SDK opens gRPC connections on construction, so both Spectrum packages are mocked. The
8+
* mocks are deliberately thin: these tests cover the pooling, attachment, and error-mapping logic
9+
* this module owns, not the provider's behavior.
10+
*/
11+
const {
12+
MockUnsupportedError,
13+
contentBuilder,
14+
mockGetAttachment,
15+
mockGetMessage,
16+
mockSpaceCreate,
17+
mockSpaceGet,
18+
mockSpectrum,
19+
mockStop,
20+
} = vi.hoisted(() => ({
21+
MockUnsupportedError: class MockUnsupportedError extends Error {},
22+
contentBuilder: (type: string) => vi.fn((...args: unknown[]) => ({ type, args })),
23+
mockGetAttachment: vi.fn(),
24+
mockGetMessage: vi.fn(),
25+
mockSpaceCreate: vi.fn(),
26+
mockSpaceGet: vi.fn(),
27+
mockSpectrum: vi.fn(),
28+
mockStop: vi.fn(),
29+
}))
30+
31+
vi.mock('@spectrum-ts/core', () => ({
32+
Spectrum: mockSpectrum,
33+
UnsupportedError: MockUnsupportedError,
34+
addMember: contentBuilder('addMember'),
35+
attachment: contentBuilder('attachment'),
36+
avatar: contentBuilder('avatar'),
37+
edit: contentBuilder('edit'),
38+
leaveSpace: contentBuilder('leaveSpace'),
39+
poll: contentBuilder('poll'),
40+
reaction: contentBuilder('reaction'),
41+
read: contentBuilder('read'),
42+
removeMember: contentBuilder('removeMember'),
43+
rename: contentBuilder('rename'),
44+
reply: contentBuilder('reply'),
45+
text: contentBuilder('text'),
46+
typing: contentBuilder('typing'),
47+
unsend: contentBuilder('unsend'),
48+
voice: contentBuilder('voice'),
49+
}))
50+
51+
vi.mock('@spectrum-ts/imessage', () => ({
52+
effect: contentBuilder('effect'),
53+
imessage: Object.assign(
54+
vi.fn(() => ({
55+
space: { get: mockSpaceGet, create: mockSpaceCreate },
56+
user: vi.fn(async (handle: string) => ({ id: handle })),
57+
getAttachment: mockGetAttachment,
58+
shareContactCard: vi.fn(),
59+
background: vi.fn(),
60+
})),
61+
{
62+
config: vi.fn(() => ({ platform: 'imessage' })),
63+
effect: { message: { balloons: 'com.apple.messages.effect.CKBalloonEffect' } },
64+
}
65+
),
66+
}))
67+
68+
import {
69+
createPhotonGroup,
70+
downloadPhotonAttachment,
71+
getPhotonMessage,
72+
} from '@/app/api/tools/photon_imessage/utils'
73+
74+
const CREDS = { projectId: 'proj-1', projectSecret: 'secret-1' }
75+
76+
/** A chat GUID addresses an existing space directly, so no DM is created along the way. */
77+
const CHAT_ID = 'any;-;+14155551234'
78+
79+
/** Mirrors the pool ceiling in the module under test. */
80+
const MAX_CACHED_INSTANCES = 8
81+
82+
/** Point `space.get` at a space whose single message carries {@link content}. */
83+
function stubMessage(content: unknown) {
84+
mockSpectrum.mockResolvedValue({ stop: mockStop })
85+
mockGetMessage.mockResolvedValue({
86+
id: 'msg-1',
87+
content,
88+
sender: { id: '+14155551234' },
89+
timestamp: new Date('2026-08-21T10:00:00.000Z'),
90+
})
91+
mockSpaceGet.mockResolvedValue({ id: CHAT_ID, getMessage: mockGetMessage, send: vi.fn() })
92+
}
93+
94+
describe('photon iMessage tool client', () => {
95+
beforeEach(() => {
96+
vi.clearAllMocks()
97+
mockStop.mockResolvedValue(undefined)
98+
})
99+
100+
describe('instance pooling', () => {
101+
it('reuses one instance across calls for the same project', async () => {
102+
stubMessage({ type: 'text', text: 'hi' })
103+
104+
await getPhotonMessage({ ...CREDS, chatId: CHAT_ID, messageId: 'msg-1' })
105+
await getPhotonMessage({ ...CREDS, chatId: CHAT_ID, messageId: 'msg-1' })
106+
107+
expect(mockSpectrum).toHaveBeenCalledTimes(1)
108+
})
109+
110+
it('never stops an instance while an operation is still using it', async () => {
111+
// Push the held project past the pool ceiling while its own download is still in flight, so
112+
// `stop()` on that instance must wait for the download to finish.
113+
let releaseDownload: (() => void) | undefined
114+
mockGetAttachment.mockImplementationOnce(async () => {
115+
await new Promise<void>((resolve) => {
116+
releaseDownload = resolve
117+
})
118+
return {
119+
name: 'a.jpg',
120+
mimeType: 'image/jpeg',
121+
size: 1,
122+
read: async () => Buffer.from('x'),
123+
}
124+
})
125+
stubMessage({ type: 'text', text: 'hi' })
126+
127+
// A dedicated spy for the held instance: evicting the other pooled projects legitimately
128+
// stops those, and only this one must survive until its download completes.
129+
const heldStop = vi.fn().mockResolvedValue(undefined)
130+
mockSpectrum.mockResolvedValueOnce({ stop: heldStop })
131+
132+
const held = downloadPhotonAttachment({
133+
projectId: 'held-project',
134+
projectSecret: 's',
135+
attachmentId: 'att-1',
136+
})
137+
// Let the held download reach its await before filling the pool behind it.
138+
await vi.waitFor(() => expect(releaseDownload).toBeDefined())
139+
140+
for (let index = 0; index < MAX_CACHED_INSTANCES; index += 1) {
141+
await getPhotonMessage({
142+
projectId: `project-${index}`,
143+
projectSecret: 's',
144+
chatId: CHAT_ID,
145+
messageId: 'msg-1',
146+
})
147+
}
148+
149+
expect(heldStop).not.toHaveBeenCalled()
150+
151+
releaseDownload?.()
152+
await expect(held).resolves.toMatchObject({ attachmentId: 'att-1' })
153+
154+
await vi.waitFor(() => expect(heldStop).toHaveBeenCalledTimes(1))
155+
})
156+
157+
it('does not cache a failed construction', async () => {
158+
mockSpectrum.mockRejectedValueOnce(new Error('bad credentials'))
159+
160+
await expect(
161+
getPhotonMessage({
162+
projectId: 'transient',
163+
projectSecret: 's',
164+
chatId: CHAT_ID,
165+
messageId: 'msg-1',
166+
})
167+
).rejects.toThrow('bad credentials')
168+
169+
stubMessage({ type: 'text', text: 'hi' })
170+
await expect(
171+
getPhotonMessage({
172+
projectId: 'transient',
173+
projectSecret: 's',
174+
chatId: CHAT_ID,
175+
messageId: 'msg-1',
176+
})
177+
).resolves.toMatchObject({ messageId: 'msg-1' })
178+
})
179+
})
180+
181+
describe('getPhotonMessage attachments', () => {
182+
it('surfaces a native voice memo so Download Attachment can be driven from it', async () => {
183+
stubMessage({
184+
type: 'voice',
185+
id: 'voice-1',
186+
name: 'Audio Message.caf',
187+
mimeType: 'audio/x-caf',
188+
size: 2048,
189+
})
190+
191+
const result = await getPhotonMessage({ ...CREDS, chatId: CHAT_ID, messageId: 'msg-1' })
192+
193+
expect(result.attachments).toEqual([
194+
{ id: 'voice-1', name: 'Audio Message.caf', mimeType: 'audio/x-caf', size: 2048 },
195+
])
196+
})
197+
198+
it('walks reply and group wrappers the way the webhook handler does', async () => {
199+
stubMessage({
200+
type: 'reply',
201+
content: {
202+
type: 'group',
203+
items: [
204+
{ content: { type: 'text', text: 'look' } },
205+
{
206+
content: {
207+
type: 'attachment',
208+
id: 'att-1',
209+
name: 'a.jpg',
210+
mimeType: 'image/jpeg',
211+
size: 10,
212+
},
213+
},
214+
{ content: { type: 'voice', id: 'voice-1', mimeType: 'audio/x-caf' } },
215+
],
216+
},
217+
})
218+
219+
const result = await getPhotonMessage({ ...CREDS, chatId: CHAT_ID, messageId: 'msg-1' })
220+
221+
expect(result.attachments.map((attachment) => attachment.id)).toEqual(['att-1', 'voice-1'])
222+
expect(result.attachments[1]).toEqual({
223+
id: 'voice-1',
224+
name: null,
225+
mimeType: 'audio/x-caf',
226+
size: null,
227+
})
228+
})
229+
})
230+
231+
describe('downloadPhotonAttachment', () => {
232+
it('rejects an oversized attachment on its declared size, before reading any bytes', async () => {
233+
const read = vi.fn()
234+
mockGetAttachment.mockResolvedValue({
235+
name: 'huge.mov',
236+
mimeType: 'video/quicktime',
237+
size: 60 * 1024 * 1024,
238+
read,
239+
})
240+
stubMessage({ type: 'text', text: 'hi' })
241+
242+
await expect(
243+
downloadPhotonAttachment({ ...CREDS, attachmentId: 'att-huge' })
244+
).rejects.toThrow(/above the 50MB download limit/)
245+
expect(read).not.toHaveBeenCalled()
246+
})
247+
248+
it('re-checks the delivered bytes when the provider declares no size', async () => {
249+
mockGetAttachment.mockResolvedValue({
250+
name: 'huge.mov',
251+
mimeType: 'video/quicktime',
252+
size: undefined,
253+
read: async () => Buffer.alloc(51 * 1024 * 1024),
254+
})
255+
stubMessage({ type: 'text', text: 'hi' })
256+
257+
await expect(
258+
downloadPhotonAttachment({ ...CREDS, attachmentId: 'att-unknown' })
259+
).rejects.toThrow(/above the 50MB download limit/)
260+
})
261+
})
262+
263+
describe('createPhotonGroup', () => {
264+
it('explains the dedicated-line requirement when the provider says unsupported', async () => {
265+
stubMessage({ type: 'text', text: 'hi' })
266+
mockSpaceCreate.mockRejectedValue(new MockUnsupportedError('group create is remote-only'))
267+
268+
await expect(
269+
createPhotonGroup({ ...CREDS, handles: ['+15551112222', '+15553334444'] })
270+
).rejects.toThrow(/requires a dedicated Photon line/)
271+
})
272+
273+
it('leaves every other failure with its own cause', async () => {
274+
stubMessage({ type: 'text', text: 'hi' })
275+
mockSpaceCreate.mockRejectedValue(new Error('Unauthorized: invalid project secret'))
276+
277+
await expect(
278+
createPhotonGroup({ ...CREDS, handles: ['+15551112222', '+15553334444'] })
279+
).rejects.toThrow('Unauthorized: invalid project secret')
280+
})
281+
})
282+
})

0 commit comments

Comments
 (0)