Skip to content

Commit 4e11e89

Browse files
committed
fix(integrations): address second round of Photon iMessage review
The 409 re-key branch no longer discards the outcome of the steps it depends on. Re-registering over a stale record that is still there just earns another 409, so a failed listing or a failed DELETE now surfaces as the thing an operator can act on — delete the webhook in the Photon dashboard and redeploy — instead of a second conflict with no explanation. One content walker, not two. `extractText` claimed to mirror the webhook's `collectText` but returned only a group's first text node and counted an empty string as a hit, so Get Message and the trigger could report different `text` for the same grouped delivery. Both surfaces now read through `@/lib/photon-imessage/content`. Sharing it also settles the attachment shape: absent fields are `null` on both sides rather than `null` on one and `''` on the other. This is the third bug from these two copies drifting, so the copies are gone rather than re-aligned. Media file inputs are required for `send_media` and `send_voice_memo`, matching Linq's attachment pattern, so the editor catches a missing file instead of letting the run fail on a route 400. The clearable ops keep an optional file — `imageAction: 'clear'` is a valid way to run those without one. Both members of the canonical pair carry the same condition, as the group requires. Also converts the `messageEffects` declaration comment to TSDoc. Adds 24 tests: the walkers' group/reply/voice handling and the parity the shared module exists to guarantee, plus both new 409-recovery failure paths.
1 parent adbf562 commit 4e11e89

6 files changed

Lines changed: 304 additions & 135 deletions

File tree

apps/sim/app/api/tools/photon_imessage/utils.ts

Lines changed: 12 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,16 @@ import {
2222
} from '@spectrum-ts/core'
2323
import { effect, imessage } from '@spectrum-ts/imessage'
2424
import { LRUCache } from 'lru-cache'
25+
import {
26+
collectPhotonAttachments,
27+
collectPhotonText,
28+
type PhotonAttachmentSummary,
29+
} from '@/lib/photon-imessage/content'
2530

26-
// Apple effect identifiers keyed by friendly name (balloons, confetti, slam, …), exposed as a
27-
// static on the provider callable.
31+
/**
32+
* Apple effect identifiers keyed by friendly name (balloons, confetti, slam, …), exposed as a
33+
* static on the provider callable.
34+
*/
2835
const messageEffects = imessage.effect.message
2936

3037
const logger = createLogger('PhotonImessageClient')
@@ -458,13 +465,6 @@ export async function createPhotonGroup(
458465
})
459466
}
460467

