agent-messenger 2.15.1 → 2.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +1 -1
  3. package/dist/package.json +1 -1
  4. package/dist/src/platforms/kakaotalk/attachment-router.d.ts +25 -0
  5. package/dist/src/platforms/kakaotalk/attachment-router.d.ts.map +1 -0
  6. package/dist/src/platforms/kakaotalk/attachment-router.js +29 -0
  7. package/dist/src/platforms/kakaotalk/attachment-router.js.map +1 -0
  8. package/dist/src/platforms/kakaotalk/client.d.ts +14 -1
  9. package/dist/src/platforms/kakaotalk/client.d.ts.map +1 -1
  10. package/dist/src/platforms/kakaotalk/client.js +216 -0
  11. package/dist/src/platforms/kakaotalk/client.js.map +1 -1
  12. package/dist/src/platforms/kakaotalk/commands/message.d.ts.map +1 -1
  13. package/dist/src/platforms/kakaotalk/commands/message.js +49 -0
  14. package/dist/src/platforms/kakaotalk/commands/message.js.map +1 -1
  15. package/dist/src/platforms/kakaotalk/image-meta.d.ts +7 -0
  16. package/dist/src/platforms/kakaotalk/image-meta.d.ts.map +1 -0
  17. package/dist/src/platforms/kakaotalk/image-meta.js +153 -0
  18. package/dist/src/platforms/kakaotalk/image-meta.js.map +1 -0
  19. package/dist/src/platforms/kakaotalk/index.d.ts +8 -2
  20. package/dist/src/platforms/kakaotalk/index.d.ts.map +1 -1
  21. package/dist/src/platforms/kakaotalk/index.js +5 -1
  22. package/dist/src/platforms/kakaotalk/index.js.map +1 -1
  23. package/dist/src/platforms/kakaotalk/media-upload.d.ts +3 -0
  24. package/dist/src/platforms/kakaotalk/media-upload.d.ts.map +1 -0
  25. package/dist/src/platforms/kakaotalk/media-upload.js +44 -0
  26. package/dist/src/platforms/kakaotalk/media-upload.js.map +1 -0
  27. package/dist/src/platforms/kakaotalk/protocol/connection.d.ts +1 -0
  28. package/dist/src/platforms/kakaotalk/protocol/connection.d.ts.map +1 -1
  29. package/dist/src/platforms/kakaotalk/protocol/connection.js +11 -0
  30. package/dist/src/platforms/kakaotalk/protocol/connection.js.map +1 -1
  31. package/dist/src/platforms/kakaotalk/protocol/media-uploader.d.ts +25 -0
  32. package/dist/src/platforms/kakaotalk/protocol/media-uploader.d.ts.map +1 -0
  33. package/dist/src/platforms/kakaotalk/protocol/media-uploader.js +99 -0
  34. package/dist/src/platforms/kakaotalk/protocol/media-uploader.js.map +1 -0
  35. package/dist/src/platforms/kakaotalk/protocol/session.d.ts +6 -0
  36. package/dist/src/platforms/kakaotalk/protocol/session.d.ts.map +1 -1
  37. package/dist/src/platforms/kakaotalk/protocol/session.js +61 -0
  38. package/dist/src/platforms/kakaotalk/protocol/session.js.map +1 -1
  39. package/dist/src/platforms/kakaotalk/types.d.ts +44 -0
  40. package/dist/src/platforms/kakaotalk/types.d.ts.map +1 -1
  41. package/dist/src/platforms/kakaotalk/types.js +9 -0
  42. package/dist/src/platforms/kakaotalk/types.js.map +1 -1
  43. package/dist/src/platforms/slackbot/types.d.ts +4 -0
  44. package/dist/src/platforms/slackbot/types.d.ts.map +1 -1
  45. package/docs/content/docs/cli/kakaotalk.mdx +47 -2
  46. package/docs/content/docs/sdk/kakaotalk.mdx +32 -0
  47. package/package.json +1 -1
  48. package/skills/agent-channeltalk/SKILL.md +1 -1
  49. package/skills/agent-channeltalkbot/SKILL.md +1 -1
  50. package/skills/agent-discord/SKILL.md +1 -1
  51. package/skills/agent-discordbot/SKILL.md +1 -1
  52. package/skills/agent-instagram/SKILL.md +1 -1
  53. package/skills/agent-kakaotalk/SKILL.md +62 -4
  54. package/skills/agent-kakaotalk/references/common-patterns.md +50 -11
  55. package/skills/agent-line/SKILL.md +1 -1
  56. package/skills/agent-slack/SKILL.md +1 -1
  57. package/skills/agent-slackbot/SKILL.md +1 -1
  58. package/skills/agent-teams/SKILL.md +1 -1
  59. package/skills/agent-telegram/SKILL.md +1 -1
  60. package/skills/agent-telegrambot/SKILL.md +1 -1
  61. package/skills/agent-webex/SKILL.md +1 -1
  62. package/skills/agent-wechatbot/SKILL.md +1 -1
  63. package/skills/agent-whatsapp/SKILL.md +1 -1
  64. package/skills/agent-whatsappbot/SKILL.md +1 -1
  65. package/src/platforms/kakaotalk/attachment-router.test.ts +102 -0
  66. package/src/platforms/kakaotalk/attachment-router.ts +50 -0
  67. package/src/platforms/kakaotalk/client.ts +315 -8
  68. package/src/platforms/kakaotalk/commands/message.ts +66 -0
  69. package/src/platforms/kakaotalk/image-meta.test.ts +90 -0
  70. package/src/platforms/kakaotalk/image-meta.ts +176 -0
  71. package/src/platforms/kakaotalk/index.ts +13 -0
  72. package/src/platforms/kakaotalk/media-upload.ts +44 -0
  73. package/src/platforms/kakaotalk/protocol/connection.ts +11 -0
  74. package/src/platforms/kakaotalk/protocol/media-uploader.ts +129 -0
  75. package/src/platforms/kakaotalk/protocol/session.ts +67 -0
  76. package/src/platforms/kakaotalk/types.ts +57 -0
  77. package/src/platforms/slackbot/types.ts +16 -0
  78. package/src/platforms/telegrambot/cli.ts +0 -0
