Skip to content

Commit 3133b23

Browse files
committed
feat(integrations): add iMessage via Photon
Adds an iMessage integration backed by Photon (spectrum-ts), covering both directions: a send tool and a signed webhook trigger for inbound messages. Photon reaches iMessage over gRPC through the @spectrum-ts/imessage SDK rather than a plain REST call, so the send runs in an internal route. Spectrum instances are cached per project and evicted LRU, because constructing one mints cloud credentials and opens a connection per line. Inbound deliveries are verified with Photon's own portable verifier (@spectrum-ts/core/webhook), which implements the documented v0 scheme: HMAC-SHA256 over "v0:<timestamp>:<rawBody>" with a 5-minute replay window. Verification fails closed when no secret is configured — an inbound message can drive an agent, so an unverified delivery must never reach one. Deliveries are at-least-once, so runs are deduped on the message id. Payload mapping is taken from the SlimEnvelope schema in photon-hq/spectrum-ts rather than inferred, and a test asserts formatInput's keys match the trigger outputs exactly.
1 parent b440f4e commit 3133b23

76 files changed

Lines changed: 6804 additions & 6 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/docs/components/icons.tsx

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1042,6 +1042,17 @@ export function PerplexityIcon(props: SVGProps<SVGSVGElement>) {
10421042
)
10431043
}
10441044