461-
export interface PhotonAttachmentSummary {
462-
id: string | null
463-
name: string | null
464-
mimeType: string | null
465-
size: number | null
466-
}
467-
468468
export interface PhotonMessageDetails {
469469
messageId: string
470470
chatId: string
@@ -475,59 +475,6 @@ export interface PhotonMessageDetails {
475475
attachments: PhotonAttachmentSummary[]
476476
}
477477

478-
/** Depth-first text extraction through reply/group wrappers, mirroring the webhook handler. */
479-
function extractText(content: unknown): string | null {
480-
if (!content || typeof content !== 'object') {
481-
return null
482-
}
483-
const node = content as Record<string, unknown>
484-
if (node.type === 'text' && typeof node.text === 'string') {
485-
return node.text
486-
}
487-
if (node.type === 'reply') {
488-
return extractText(node.content)
489-
}
490-
if (node.type === 'group' && Array.isArray(node.items)) {
491-
for (const item of node.items) {
492-
const found = extractText((item as Record<string, unknown>).content ?? item)
493-
if (found !== null) {
494-
return found
495-
}
496-
}
497-
}
498-
return null
499-
}
500-
501-
/**
502-
* Attachment metadata, mirroring `collectAttachments` in the webhook handler. A native voice memo
503-
* is a distinct content arm carrying the same id/name/mimeType, so both feed Download Attachment.
504-
*/
505-
function extractAttachments(content: unknown): PhotonAttachmentSummary[] {
506-
if (!content || typeof content !== 'object') {
507-
return []
508-
}
509-
const node = content as Record<string, unknown>
510-
if (node.type === 'attachment' || node.type === 'voice') {
511-
return [
512-
{
513-
id: typeof node.id === 'string' ? node.id : null,
514-
name: typeof node.name === 'string' ? node.name : null,
515-
mimeType: typeof node.mimeType === 'string' ? node.mimeType : null,
516-
size: typeof node.size === 'number' ? node.size : null,
517-
},
518-
]
519-
}
520-
if (node.type === 'reply') {
521-
return extractAttachments(node.content)
522-
}
523-
if (node.type === 'group' && Array.isArray(node.items)) {
524-
return node.items.flatMap((item) =>
525-
extractAttachments((item as Record<string, unknown>).content ?? item)
526-
)
527-
}
528-
return []
529-
}
530-
531478
export async function getPhotonMessage(
532479
params: PhotonCredentials & {
533480
chatId: string
@@ -541,11 +488,12 @@ export async function getPhotonMessage(
541488
return {
542489
messageId: message.id,
543490
chatId: space.id,
544-
text: extractText(message.content),
491+
// `''` from the shared walker means "no text"; this surface reports that as null.
492+
text: collectPhotonText(message.content) || null,
545493
contentType: message.content?.type ?? 'unknown',
546494
senderId: message.sender?.id ?? null,
547495
timestamp: message.timestamp?.toISOString() ?? null,
548-
attachments: extractAttachments(message.content),
496+
attachments: collectPhotonAttachments(message.content),
549497
}
550498
})
551499
}

apps/sim/blocks/blocks/photon_imessage.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,12 @@ const FILE_OPS = [
5050

5151
const CLEARABLE_OPS = ['set_group_avatar', 'set_chat_background'] as const
5252

53+
/**
54+
* File operations with no alternative to sending bytes. The clearable ops are excluded because
55+
* `imageAction: 'clear'` is a valid way to run them without a file.
56+
*/
57+
const FILE_REQUIRED_OPS = ['send_media', 'send_voice_memo'] as const
58+
5359
const PARTICIPANT_OPS = ['add_participant', 'remove_participant'] as const
5460

5561
const splitHandles = (value: unknown): string[] =>
@@ -386,6 +392,7 @@ export const PhotonImessageBlock: BlockConfig = {
386392
placeholder: 'Upload a file (max 100MB)',
387393
multiple: false,
388394
condition: { field: 'operation', value: [...FILE_OPS] },
395+
required: { field: 'operation', value: [...FILE_REQUIRED_OPS] },
389396
mode: 'basic',
390397
},
391398
{
@@ -395,6 +402,7 @@ export const PhotonImessageBlock: BlockConfig = {
395402
canonicalParamId: 'file',
396403
placeholder: 'Reference a file from a previous block (e.g. {{block.output.file}})',
397404
condition: { field: 'operation', value: [...FILE_OPS] },
405+
required: { field: 'operation', value: [...FILE_REQUIRED_OPS] },
398406
mode: 'advanced',
399407
},
400408
{
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { describe, expect, it } from 'vitest'
5+
import { collectPhotonAttachments, collectPhotonText } from '@/lib/photon-imessage/content'
6+
7+
const attachmentNode = {
8+
type: 'attachment',
9+
id: 'att-1',
10+
name: 'photo.jpg',
11+
mimeType: 'image/jpeg',
12+
size: 2048,
13+
}
14+
15+
const voiceNode = { type: 'voice', id: 'voice-1', mimeType: 'audio/x-caf' }
16+
17+
describe('collectPhotonText', () => {
18+
it('reads a plain text node', () => {
19+
expect(collectPhotonText({ type: 'text', text: 'hello there' })).toBe('hello there')
20+
})
21+
22+
it('reaches through a reply wrapper to the inner content', () => {
23+
expect(
24+
collectPhotonText({
25+
type: 'reply',
26+
content: { type: 'text', text: 'replying to you' },
27+
target: { id: 'msg-1' },
28+
})
29+
).toBe('replying to you')
30+
})
31+
32+
it('joins every text in a group rather than stopping at the first', () => {
33+
expect(
34+
collectPhotonText({
35+
type: 'group',
36+
items: [
37+
{ content: { type: 'text', text: 'first' } },
38+
{ content: attachmentNode },
39+
{ content: { type: 'text', text: 'second' } },
40+
],
41+
})
42+
).toBe('first\nsecond')
43+
})
44+
45+
it('skips an empty text in a group instead of treating it as the answer', () => {
46+
expect(
47+
collectPhotonText({
48+
type: 'group',
49+
items: [
50+
{ content: { type: 'text', text: '' } },
51+
{ content: { type: 'text', text: 'the real message' } },
52+
],
53+
})
54+
).toBe('the real message')
55+
})
56+
57+
it('accepts a group item that is the content node itself', () => {
58+
expect(collectPhotonText({ type: 'group', items: [{ type: 'text', text: 'unwrapped' }] })).toBe(
59+
'unwrapped'
60+
)
61+
})
62+
63+
it('returns an empty string for content that carries no text', () => {
64+
expect(collectPhotonText(attachmentNode)).toBe('')
65+
expect(collectPhotonText({ type: 'reaction', emoji: '❤️' })).toBe('')
66+
expect(collectPhotonText(null)).toBe('')
67+
expect(collectPhotonText('not a node')).toBe('')
68+
})
69+
})
70+
71+
describe('collectPhotonAttachments', () => {
72+
it('summarizes an attachment node', () => {
73+
expect(collectPhotonAttachments(attachmentNode)).toEqual([
74+
{ id: 'att-1', name: 'photo.jpg', mimeType: 'image/jpeg', size: 2048 },
75+
])
76+
})
77+
78+
it('treats a native voice memo as an attachment, nulling the fields it omits', () => {
79+
expect(collectPhotonAttachments(voiceNode)).toEqual([
80+
{ id: 'voice-1', name: null, mimeType: 'audio/x-caf', size: null },
81+
])
82+
})
83+
84+
it('collects every media node in a group, through a reply wrapper', () => {
85+
expect(
86+
collectPhotonAttachments({
87+
type: 'reply',
88+
content: {
89+
type: 'group',
90+
items: [
91+
{ content: { type: 'text', text: 'look' } },
92+
{ content: attachmentNode },
93+
{ content: voiceNode },
94+
],
95+
},
96+
}).map((attachment) => attachment.id)
97+
).toEqual(['att-1', 'voice-1'])
98+
})
99+
100+
it('returns nothing for content that carries no media', () => {
101+
expect(collectPhotonAttachments({ type: 'text', text: 'hi' })).toEqual([])
102+
expect(collectPhotonAttachments(null)).toEqual([])
103+
})
104+
})
105+
106+
describe('surface parity', () => {
107+
/**
108+
* The webhook handler and the Get Message tool both read a message through these walkers, so a
109+
* grouped delivery reports identically on either — the drift this module exists to prevent.
110+
*/
111+
it('reports one grouped message the same way for both readers', () => {
112+
const grouped = {
113+
type: 'group',
114+
items: [
115+
{ content: { type: 'text', text: 'caption one' } },
116+
{ content: attachmentNode },
117+
{ content: { type: 'text', text: 'caption two' } },
118+
{ content: voiceNode },
119+
],
120+
}
121+
122+
const triggerText = collectPhotonText(grouped)
123+
const toolText = collectPhotonText(grouped) || null
124+
125+
expect(triggerText).toBe('caption one\ncaption two')
126+
expect(toolText).toBe(triggerText)
127+
expect(collectPhotonAttachments(grouped)).toEqual([
128+
{ id: 'att-1', name: 'photo.jpg', mimeType: 'image/jpeg', size: 2048 },
129+
{ id: 'voice-1', name: null, mimeType: 'audio/x-caf', size: null },
130+
])
131+
})
132+
})
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import { isRecordLike } from '@sim/utils/object'
2+
3+
/**
4+
* Walkers over Photon's slim content tree.
5+
*
6+
* Two surfaces read the same message: the webhook handler, which builds trigger outputs, and the
7+
* Get Message tool. A workflow that reads `text` or `attachments` must see the same thing from
8+
* either, so there is one implementation rather than a copy per surface with a comment asking the
9+
* next reader to keep them aligned.
10+
*/
11+
12+
export interface PhotonAttachmentSummary {
13+
id: string | null
14+
name: string | null
15+
mimeType: string | null
16+
size: number | null
17+
}
18+
19+
const asNullableString = (value: unknown): string | null =>
20+
typeof value === 'string' ? value : null
21+
22+
/** A group item is either a `{ content }` wrapper or the content node itself. */
23+
const itemContent = (item: Record<string, unknown>): unknown => item.content ?? item
24+
25+
/**
26+
* The human-readable text of a content tree. A reply carries its own inner content and a group
27+
* carries N items, so the text a workflow wants is not always at the top level. Every non-empty
28+
* text in a group contributes, joined by newlines; `''` means the message carries no text.
29+
*/
30+
export function collectPhotonText(content: unknown): string {
31+
if (!isRecordLike(content)) {
32+
return ''
33+
}
34+
35+
switch (content.type) {
36+
case 'text':
37+
return typeof content.text === 'string' ? content.text : ''
38+
case 'reply':
39+
return collectPhotonText(content.content)
40+
case 'group': {
41+
const items = Array.isArray(content.items) ? content.items : []
42+
return items
43+
.map((item) => (isRecordLike(item) ? collectPhotonText(itemContent(item)) : ''))
44+
.filter(Boolean)
45+
.join('\n')
46+
}
47+
default:
48+
return ''
49+
}
50+
}
51+
52+
/**
53+
* Attachment metadata for every media node in a content tree. A native voice memo is its own
54+
* content arm carrying the same id/name/mimeType, and both feed the Download Attachment operation.
55+
*
56+
* Only metadata: bytes stay behind the platform and are fetched on demand, which a webhook payload
57+
* cannot do.
58+
*/
59+
export function collectPhotonAttachments(content: unknown): PhotonAttachmentSummary[] {
60+
if (!isRecordLike(content)) {
61+
return []
62+
}
63+
64+
switch (content.type) {
65+
case 'attachment':
66+
case 'voice':
67+
return [
68+
{
69+
id: asNullableString(content.id),
70+
name: asNullableString(content.name),
71+
mimeType: asNullableString(content.mimeType),
72+
size: typeof content.size === 'number' ? content.size : null,
73+
},
74+
]
75+
case 'reply':
76+
return collectPhotonAttachments(content.content)
77+
case 'group': {
78+
const items = Array.isArray(content.items) ? content.items : []
79+
return items.flatMap((item) =>
80+
isRecordLike(item) ? collectPhotonAttachments(itemContent(item)) : []
81+
)
82+
}
83+
default:
84+
return []
85+
}
86+
}

0 commit comments

Comments
 (0)