@@ -55,9 +55,48 @@ agent-kakaotalk message send "$TARGET_CHAT" "Hey Alice!"
55
55
 
56
56
  **When to use**: First time interacting with a chat, or when the user references a chat by name.
57
57
 
58
- > Note: `display_name` joins the chat's member nicknames. For the user-set room title (matching the KakaoTalk app), see [Pattern 9](#pattern-9-resolve-canonical-room-titles).
58
+ > Note: `display_name` joins the chat's member nicknames. For the user-set room title (matching the KakaoTalk app), see [Pattern 10](#pattern-10-resolve-canonical-room-titles).
59
59
 
60
- ## Pattern 3: Monitor Chat for New Messages
60
+ ## Pattern 3: Send Files, Photos, Videos, and Audio
61
+
62
+ **Use case**: Upload an attachment to a chat (photo, video, voice, generic file, or multi-photo gallery)
63
+
64
+ ```bash
65
+ #!/bin/bash
66
+
67
+ CHAT_ID="9876543210"
68
+
69
+ # Single file — MIME is sniffed from the filename and routed to the right kind
70
+ agent-kakaotalk message upload "$CHAT_ID" ./photo.jpg # → photo (inline preview)
71
+ agent-kakaotalk message upload "$CHAT_ID" ./clip.mp4 # → video (inline player)
72
+ agent-kakaotalk message upload "$CHAT_ID" ./voice.m4a # → audio (voice bubble)
73
+ agent-kakaotalk message upload "$CHAT_ID" ./report.pdf # → file (download icon)
74
+
75
+ # Multi-photo gallery — pass 2+ files and the CLI uses the gallery flow
76
+ agent-kakaotalk message upload "$CHAT_ID" ./img1.jpg ./img2.jpg ./img3.jpg
77
+
78
+ # Force a specific kind to override auto-routing
79
+ agent-kakaotalk message upload "$CHAT_ID" ./clip.mp4 --as file # send as generic file, not inline video
80
+
81
+ # Override MIME detection for extension-less files
82
+ agent-kakaotalk message upload "$CHAT_ID" ./data.bin --mime application/octet-stream
83
+
84
+ # With error handling
85
+ RESULT=$(agent-kakaotalk message upload "$CHAT_ID" ./photo.jpg)
86
+ SUCCESS=$(echo "$RESULT" | jq -r '.success')
87
+ if [ "$SUCCESS" = "true" ]; then
88
+ echo "Uploaded as log_id $(echo "$RESULT" | jq -r '.log_id')"
89
+ else
90
+ echo "Upload failed: $(echo "$RESULT" | jq -r '.status_code')"
91
+ exit 1
92
+ fi
93
+ ```
94
+
95
+ **When to use**: Sending screenshots, generated reports, deployment artifacts, voice notes, or photo bundles to a chat.
96
+
97
+ **Routing rules**: `image/*` → photo, `video/*` → video, `audio/*` → audio (voice memo), everything else → generic file. The full SHIP / POST / COMPLETE LOCO pipeline is handled internally — no separate "send text after upload" step is needed. KakaoTalk caps single-message attachment sizes server-side; the CLI surfaces the server's status code on rejection.
98
+
99
+ ## Pattern 4: Monitor Chat for New Messages
61
100
 