1045+
export function PhotonIcon(props: SVGProps<SVGSVGElement>) {
1046+
return (
1047+
<svg width='1em' height='1em' viewBox='2 5 24 24' xmlns='http://www.w3.org/2000/svg' {...props}>
1048+
<path
1049+
d='M21.56 18.452H24.192C24.668 18.452 25.004 18.9 24.892 19.32L22.876 27.328C22.82 27.636 22.54 27.86 22.204 27.86H19.544C19.124 27.86 18.788 27.412 18.872 26.992L20.888 18.984C20.972 18.676 21.224 18.452 21.56 18.452ZM16.24 6.02H18.872C19.348 6.02 19.684 6.468 19.544 6.888L17.584 14.896C17.472 15.204 17.192 15.428 16.856 15.428H14.252C13.776 15.428 13.44 14.98 13.58 14.56L15.568 6.552C15.624 6.244 15.932 6.02 16.24 6.02ZM5.712 11.788H8.344C8.792 11.788 9.128 12.236 9.016 12.684L7.028 20.664C6.944 20.972 6.664 21.196 6.356 21.196H3.696C3.248 21.196 2.884 20.776 3.024 20.328L5.012 12.348C5.096 12.012 5.376 11.788 5.712 11.788Z'
1050+
fill='currentColor'
1051+
/>
1052+
</svg>
1053+
)
1054+
}
1055+
10451056
export function ObsidianIcon(props: SVGProps<SVGSVGElement>) {
10461057
const id = useId()
10471058
const bl = `${id}-bl`

apps/docs/components/ui/icon-mapping.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,7 @@ import {
179179
PeopleDataLabsIcon,
180180
PerplexityIcon,
181181
PersonaIcon,
182+
PhotonIcon,
182183
PineconeIcon,
183184
PipedriveIcon,
184185
PitchBookIcon,
@@ -475,6 +476,7 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
475476
peopledatalabs: PeopleDataLabsIcon,
476477
perplexity: PerplexityIcon,
477478
persona: PersonaIcon,
479+
photon_imessage: PhotonIcon,
478480
pinecone: PineconeIcon,
479481
pipedrive: PipedriveIcon,
480482
pitchbook: PitchBookIcon,

apps/docs/content/docs/en/integrations/meta.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,7 @@
190190
"peopledatalabs",
191191
"perplexity",
192192
"persona",
193+
"photon_imessage",
193194
"pinecone",
194195
"pipedrive",
195196
"pipedrive-service-account",

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

Lines changed: 585 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { createLogger } from '@sim/logger'
2+
import { getErrorMessage } from '@sim/utils/errors'
3+
import { type NextRequest, NextResponse } from 'next/server'
4+
import { photonImessageAddParticipantContract } from '@/lib/api/contracts/tools/photon-imessage'
5+
import { parseRequest } from '@/lib/api/server'
6+
import { checkInternalAuth } from '@/lib/auth/hybrid'
7+
import { generateRequestId } from '@/lib/core/utils/request'
8+
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
9+
import { addPhotonParticipant } from '@/app/api/tools/photon_imessage/utils'
10+
11+
export const dynamic = 'force-dynamic'
12+
13+
const logger = createLogger('PhotonImessageAddParticipantAPI')
14+
15+
export const POST = withRouteHandler(async (request: NextRequest) => {
16+
const requestId = generateRequestId()
17+
18+
try {
19+
const authResult = await checkInternalAuth(request, { requireWorkflowId: false })
20+
if (!authResult.success) {
21+
logger.warn(`[${requestId}] Unauthorized Photon participant add attempt: ${authResult.error}`)
22+
return NextResponse.json(
23+
{ success: false, error: authResult.error || 'Authentication required' },
24+
{ status: 401 }
25+
)
26+
}
27+
28+
const parsed = await parseRequest(photonImessageAddParticipantContract, request, {})
29+
if (!parsed.success) return parsed.response
30+
const body = parsed.data.body
31+
32+
const output = await addPhotonParticipant({ ...body })
33+
return NextResponse.json({ success: true, output })
34+
} catch (error) {
35+
const errorMessage = getErrorMessage(error, 'Unknown error occurred')
36+
logger.error(`[${requestId}] Photon participant add failed:`, error)
37+
38+
return NextResponse.json(
39+
{ success: false, error: `Photon participant add failed: ${errorMessage}` },
40+
{ status: 500 }
41+
)
42+
}
43+
})
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import { createLogger } from '@sim/logger'
2+
import { getErrorMessage } from '@sim/utils/errors'
3+
import { type NextRequest, NextResponse } from 'next/server'
4+
import { photonImessageChatBackgroundContract } from '@/lib/api/contracts/tools/photon-imessage'
5+
import { parseRequest } from '@/lib/api/server'
6+
import { checkInternalAuth } from '@/lib/auth/hybrid'
7+
import { generateRequestId } from '@/lib/core/utils/request'
8+
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
9+
import { materializePhotonFile } from '@/app/api/tools/photon_imessage/route-helpers'
10+
import { setPhotonChatBackground } from '@/app/api/tools/photon_imessage/utils'
11+
12+
export const dynamic = 'force-dynamic'
13+
14+
const logger = createLogger('PhotonImessageChatBackgroundAPI')
15+
16+
export const POST = withRouteHandler(async (request: NextRequest) => {
17+
const requestId = generateRequestId()
18+
19+
try {
20+
const authResult = await checkInternalAuth(request, { requireWorkflowId: false })
21+
if (!authResult.success) {
22+
logger.warn(
23+
`[${requestId}] Unauthorized Photon chat background update attempt: ${authResult.error}`
24+
)
25+
return NextResponse.json(
26+
{ success: false, error: authResult.error || 'Authentication required' },
27+
{ status: 401 }
28+
)
29+
}
30+
31+
const parsed = await parseRequest(photonImessageChatBackgroundContract, request, {})
32+
if (!parsed.success) return parsed.response
33+
const body = parsed.data.body
34+
35+
if (body.clear) {
36+
const output = await setPhotonChatBackground({
37+
projectId: body.projectId,
38+
projectSecret: body.projectSecret,
39+
to: body.to,
40+
clear: true,
41+
})
42+
return NextResponse.json({ success: true, output })
43+
}
44+
45+
const file = await materializePhotonFile(body, { requestId, userId: authResult.userId })
46+
if (file instanceof NextResponse) return file
47+
48+
const output = await setPhotonChatBackground({
49+
projectId: body.projectId,
50+
projectSecret: body.projectSecret,
51+
to: body.to,
52+
fileBuffer: file.buffer,
53+
fileName: file.fileName,
54+
mimeType: file.mimeType,
55+
})
56+
return NextResponse.json({ success: true, output })
57+
} catch (error) {
58+
const errorMessage = getErrorMessage(error, 'Unknown error occurred')
59+
logger.error(`[${requestId}] Photon chat background update failed:`, error)
60+
61+
return NextResponse.json(
62+
{ success: false, error: `Photon chat background update failed: ${errorMessage}` },
63+
{ status: 500 }
64+
)
65+
}
66+
})
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { createLogger } from '@sim/logger'
2+
import { getErrorMessage } from '@sim/utils/errors'
3+
import { type NextRequest, NextResponse } from 'next/server'
4+
import { photonImessageCreateGroupContract } from '@/lib/api/contracts/tools/photon-imessage'
5+
import { parseRequest } from '@/lib/api/server'
6+
import { checkInternalAuth } from '@/lib/auth/hybrid'
7+
import { generateRequestId } from '@/lib/core/utils/request'
8+
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
9+
import { createPhotonGroup } from '@/app/api/tools/photon_imessage/utils'
10+
11+
export const dynamic = 'force-dynamic'
12+
13+
const logger = createLogger('PhotonImessageCreateGroupAPI')
14+
15+
export const POST = withRouteHandler(async (request: NextRequest) => {
16+
const requestId = generateRequestId()
17+
18+
try {
19+
const authResult = await checkInternalAuth(request, { requireWorkflowId: false })
20+
if (!authResult.success) {
21+
logger.warn(`[${requestId}] Unauthorized Photon group creation attempt: ${authResult.error}`)
22+
return NextResponse.json(
23+
{ success: false, error: authResult.error || 'Authentication required' },
24+
{ status: 401 }
25+
)
26+
}
27+
28+
const parsed = await parseRequest(photonImessageCreateGroupContract, request, {})
29+
if (!parsed.success) return parsed.response
30+
const body = parsed.data.body
31+
32+
const output = await createPhotonGroup({ ...body })
33+
return NextResponse.json({ success: true, output })
34+
} catch (error) {
35+
const errorMessage = getErrorMessage(error, 'Unknown error occurred')
36+
logger.error(`[${requestId}] Photon group creation failed:`, error)
37+
38+
return NextResponse.json(
39+
{ success: false, error: `Photon group creation failed: ${errorMessage}` },
40+
{ status: 500 }
41+
)
42+
}
43+
})
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { createLogger } from '@sim/logger'
2+
import { getErrorMessage } from '@sim/utils/errors'
3+
import { type NextRequest, NextResponse } from 'next/server'
4+
import { photonImessageCreatePollContract } from '@/lib/api/contracts/tools/photon-imessage'
5+
import { parseRequest } from '@/lib/api/server'
6+
import { checkInternalAuth } from '@/lib/auth/hybrid'
7+
import { generateRequestId } from '@/lib/core/utils/request'
8+
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
9+
import { createPhotonPoll } from '@/app/api/tools/photon_imessage/utils'
10+
11+
export const dynamic = 'force-dynamic'
12+
13+
const logger = createLogger('PhotonImessageCreatePollAPI')
14+
15+
export const POST = withRouteHandler(async (request: NextRequest) => {
16+
const requestId = generateRequestId()
17+
18+
try {
19+
const authResult = await checkInternalAuth(request, { requireWorkflowId: false })
20+
if (!authResult.success) {
21+
logger.warn(`[${requestId}] Unauthorized Photon poll creation attempt: ${authResult.error}`)
22+
return NextResponse.json(
23+
{ success: false, error: authResult.error || 'Authentication required' },
24+
{ status: 401 }
25+
)
26+
}
27+
28+
const parsed = await parseRequest(photonImessageCreatePollContract, request, {})
29+
if (!parsed.success) return parsed.response
30+
const body = parsed.data.body
31+
32+
const output = await createPhotonPoll({ ...body })
33+
return NextResponse.json({ success: true, output })
34+
} catch (error) {
35+
const errorMessage = getErrorMessage(error, 'Unknown error occurred')
36+
logger.error(`[${requestId}] Photon poll creation failed:`, error)
37+
38+
return NextResponse.json(
39+
{ success: false, error: `Photon poll creation failed: ${errorMessage}` },
40+
{ status: 500 }
41+
)
42+
}
43+
})
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import { createLogger } from '@sim/logger'
2+
import { getErrorMessage } from '@sim/utils/errors'
3+
import { type NextRequest, NextResponse } from 'next/server'
4+
import { photonImessageDownloadAttachmentContract } from '@/lib/api/contracts/tools/photon-imessage'
5+
import { parseRequest } from '@/lib/api/server'
6+
import { checkInternalAuth } from '@/lib/auth/hybrid'
7+
import { generateRequestId } from '@/lib/core/utils/request'
8+
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
9+
import { downloadPhotonAttachment } from '@/app/api/tools/photon_imessage/utils'
10+
11+
export const dynamic = 'force-dynamic'
12+
13+
const logger = createLogger('PhotonImessageDownloadAttachmentAPI')
14+
15+
export const POST = withRouteHandler(async (request: NextRequest) => {
16+
const requestId = generateRequestId()
17+
18+
try {
19+
const authResult = await checkInternalAuth(request, { requireWorkflowId: false })
20+
if (!authResult.success) {
21+
logger.warn(
22+
`[${requestId}] Unauthorized Photon attachment download attempt: ${authResult.error}`
23+
)
24+
return NextResponse.json(
25+
{ success: false, error: authResult.error || 'Authentication required' },
26+
{ status: 401 }
27+
)
28+
}
29+
30+
const parsed = await parseRequest(photonImessageDownloadAttachmentContract, request, {})
31+
if (!parsed.success) return parsed.response
32+
const body = parsed.data.body
33+
34+
const output = await downloadPhotonAttachment({ ...body })
35+
return NextResponse.json({ success: true, output })
36+
} catch (error) {
37+
const errorMessage = getErrorMessage(error, 'Unknown error occurred')
38+
logger.error(`[${requestId}] Photon attachment download failed:`, error)
39+
40+
return NextResponse.json(
41+
{ success: false, error: `Photon attachment download failed: ${errorMessage}` },
42+
{ status: 500 }
43+
)
44+
}
45+
})
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { createLogger } from '@sim/logger'
2+
import { getErrorMessage } from '@sim/utils/errors'
3+
import { type NextRequest, NextResponse } from 'next/server'
4+
import { photonImessageEditContract } from '@/lib/api/contracts/tools/photon-imessage'
5+
import { parseRequest } from '@/lib/api/server'
6+
import { checkInternalAuth } from '@/lib/auth/hybrid'
7+
import { generateRequestId } from '@/lib/core/utils/request'
8+
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
9+
import { editPhotonMessage } from '@/app/api/tools/photon_imessage/utils'
10+
11+
export const dynamic = 'force-dynamic'
12+
13+
const logger = createLogger('PhotonImessageEditAPI')
14+
15+
export const POST = withRouteHandler(async (request: NextRequest) => {
16+
const requestId = generateRequestId()
17+
18+
try {
19+
const authResult = await checkInternalAuth(request, { requireWorkflowId: false })
20+
if (!authResult.success) {
21+
logger.warn(`[${requestId}] Unauthorized Photon message edit attempt: ${authResult.error}`)
22+
return NextResponse.json(
23+
{ success: false, error: authResult.error || 'Authentication required' },
24+
{ status: 401 }
25+
)
26+
}
27+
28+
const parsed = await parseRequest(photonImessageEditContract, request, {})
29+
if (!parsed.success) return parsed.response
30+
const body = parsed.data.body
31+
32+
const output = await editPhotonMessage({ ...body })
33+
return NextResponse.json({ success: true, output })
34+
} catch (error) {
35+
const errorMessage = getErrorMessage(error, 'Unknown error occurred')
36+
logger.error(`[${requestId}] Photon message edit failed:`, error)
37+
38+
return NextResponse.json(
39+
{ success: false, error: `Photon message edit failed: ${errorMessage}` },
40+
{ status: 500 }
41+
)
42+
}
43+
})

0 commit comments

Comments
 (0)