62
101
  **Use case**: Watch a chat room and respond to new messages
63
102
 
@@ -91,7 +130,7 @@ done
91
130
 
92
131
  **Limitations**: Polling-based, not real-time. Each poll establishes a LOCO connection, so use reasonable intervals (10s+).
93
132
 
94
- ## Pattern 4: Read Recent Chat History
133
+ ## Pattern 5: Read Recent Chat History
95
134
 
96
135
  **Use case**: Catch up on what happened in a chat
97
136
 
@@ -113,7 +152,7 @@ echo "$MESSAGES" | jq -r '.[] | "\(.author_id): \(.message // "[non-text]")"'
113
152
 
114
153
  **When to use**: Context gathering, summarizing conversations, catching up on missed messages.
115
154
 
116
- ## Pattern 5: Fetch More Messages
155
+ ## Pattern 6: Fetch More Messages
117
156
 
118
157
  **Use case**: Read more messages than the default 20
119
158
 
@@ -148,7 +187,7 @@ NEW_MESSAGES=$(agent-kakaotalk message list "$CHAT_ID" --from "$LAST_SEEN")
148
187
 
149
188
  **Pagination details**: The CLI now prefers KakaoTalk's `MCHATLOGS` flow for history reads, fetching message batches from the requested `--from` point and returning the last N messages after deduplication and ascending sort. If that path cannot provide results, it falls back to `CHATONROOM` + `SYNCMSG` for compatibility. As a safety net, both paths are capped at 50 internal pages. A warning is printed to stderr only when that cap is actually hit and the returned history may be incomplete.
150
189
 
151
- ## Pattern 6: Multi-Chat Broadcast
190
+ ## Pattern 7: Multi-Chat Broadcast
152
191
 
153
192
  **Use case**: Send the same message to multiple chats
154
193
 
@@ -176,7 +215,7 @@ done
176
215
 
177
216
  **When to use**: Announcements, notifications across multiple chats.
178
217
 
179
- ## Pattern 7: Multi-Account Operations
218
+ ## Pattern 8: Multi-Account Operations
180
219
 
181
220
  **Use case**: Manage and operate across multiple KakaoTalk accounts
182
221
 
@@ -201,7 +240,7 @@ agent-kakaotalk auth status --account 1111111111
201
240
 
202
241
  **When to use**: Managing multiple KakaoTalk identities, sending messages as different accounts, or checking status across accounts.
203
242
 
204
- ## Pattern 8: Unread Message Summary
243
+ ## Pattern 9: Unread Message Summary
205
244
 
206
245
  **Use case**: Check which chats have unread messages
207
246
 
@@ -223,7 +262,7 @@ echo "$UNREAD" | jq -r '.[] | " \(.display_name // "Unknown") — \(.unread_cou
223
262
 
224
263
  **When to use**: Morning catch-up, checking for urgent messages, triage.
225
264
 
226
- ## Pattern 9: Resolve Canonical Room Titles
265
+ ## Pattern 10: Resolve Canonical Room Titles
227
266
 
228
267
  **Use case**: Show user-set room names (matching the official KakaoTalk app) instead of comma-joined member nicknames
229
268
 
@@ -269,7 +308,7 @@ const chats = await client.getChats({ resolveTitles: true })
269
308
  const title = await client.getChatTitle('9876543210')
270
309
  ```
271
310
 
272
- ## Pattern 10: Error Handling and Retry
311
+ ## Pattern 11: Error Handling and Retry
273
312
 
274
313
  **Use case**: Robust message sending with retries
275
314
 
@@ -454,9 +493,9 @@ The `deviceType` determines the LOCO protocol identity: `'tablet'` sends `os: 'a
454
493
 
455
494
  ### Auto-Reconnect
456
495
 
457
- `getChats`, `getMessages`, and `sendMessage` automatically reconnect once when the LOCO session dies (e.g. the KakaoTalk desktop app reclaims the session or the network drops). The reconnect is transparent — callers don't need to handle session-drop errors.
496
+ `getChats`, `getMessages`, `sendMessage`, and `sendAttachment` (plus the underlying typed helpers `sendPhoto` / `sendVideo` / `sendAudio` / `sendFile` / `sendMultiPhoto`) automatically reconnect once when the LOCO session dies (e.g. the KakaoTalk desktop app reclaims the session or the network drops). The reconnect is transparent — callers don't need to handle session-drop errors.
458
497
 
459
- Reconnect only triggers on actual session death. Operation-level errors (invalid chat ID, server rejection, etc.) are thrown immediately without retry, so side effects like `sendMessage` are never duplicated.
498
+ Reconnect only triggers on actual session death. Operation-level errors (invalid chat ID, server rejection, etc.) are thrown immediately without retry, so side effects like `sendMessage` or `sendAttachment` are never duplicated.
460
499
 
461
500
  ## See Also
462
501
 
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: agent-line
3
3
  description: Interact with LINE - send messages, read chats, manage conversations
4
- version: 2.15.1
4
+ version: 2.17.0
5
5
  allowed-tools: Bash(agent-line:*)
6
6
  metadata:
7
7
  openclaw:
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: agent-slack
3
3
  description: Interact with Slack workspaces - send messages, read channels, manage reactions
4
- version: 2.15.1
4
+ version: 2.17.0
5
5
  allowed-tools: Bash(agent-slack:*)
6
6
  metadata:
7
7
  openclaw:
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: agent-slackbot
3
3
  description: Interact with Slack workspaces using bot tokens - send messages, read channels, manage reactions
4
- version: 2.15.1
4
+ version: 2.17.0
5
5
  allowed-tools: Bash(agent-slackbot:*)
6
6
  metadata:
7
7
  openclaw:
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: agent-teams
3
3
  description: Interact with Microsoft Teams - send messages, read channels, manage reactions
4
- version: 2.15.1
4
+ version: 2.17.0
5
5
  allowed-tools: Bash(agent-teams:*)
6
6
  metadata:
7
7
  openclaw:
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: agent-telegram
3
3
  description: Interact with Telegram through TDLib - authenticate, inspect chats, and send messages
4
- version: 2.15.1
4
+ version: 2.17.0
5
5
  allowed-tools: Bash(agent-telegram:*)
6
6
  ---
7
7
 
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: agent-telegrambot
3
3
  description: Interact with Telegram using bot tokens - send messages, read chats, manage reactions
4
- version: 2.15.1
4
+ version: 2.17.0
5
5
  allowed-tools: Bash(agent-telegrambot:*)
6
6
  metadata:
7
7
  openclaw:
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: agent-webex
3
3
  description: Interact with Cisco Webex - send messages, read spaces, manage memberships
4
- version: 2.15.1
4
+ version: 2.17.0
5
5
  allowed-tools: Bash(agent-webex:*)
6
6
  metadata:
7
7
  openclaw:
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: agent-wechatbot
3
3
  description: Interact with WeChat Official Account using API credentials - send messages, manage templates, list followers
4
- version: 2.15.1
4
+ version: 2.17.0
5
5
  allowed-tools: Bash(agent-wechatbot:*)
6
6
  metadata:
7
7
  openclaw:
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: agent-whatsapp
3
3
  description: Interact with WhatsApp - send messages, read chats, manage conversations
4
- version: 2.15.1
4
+ version: 2.17.0
5
5
  allowed-tools: Bash(agent-whatsapp:*)
6
6
  metadata:
7
7
  openclaw:
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: agent-whatsappbot
3
3
  description: Interact with WhatsApp using Cloud API credentials - send messages, manage templates
4
- version: 2.15.1
4
+ version: 2.17.0
5
5
  allowed-tools: Bash(agent-whatsappbot:*)
6
6
  metadata:
7
7
  openclaw:
@@ -0,0 +1,102 @@
1
+ import { describe, expect, it } from 'bun:test'
2
+
3
+ import { planAttachments, resolveAttachment } from './attachment-router'
4
+
5
+ const bytes = new Uint8Array([0])
6
+
7
+ describe('resolveAttachment', () => {
8
+ it('classifies image MIME as photo', () => {
9
+ expect(resolveAttachment({ data: bytes, filename: 'x.jpg' }).kind).toBe('photo')
10
+ expect(resolveAttachment({ data: bytes, filename: 'x.png', mime: 'image/png' }).kind).toBe('photo')
11
+ })
12
+
13
+ it('classifies video MIME as video', () => {
14
+ expect(resolveAttachment({ data: bytes, filename: 'x.mp4' }).kind).toBe('video')
15
+ })
16
+
17
+ it('classifies audio MIME as audio', () => {
18
+ expect(resolveAttachment({ data: bytes, filename: 'x.m4a' }).kind).toBe('audio')
19
+ })
20
+
21
+ it('classifies everything else as file', () => {
22
+ expect(resolveAttachment({ data: bytes, filename: 'x.pdf' }).kind).toBe('file')
23
+ expect(resolveAttachment({ data: bytes, filename: 'x.zip' }).kind).toBe('file')
24
+ expect(resolveAttachment({ data: bytes, filename: 'x.unknown-ext' }).kind).toBe('file')
25
+ })
26
+
27
+ it('uses explicit mime override over filename inference', () => {
28
+ const r = resolveAttachment({ data: bytes, filename: 'x.pdf', mime: 'image/png' })
29
+ expect(r.kind).toBe('photo')
30
+ expect(r.mime).toBe('image/png')
31
+ })
32
+
33
+ it('routes upper-case and mixed-case MIME overrides the same as lower-case', () => {
34
+ expect(resolveAttachment({ data: bytes, filename: 'x.bin', mime: 'IMAGE/JPEG' }).kind).toBe('photo')
35
+ expect(resolveAttachment({ data: bytes, filename: 'x.bin', mime: 'Video/MP4' }).kind).toBe('video')
36
+ expect(resolveAttachment({ data: bytes, filename: 'x.bin', mime: 'Audio/MPEG' }).kind).toBe('audio')
37
+ expect(resolveAttachment({ data: bytes, filename: 'x.bin', mime: 'IMAGE/PNG' }).mime).toBe('image/png')
38
+ })
39
+
40
+ it('preserves data and filename verbatim', () => {
41
+ const r = resolveAttachment({ data: bytes, filename: 'cat picture.jpg' })
42
+ expect(r.data).toBe(bytes)
43
+ expect(r.filename).toBe('cat picture.jpg')
44
+ })
45
+ })
46
+
47
+ describe('planAttachments', () => {
48
+ it('throws on empty array', () => {
49
+ expect(() => planAttachments([])).toThrow(/empty/i)
50
+ })
51
+
52
+ it('returns single for a one-element array', () => {
53
+ const plan = planAttachments([{ data: bytes, filename: 'x.jpg' }])
54
+ expect(plan.kind).toBe('single')
55
+ if (plan.kind !== 'single') return
56
+ expect(plan.resolved.kind).toBe('photo')
57
+ })
58
+
59
+ it('returns single (not multiphoto) for a one-photo array', () => {
60
+ const plan = planAttachments([{ data: bytes, filename: 'a.png' }])
61
+ expect(plan.kind).toBe('single')
62
+ })
63
+
64
+ it('returns multiphoto when every item resolves to photo', () => {
65
+ const plan = planAttachments([
66
+ { data: bytes, filename: 'a.jpg' },
67
+ { data: bytes, filename: 'b.png' },
68
+ { data: bytes, filename: 'c.webp' },
69
+ ])
70
+ expect(plan.kind).toBe('multiphoto')
71
+ if (plan.kind !== 'multiphoto') return
72
+ expect(plan.items.length).toBe(3)
73
+ })
74
+
75
+ it('returns sequential for mixed kinds (image + file)', () => {
76
+ const plan = planAttachments([
77
+ { data: bytes, filename: 'photo.jpg' },
78
+ { data: bytes, filename: 'spec.pdf' },
79
+ ])
80
+ expect(plan.kind).toBe('sequential')
81
+ if (plan.kind !== 'sequential') return
82
+ expect(plan.resolved.map((r) => r.kind)).toEqual(['photo', 'file'])
83
+ })
84
+
85
+ it('returns sequential for all-video (multiphoto is image-only)', () => {
86
+ const plan = planAttachments([
87
+ { data: bytes, filename: 'a.mp4' },
88
+ { data: bytes, filename: 'b.mp4' },
89
+ ])
90
+ expect(plan.kind).toBe('sequential')
91
+ if (plan.kind !== 'sequential') return
92
+ expect(plan.resolved.every((r) => r.kind === 'video')).toBe(true)
93
+ })
94
+
95
+ it('honors explicit mime overrides when classifying for the photo gate', () => {
96
+ const plan = planAttachments([
97
+ { data: bytes, filename: 'a.pdf', mime: 'image/jpeg' },
98
+ { data: bytes, filename: 'b.bin', mime: 'image/png' },
99
+ ])
100
+ expect(plan.kind).toBe('multiphoto')
101
+ })
102
+ })
@@ -0,0 +1,50 @@
1
+ import { guessMimeFromFilename } from './media-upload'
2
+
3
+ export type AttachmentInput = {
4
+ data: Uint8Array | Buffer
5
+ filename: string
6
+ mime?: string
7
+ }
8
+
9
+ export type SingleAttachmentKind = 'photo' | 'video' | 'audio' | 'file'
10
+
11
+ export type ResolvedAttachment = {
12
+ kind: SingleAttachmentKind
13
+ mime: string
14
+ data: Uint8Array | Buffer
15
+ filename: string
16
+ }
17
+
18
+ export type AttachmentPlan =
19
+ | { kind: 'single'; resolved: ResolvedAttachment }
20
+ | { kind: 'multiphoto'; items: readonly AttachmentInput[] }
21
+ | { kind: 'sequential'; resolved: readonly ResolvedAttachment[] }
22
+
23
+ export function resolveAttachment(input: AttachmentInput): ResolvedAttachment {
24
+ // MIME types are case-insensitive per RFC 2045 §5.1; normalize so an `Image/JPEG`
25
+ // override still routes to `photo` instead of falling through to `file`.
26
+ const mime = (input.mime ?? guessMimeFromFilename(input.filename)).toLowerCase()
27
+ const kind: SingleAttachmentKind = mime.startsWith('image/')
28
+ ? 'photo'
29
+ : mime.startsWith('video/')
30
+ ? 'video'
31
+ : mime.startsWith('audio/')
32
+ ? 'audio'
33
+ : 'file'
34
+ return { kind, mime, data: input.data, filename: input.filename }
35
+ }
36
+
37
+ export function planAttachments(items: readonly AttachmentInput[]): AttachmentPlan {
38
+ if (items.length === 0) {
39
+ throw new Error('sendAttachment received an empty attachments array')
40
+ }
41
+ if (items.length === 1) {
42
+ return { kind: 'single', resolved: resolveAttachment(items[0]!) }
43
+ }
44
+ const resolved = items.map(resolveAttachment)
45
+ // MULTIPHOTO (message_type 27) is image-only by KakaoTalk's wire protocol.
46
+ if (resolved.every((r) => r.kind === 'photo')) {
47
+ return { kind: 'multiphoto', items: items.slice() }
48
+ }
49
+ return { kind: 'sequential', resolved }
50
+ }