agent-messenger 2.27.2 → 2.28.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 (189) hide show
  1. package/.claude-plugin/README.md +11 -1
  2. package/.claude-plugin/marketplace.json +14 -1
  3. package/.claude-plugin/plugin.json +4 -2
  4. package/AGENTS.md +4 -0
  5. package/README.md +40 -9
  6. package/dist/package.json +10 -2
  7. package/dist/src/cli.d.ts.map +1 -1
  8. package/dist/src/cli.js +3 -0
  9. package/dist/src/cli.js.map +1 -1
  10. package/dist/src/platforms/discordbot/cli.js +1 -1
  11. package/dist/src/platforms/imessage/cli.d.ts +5 -0
  12. package/dist/src/platforms/imessage/cli.d.ts.map +1 -0
  13. package/dist/src/platforms/imessage/cli.js +31 -0
  14. package/dist/src/platforms/imessage/cli.js.map +1 -0
  15. package/dist/src/platforms/imessage/client.d.ts +43 -0
  16. package/dist/src/platforms/imessage/client.d.ts.map +1 -0
  17. package/dist/src/platforms/imessage/client.js +190 -0
  18. package/dist/src/platforms/imessage/client.js.map +1 -0
  19. package/dist/src/platforms/imessage/commands/auth.d.ts +31 -0
  20. package/dist/src/platforms/imessage/commands/auth.d.ts.map +1 -0
  21. package/dist/src/platforms/imessage/commands/auth.js +100 -0
  22. package/dist/src/platforms/imessage/commands/auth.js.map +1 -0
  23. package/dist/src/platforms/imessage/commands/chat.d.ts +3 -0
  24. package/dist/src/platforms/imessage/commands/chat.d.ts.map +1 -0
  25. package/dist/src/platforms/imessage/commands/chat.js +42 -0
  26. package/dist/src/platforms/imessage/commands/chat.js.map +1 -0
  27. package/dist/src/platforms/imessage/commands/doctor.d.ts +25 -0
  28. package/dist/src/platforms/imessage/commands/doctor.d.ts.map +1 -0
  29. package/dist/src/platforms/imessage/commands/doctor.js +101 -0
  30. package/dist/src/platforms/imessage/commands/doctor.js.map +1 -0
  31. package/dist/src/platforms/imessage/commands/index.d.ts +7 -0
  32. package/dist/src/platforms/imessage/commands/index.d.ts.map +1 -0
  33. package/dist/src/platforms/imessage/commands/index.js +7 -0
  34. package/dist/src/platforms/imessage/commands/index.js.map +1 -0
  35. package/dist/src/platforms/imessage/commands/message.d.ts +5 -0
  36. package/dist/src/platforms/imessage/commands/message.d.ts.map +1 -0
  37. package/dist/src/platforms/imessage/commands/message.js +127 -0
  38. package/dist/src/platforms/imessage/commands/message.js.map +1 -0
  39. package/dist/src/platforms/imessage/commands/setup.d.ts +20 -0
  40. package/dist/src/platforms/imessage/commands/setup.d.ts.map +1 -0
  41. package/dist/src/platforms/imessage/commands/setup.js +57 -0
  42. package/dist/src/platforms/imessage/commands/setup.js.map +1 -0
  43. package/dist/src/platforms/imessage/commands/shared.d.ts +10 -0
  44. package/dist/src/platforms/imessage/commands/shared.d.ts.map +1 -0
  45. package/dist/src/platforms/imessage/commands/shared.js +53 -0
  46. package/dist/src/platforms/imessage/commands/shared.js.map +1 -0
  47. package/dist/src/platforms/imessage/commands/whoami.d.ts +3 -0
  48. package/dist/src/platforms/imessage/commands/whoami.d.ts.map +1 -0
  49. package/dist/src/platforms/imessage/commands/whoami.js +20 -0
  50. package/dist/src/platforms/imessage/commands/whoami.js.map +1 -0
  51. package/dist/src/platforms/imessage/credential-manager.d.ts +22 -0
  52. package/dist/src/platforms/imessage/credential-manager.d.ts.map +1 -0
  53. package/dist/src/platforms/imessage/credential-manager.js +111 -0
  54. package/dist/src/platforms/imessage/credential-manager.js.map +1 -0
  55. package/dist/src/platforms/imessage/ensure-auth.d.ts +2 -0
  56. package/dist/src/platforms/imessage/ensure-auth.d.ts.map +1 -0
  57. package/dist/src/platforms/imessage/ensure-auth.js +13 -0
  58. package/dist/src/platforms/imessage/ensure-auth.js.map +1 -0
  59. package/dist/src/platforms/imessage/errors.d.ts +3 -0
  60. package/dist/src/platforms/imessage/errors.d.ts.map +1 -0
  61. package/dist/src/platforms/imessage/errors.js +25 -0
  62. package/dist/src/platforms/imessage/errors.js.map +1 -0
  63. package/dist/src/platforms/imessage/index.d.ts +7 -0
  64. package/dist/src/platforms/imessage/index.d.ts.map +1 -0
  65. package/dist/src/platforms/imessage/index.js +5 -0
  66. package/dist/src/platforms/imessage/index.js.map +1 -0
  67. package/dist/src/platforms/imessage/rpc.d.ts +20 -0
  68. package/dist/src/platforms/imessage/rpc.d.ts.map +1 -0
  69. package/dist/src/platforms/imessage/rpc.js +133 -0
  70. package/dist/src/platforms/imessage/rpc.js.map +1 -0
  71. package/dist/src/platforms/imessage/types.d.ts +59 -0
  72. package/dist/src/platforms/imessage/types.d.ts.map +1 -0
  73. package/dist/src/platforms/imessage/types.js +29 -0
  74. package/dist/src/platforms/imessage/types.js.map +1 -0
  75. package/dist/src/platforms/instagram/cli.js +1 -1
  76. package/dist/src/platforms/instagram/client.d.ts +6 -1
  77. package/dist/src/platforms/instagram/client.d.ts.map +1 -1
  78. package/dist/src/platforms/instagram/client.js +17 -0
  79. package/dist/src/platforms/instagram/client.js.map +1 -1
  80. package/dist/src/platforms/instagram/hybrid-listener.d.ts +48 -0
  81. package/dist/src/platforms/instagram/hybrid-listener.d.ts.map +1 -0
  82. package/dist/src/platforms/instagram/hybrid-listener.js +154 -0
  83. package/dist/src/platforms/instagram/hybrid-listener.js.map +1 -0
  84. package/dist/src/platforms/instagram/index.d.ts +2 -0
  85. package/dist/src/platforms/instagram/index.d.ts.map +1 -1
  86. package/dist/src/platforms/instagram/index.js +2 -0
  87. package/dist/src/platforms/instagram/index.js.map +1 -1
  88. package/dist/src/platforms/instagram/mqtt/connection.d.ts +4 -0
  89. package/dist/src/platforms/instagram/mqtt/connection.d.ts.map +1 -0
  90. package/dist/src/platforms/instagram/mqtt/connection.js +66 -0
  91. package/dist/src/platforms/instagram/mqtt/connection.js.map +1 -0
  92. package/dist/src/platforms/instagram/mqtt/thrift.d.ts +22 -0
  93. package/dist/src/platforms/instagram/mqtt/thrift.d.ts.map +1 -0
  94. package/dist/src/platforms/instagram/mqtt/thrift.js +143 -0
  95. package/dist/src/platforms/instagram/mqtt/thrift.js.map +1 -0
  96. package/dist/src/platforms/instagram/mqtt/transport.d.ts +42 -0
  97. package/dist/src/platforms/instagram/mqtt/transport.d.ts.map +1 -0
  98. package/dist/src/platforms/instagram/mqtt/transport.js +246 -0
  99. package/dist/src/platforms/instagram/mqtt/transport.js.map +1 -0
  100. package/dist/src/platforms/instagram/realtime-listener.d.ts +33 -0
  101. package/dist/src/platforms/instagram/realtime-listener.d.ts.map +1 -0
  102. package/dist/src/platforms/instagram/realtime-listener.js +151 -0
  103. package/dist/src/platforms/instagram/realtime-listener.js.map +1 -0
  104. package/dist/src/platforms/kakaotalk/cli.js +1 -1
  105. package/dist/src/platforms/line/cli.js +1 -1
  106. package/dist/src/platforms/webex/cli.js +1 -1
  107. package/dist/src/platforms/webexbot/cli.js +1 -1
  108. package/dist/src/platforms/wechatbot/cli.js +1 -1
  109. package/dist/src/platforms/whatsapp/cli.js +1 -1
  110. package/dist/src/platforms/whatsappbot/cli.js +1 -1
  111. package/dist/src/tui/adapters/imessage-adapter.d.ts +21 -0
  112. package/dist/src/tui/adapters/imessage-adapter.d.ts.map +1 -0
  113. package/dist/src/tui/adapters/imessage-adapter.js +110 -0
  114. package/dist/src/tui/adapters/imessage-adapter.js.map +1 -0
  115. package/dist/src/tui/app.d.ts.map +1 -1
  116. package/dist/src/tui/app.js +9 -0
  117. package/dist/src/tui/app.js.map +1 -1
  118. package/docs/content/docs/agent-skills.mdx +1 -1
  119. package/docs/content/docs/cli/imessage.mdx +99 -0
  120. package/docs/content/docs/cli/instagram.mdx +1 -1
  121. package/docs/content/docs/cli/meta.json +1 -0
  122. package/docs/content/docs/index.mdx +9 -6
  123. package/docs/content/docs/quick-start.mdx +2 -0
  124. package/docs/content/docs/sdk/instagram.mdx +90 -5
  125. package/docs/content/docs/tui.mdx +4 -3
  126. package/package.json +10 -2
  127. package/scripts/postbuild.ts +12 -15
  128. package/skills/agent-channeltalk/SKILL.md +1 -1
  129. package/skills/agent-channeltalkbot/SKILL.md +1 -1
  130. package/skills/agent-discord/SKILL.md +1 -1
  131. package/skills/agent-discordbot/SKILL.md +1 -1
  132. package/skills/agent-imessage/SKILL.md +133 -0
  133. package/skills/agent-imessage/references/authentication.md +66 -0
  134. package/skills/agent-imessage/references/permissions.md +31 -0
  135. package/skills/agent-imessage/references/setup.md +49 -0
  136. package/skills/agent-instagram/SKILL.md +72 -2
  137. package/skills/agent-instagram/references/common-patterns.md +36 -0
  138. package/skills/agent-instagram/templates/monitor-chat.sh +5 -0
  139. package/skills/agent-kakaotalk/SKILL.md +1 -1
  140. package/skills/agent-line/SKILL.md +1 -1
  141. package/skills/agent-slack/SKILL.md +1 -1
  142. package/skills/agent-slackbot/SKILL.md +1 -1
  143. package/skills/agent-teams/SKILL.md +1 -1
  144. package/skills/agent-telegram/SKILL.md +1 -1
  145. package/skills/agent-telegrambot/SKILL.md +1 -1
  146. package/skills/agent-webex/SKILL.md +1 -1
  147. package/skills/agent-webexbot/SKILL.md +1 -1
  148. package/skills/agent-wechatbot/SKILL.md +1 -1
  149. package/skills/agent-whatsapp/SKILL.md +1 -1
  150. package/skills/agent-whatsappbot/SKILL.md +1 -1
  151. package/src/cli.ts +4 -0
  152. package/src/platforms/imessage/cli.ts +39 -0
  153. package/src/platforms/imessage/client.test.ts +137 -0
  154. package/src/platforms/imessage/client.ts +253 -0
  155. package/src/platforms/imessage/commands/auth.ts +142 -0
  156. package/src/platforms/imessage/commands/chat.ts +51 -0
  157. package/src/platforms/imessage/commands/doctor.test.ts +80 -0
  158. package/src/platforms/imessage/commands/doctor.ts +139 -0
  159. package/src/platforms/imessage/commands/index.ts +6 -0
  160. package/src/platforms/imessage/commands/message.test.ts +20 -0
  161. package/src/platforms/imessage/commands/message.ts +151 -0
  162. package/src/platforms/imessage/commands/setup.test.ts +52 -0
  163. package/src/platforms/imessage/commands/setup.ts +76 -0
  164. package/src/platforms/imessage/commands/shared.test.ts +19 -0
  165. package/src/platforms/imessage/commands/shared.ts +69 -0
  166. package/src/platforms/imessage/commands/whoami.ts +22 -0
  167. package/src/platforms/imessage/credential-manager.test.ts +122 -0
  168. package/src/platforms/imessage/credential-manager.ts +123 -0
  169. package/src/platforms/imessage/ensure-auth.ts +18 -0
  170. package/src/platforms/imessage/errors.ts +27 -0
  171. package/src/platforms/imessage/index.test.ts +13 -0
  172. package/src/platforms/imessage/index.ts +16 -0
  173. package/src/platforms/imessage/rpc.ts +157 -0
  174. package/src/platforms/imessage/test-stub-imsg.mjs +147 -0
  175. package/src/platforms/imessage/types.test.ts +47 -0
  176. package/src/platforms/imessage/types.ts +95 -0
  177. package/src/platforms/instagram/client.ts +23 -0
  178. package/src/platforms/instagram/hybrid-listener.test.ts +135 -0
  179. package/src/platforms/instagram/hybrid-listener.ts +196 -0
  180. package/src/platforms/instagram/index.ts +10 -0
  181. package/src/platforms/instagram/mqtt/connection.ts +72 -0
  182. package/src/platforms/instagram/mqtt/thrift.test.ts +91 -0
  183. package/src/platforms/instagram/mqtt/thrift.ts +159 -0
  184. package/src/platforms/instagram/mqtt/transport.ts +285 -0
  185. package/src/platforms/instagram/realtime-listener.test.ts +34 -0
  186. package/src/platforms/instagram/realtime-listener.ts +201 -0
  187. package/src/platforms/webexbot/cli.ts +0 -0
  188. package/src/tui/adapters/imessage-adapter.ts +131 -0
  189. package/src/tui/app.ts +13 -0
@@ -323,6 +323,39 @@ agent-instagram message send-to alice "Hello!"
323
323
  agent-instagram message send 12345678901 "Hello!"
324
324
  ```
325
325
 
326
+ ## Pattern 11: Real-Time DM Monitor (SDK)
327
+
328
+ **Use case**: Receive new DMs as they arrive, without polling
329
+
330
+ The SDK's `InstagramHybridListener` is the right tool for continuous DM monitoring. It connects over Instagram's MQTToT transport and falls back to polling automatically — no manual loop or `sleep` needed.
331
+
332
+ ```typescript
333
+ import { InstagramClient, InstagramHybridListener } from 'agent-messenger/instagram'
334
+
335
+ const client = await new InstagramClient().login()
336
+ const listener = new InstagramHybridListener(client)
337
+
338
+ listener.on('connected', ({ userId, transport }) => {
339
+ console.log(`Listening as ${userId} via ${transport}`)
340
+ })
341
+
342
+ listener.on('message', (msg) => {
343
+ if (msg.is_outgoing) return
344
+ console.log(`[${msg.thread_id}] ${msg.from}: ${msg.text ?? `[${msg.type}]`}`)
345
+ })
346
+
347
+ listener.on('error', (err) => {
348
+ console.error('Listener error:', err.message)
349
+ })
350
+
351
+ await listener.start()
352
+ // Runs until listener.stop() is called
353
+ ```
354
+
355
+ **When to use**: Any time you need to react to incoming DMs in real time — notifications, bots, monitoring dashboards. Prefer this over a polling shell loop.
356
+
357
+ **Contrast with shell polling**: The `monitor-chat.sh` template uses `sleep` + `message list` in a loop. That works for simple scripts but makes one HTTP request per interval and can't react faster than the interval. The SDK listener holds a persistent connection and delivers messages as they arrive.
358
+
326
359
  ## Anti-Patterns
327
360
 
328
361
  ### Don't Poll Too Frequently
@@ -341,6 +374,8 @@ while true; do
341
374
  done
342
375
  ```
343
376
 
377
+ For real-time monitoring, the SDK's `InstagramHybridListener` is a better alternative — it holds a persistent connection and avoids repeated HTTP requests entirely. See [Pattern 11](#pattern-11-real-time-dm-monitor-sdk) above.
378
+
344
379
  ### Don't Spam Threads
345
380
 
346
381
  ```bash
@@ -360,3 +395,4 @@ agent-instagram message send "$THREAD" "$MESSAGE"
360
395
  ## See Also
361
396
 
362
397
  - [Authentication Guide](authentication.md) - Setting up credentials
398
+ - For real-time events, use the `InstagramHybridListener` SDK (see SKILL.md → SDK: Real-Time Events)
@@ -12,6 +12,11 @@
12
12
  # Example:
13
13
  # ./monitor-chat.sh 340282366841710300949128138443434234567
14
14
  # ./monitor-chat.sh 340282366841710300949128138443434234567 10
15
+ #
16
+ # NOTE: For real-time monitoring, prefer the SDK's InstagramHybridListener over
17
+ # this poll-based approach. It connects over Instagram's MQTToT transport and
18
+ # delivers messages as they arrive, with automatic fallback to polling if needed.
19
+ # See SKILL.md -> SDK: Real-Time Events for setup and usage.
15
20
 
16
21
  set -euo pipefail
17
22
 
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: agent-kakaotalk
3
3
  description: Interact with KakaoTalk - send messages, read chats, manage conversations
4
- version: 2.27.2
4
+ version: 2.28.0
5
5
  allowed-tools: Bash(agent-kakaotalk:*)
6
6
  metadata:
7
7
  openclaw:
@@ -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.27.2
4
+ version: 2.28.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.27.2
4
+ version: 2.28.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.27.2
4
+ version: 2.28.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.27.2
4
+ version: 2.28.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.27.2
4
+ version: 2.28.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.27.2
4
+ version: 2.28.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.27.2
4
+ version: 2.28.0
5
5
  allowed-tools: Bash(agent-webex:*)
6
6
  metadata:
7
7
  openclaw:
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: agent-webexbot
3
3
  description: Interact with Cisco Webex using bot tokens - send messages, reply in threads, upload and download files, look up people, read spaces, manage memberships, stream real-time events
4
- version: 2.27.2
4
+ version: 2.28.0
5
5
  allowed-tools: Bash(agent-webexbot:*)
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.27.2
4
+ version: 2.28.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.27.2
4
+ version: 2.28.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.27.2
4
+ version: 2.28.0
5
5
  allowed-tools: Bash(agent-whatsappbot:*)
6
6
  metadata:
7
7
  openclaw:
package/src/cli.ts CHANGED
@@ -68,6 +68,10 @@ program.command('channeltalk', 'Interact with Channel Talk', {
68
68
  executableFile: join(__dirname, 'platforms', 'channeltalk', `cli${ext}`),
69
69
  })
70
70
 
71
+ program.command('imessage', 'Interact with iMessage via imsg (local, on-Mac)', {
72
+ executableFile: join(__dirname, 'platforms', 'imessage', `cli${ext}`),
73
+ })
74
+
71
75
  program.command('channeltalkbot', 'Interact with Channel Talk using API credentials', {
72
76
  executableFile: join(__dirname, 'platforms', 'channeltalkbot', `cli${ext}`),
73
77
  })
@@ -0,0 +1,39 @@
1
+ #!/usr/bin/env bun
2
+
3
+ import type { Command as CommandType } from 'commander'
4
+ import { Command } from 'commander'
5
+
6
+ import pkg from '../../../package.json' with { type: 'json' }
7
+ import { authCommand, chatCommand, doctorCommand, messageCommand, setupCommand, whoamiCommand } from './commands/index'
8
+ import { ensureIMessageAuth } from './ensure-auth'
9
+
10
+ const UNGUARDED = new Set(['auth', 'setup', 'doctor'])
11
+
12
+ function isUnguarded(command: CommandType): boolean {
13
+ let cmd: CommandType | null = command
14
+ while (cmd) {
15
+ if (UNGUARDED.has(cmd.name())) return true
16
+ cmd = cmd.parent
17
+ }
18
+ return false
19
+ }
20
+
21
+ const program = new Command()
22
+
23
+ program.name('agent-imessage').description('CLI tool for iMessage via imsg (local, on-Mac)').version(pkg.version)
24
+
25
+ program.hook('preAction', async (_thisCommand, actionCommand) => {
26
+ if (isUnguarded(actionCommand)) return
27
+ await ensureIMessageAuth()
28
+ })
29
+
30
+ program.addCommand(authCommand)
31
+ program.addCommand(setupCommand)
32
+ program.addCommand(doctorCommand)
33
+ program.addCommand(chatCommand)
34
+ program.addCommand(messageCommand)
35
+ program.addCommand(whoamiCommand)
36
+
37
+ program.parse(process.argv)
38
+
39
+ export default program
@@ -0,0 +1,137 @@
1
+ import { describe, expect, it } from 'bun:test'
2
+ import { join } from 'node:path'
3
+
4
+ import { ImsgClient } from './client'
5
+ import { IMessageError } from './types'
6
+
7
+ const STUB = join(import.meta.dir, 'test-stub-imsg.mjs')
8
+
9
+ function clientWith(mode = 'ok'): ImsgClient {
10
+ process.env.IMSG_STUB_MODE = mode
11
+ return new ImsgClient()
12
+ }
13
+
14
+ describe('ImsgClient (against stub imsg binary)', () => {
15
+ it('getVersion returns the imsg version', async () => {
16
+ const c = await clientWith().login({ binaryPath: STUB })
17
+ expect(await c.getVersion()).toBe('0.11.1')
18
+ await c.close()
19
+ })
20
+
21
+ it('connect succeeds and listChats maps group/individual + fields', async () => {
22
+ const c = await clientWith().login({ binaryPath: STUB })
23
+ await c.connect()
24
+ const chats = await c.listChats(10)
25
+ expect(chats[0]).toMatchObject({ id: 42, name: 'Jane', is_group: false, service: 'iMessage' })
26
+ expect(chats[1]).toMatchObject({ id: 43, name: 'Crew', is_group: true })
27
+ expect(chats[1]?.participants).toHaveLength(2)
28
+ await c.close()
29
+ })
30
+
31
+ it('connect throws full_disk_access when the DB is not readable', async () => {
32
+ const c = await clientWith('fda_denied').login({ binaryPath: STUB })
33
+ try {
34
+ await c.connect()
35
+ throw new Error('should have thrown')
36
+ } catch (error) {
37
+ expect((error as IMessageError).code).toBe('full_disk_access')
38
+ expect((error as IMessageError).suggestion).toContain('Full Disk Access')
39
+ } finally {
40
+ await c.close()
41
+ }
42
+ })
43
+
44
+ it('getMessages maps fields oldest-first with is_outgoing', async () => {
45
+ const c = await clientWith().login({ binaryPath: STUB })
46
+ await c.connect()
47
+ const msgs = await c.getMessages(42, 10)
48
+ expect(msgs.map((m) => m.guid)).toEqual(['m1', 'm2'])
49
+ expect(msgs[0]?.is_outgoing).toBe(false)
50
+ expect(msgs[1]?.is_outgoing).toBe(true)
51
+ await c.close()
52
+ })
53
+
54
+ it('sendMessage returns guid/id; send failure maps to send_failed', async () => {
55
+ const ok = await clientWith('ok').login({ binaryPath: STUB })
56
+ await ok.connect()
57
+ expect(await ok.sendMessage({ chatId: 42 }, 'hi')).toMatchObject({ guid: 'sent-guid', id: 99, is_outgoing: true })
58
+ await ok.close()
59
+
60
+ const fail = await clientWith('send_fail').login({ binaryPath: STUB })
61
+ await fail.connect()
62
+ try {
63
+ await fail.sendMessage({ chatId: 42 }, 'hi')
64
+ throw new Error('should have thrown')
65
+ } catch (error) {
66
+ expect((error as IMessageError).code).toBe('send_failed')
67
+ } finally {
68
+ await fail.close()
69
+ }
70
+ })
71
+
72
+ it('watch delivers live notifications', async () => {
73
+ const c = await clientWith().login({ binaryPath: STUB })
74
+ await c.connect()
75
+ const received: string[] = []
76
+ const stop = await c.watch((m) => received.push(m.guid), { chatId: 42 })
77
+ await new Promise((r) => setTimeout(r, 120))
78
+ await stop()
79
+ await c.close()
80
+ expect(received).toContain('m3')
81
+ })
82
+
83
+ it('sendReaction: standard via CLI; custom + message-target rejected', async () => {
84
+ const c = await clientWith().login({ binaryPath: STUB })
85
+ await c.connect()
86
+ await expect(c.sendReaction(42, 'love')).resolves.toBeUndefined()
87
+ await expect(c.sendReaction(42, 'sparkles')).rejects.toMatchObject({ code: 'private_api_required' })
88
+ await expect(c.sendReaction(42, 'love', 'some-guid')).rejects.toMatchObject({ code: 'private_api_required' })
89
+ await c.close()
90
+ })
91
+
92
+ it('maps an Automation-denied "imsg react" failure to automation_denied (not send_failed)', async () => {
93
+ const c = await clientWith('react_automation_denied').login({ binaryPath: STUB })
94
+ await c.connect()
95
+ try {
96
+ await c.sendReaction(42, 'love')
97
+ throw new Error('should have thrown')
98
+ } catch (error) {
99
+ expect((error as IMessageError).code).toBe('automation_denied')
100
+ } finally {
101
+ await c.close()
102
+ }
103
+ })
104
+
105
+ it('maps AppleScript "not authorized" send error to automation_denied (not send_failed)', async () => {
106
+ const c = await clientWith('automation_denied').login({ binaryPath: STUB })
107
+ await c.connect()
108
+ try {
109
+ await c.sendMessage({ chatId: 42 }, 'hi')
110
+ throw new Error('should have thrown')
111
+ } catch (error) {
112
+ expect((error as IMessageError).code).toBe('automation_denied')
113
+ } finally {
114
+ await c.close()
115
+ }
116
+ })
117
+
118
+ it('close() rejects in-flight requests instead of hanging', async () => {
119
+ const c = await clientWith().login({ binaryPath: STUB })
120
+ await c.connect()
121
+ const pending = c.listChats(1)
122
+ await c.close()
123
+ await expect(pending).rejects.toMatchObject({ code: 'rpc_error' })
124
+ })
125
+
126
+ it('imsg_not_found when the binary does not exist', async () => {
127
+ const c = await new ImsgClient().login({ binaryPath: '/nonexistent/imsg-xyz' })
128
+ try {
129
+ await c.connect()
130
+ throw new Error('should have thrown')
131
+ } catch (error) {
132
+ expect((error as IMessageError).code).toBe('imsg_not_found')
133
+ } finally {
134
+ await c.close()
135
+ }
136
+ })
137
+ })
@@ -0,0 +1,253 @@
1
+ import { spawn } from 'node:child_process'
2
+
3
+ import { classifyImsgFailure } from './errors'
4
+ import { ImsgRpc } from './rpc'
5
+ import { type IMessageChatSummary, type IMessageMessageSummary, type IMessageStatus, IMessageError } from './types'
6
+
7
+ interface ImsgChat {
8
+ id: number
9
+ name?: string | null
10
+ identifier?: string | null
11
+ guid?: string | null
12
+ service?: string | null
13
+ is_group?: boolean
14
+ participants?: string[] | null
15
+ last_message?: ImsgMessage | null
16
+ }
17
+
18
+ interface ImsgMessage {
19
+ id: number
20
+ chat_id: number
21
+ guid: string
22
+ sender?: string | null
23
+ sender_name?: string | null
24
+ is_from_me?: boolean
25
+ text?: string | null
26
+ created_at?: string | null
27
+ }
28
+
29
+ const STANDARD_REACTIONS = new Set(['love', 'like', 'dislike', 'laugh', 'emphasis', 'question'])
30
+
31
+ export interface SendTarget {
32
+ chatId?: number
33
+ chatGuid?: string
34
+ chatIdentifier?: string
35
+ to?: string
36
+ }
37
+
38
+ export type OneShotRunner = (
39
+ binaryPath: string,
40
+ args: string[],
41
+ ) => Promise<{ code: number; stdout: string; stderr: string }>
42
+
43
+ const defaultRunner: OneShotRunner = (binaryPath, args) =>
44
+ new Promise((resolve, reject) => {
45
+ let proc: ReturnType<typeof spawn>
46
+ try {
47
+ proc = spawn(binaryPath, args, { stdio: ['ignore', 'pipe', 'pipe'] })
48
+ } catch {
49
+ reject(new IMessageError(`Could not run "${binaryPath}".`, 'imsg_not_found'))
50
+ return
51
+ }
52
+ let stdout = ''
53
+ let stderr = ''
54
+ proc.stdout?.on('data', (c: Buffer) => (stdout += c.toString()))
55
+ proc.stderr?.on('data', (c: Buffer) => (stderr += c.toString()))
56
+ proc.once('error', (err: NodeJS.ErrnoException) => {
57
+ reject(
58
+ err.code === 'ENOENT'
59
+ ? new IMessageError(`Could not run "${binaryPath}".`, 'imsg_not_found', {
60
+ suggestion: 'Install imsg: "brew install steipete/tap/imsg".',
61
+ doctorCommand: 'agent-imessage doctor',
62
+ })
63
+ : err,
64
+ )
65
+ })
66
+ proc.once('close', (code) => resolve({ code: code ?? 0, stdout, stderr }))
67
+ })
68
+
69
+ function summarizeMessage(msg: ImsgMessage): IMessageMessageSummary {
70
+ return {
71
+ id: msg.id,
72
+ guid: msg.guid,
73
+ chat_id: msg.chat_id,
74
+ from: msg.is_from_me ? '' : (msg.sender ?? ''),
75
+ from_name: msg.sender_name ?? undefined,
76
+ is_outgoing: Boolean(msg.is_from_me),
77
+ timestamp: msg.created_at ?? new Date(0).toISOString(),
78
+ text: msg.text ?? undefined,
79
+ }
80
+ }
81
+
82
+ function summarizeChat(chat: ImsgChat): IMessageChatSummary {
83
+ return {
84
+ id: chat.id,
85
+ guid: chat.guid ?? null,
86
+ identifier: chat.identifier ?? null,
87
+ name: chat.name?.trim() || chat.identifier || chat.guid || String(chat.id),
88
+ service: chat.service ?? 'iMessage',
89
+ is_group: Boolean(chat.is_group),
90
+ participants: chat.participants ?? [],
91
+ last_message: chat.last_message ? summarizeMessage(chat.last_message) : undefined,
92
+ }
93
+ }
94
+
95
+ export class ImsgClient {
96
+ private binaryPath = 'imsg'
97
+ private region: string | undefined
98
+ private rpc: ImsgRpc
99
+ private runner: OneShotRunner
100
+
101
+ constructor(deps?: { rpc?: ImsgRpc; runner?: OneShotRunner }) {
102
+ this.rpc = deps?.rpc ?? new ImsgRpc()
103
+ this.runner = deps?.runner ?? defaultRunner
104
+ }
105
+
106
+ async login(credentials?: { binaryPath?: string; region?: string }): Promise<this> {
107
+ if (credentials) {
108
+ this.binaryPath = credentials.binaryPath ?? 'imsg'
109
+ this.region = credentials.region
110
+ return this
111
+ }
112
+ const { IMessageCredentialManager } = await import('./credential-manager')
113
+ const resolved = await new IMessageCredentialManager().resolveAccount()
114
+ this.binaryPath = resolved?.binary_path ?? 'imsg'
115
+ this.region = resolved?.region
116
+ return this
117
+ }
118
+
119
+ async connect(): Promise<void> {
120
+ await this.rpc.start(this.binaryPath)
121
+ await this.rpc.request('chats.list', { limit: 1 })
122
+ }
123
+
124
+ async getVersion(): Promise<string | null> {
125
+ const result = await this.runner(this.binaryPath, ['--version'])
126
+ if (result.code !== 0) return null
127
+ return result.stdout.trim() || null
128
+ }
129
+
130
+ async getStatus(): Promise<IMessageStatus> {
131
+ let version: string | null = null
132
+ let fullDiskAccess = false
133
+ try {
134
+ version = await this.getVersion()
135
+ } catch {
136
+ version = null
137
+ }
138
+ try {
139
+ await this.rpc.request('chats.list', { limit: 1 })
140
+ fullDiskAccess = true
141
+ } catch {
142
+ fullDiskAccess = false
143
+ }
144
+ return {
145
+ imsg_version: version,
146
+ binary_path: this.binaryPath,
147
+ full_disk_access: fullDiskAccess,
148
+ automation: 'unknown',
149
+ bridge_available: false,
150
+ }
151
+ }
152
+
153
+ async listChats(limit = 25): Promise<IMessageChatSummary[]> {
154
+ const result = await this.rpc.request<{ chats: ImsgChat[] }>('chats.list', { limit })
155
+ return result.chats.map(summarizeChat)
156
+ }
157
+
158
+ async searchChats(query: string, limit = 25): Promise<IMessageChatSummary[]> {
159
+ const all = await this.listChats(Math.max(limit, 100))
160
+ const lower = query.toLowerCase()
161
+ return all
162
+ .filter((c) => c.name.toLowerCase().includes(lower) || (c.identifier ?? '').toLowerCase().includes(lower))
163
+ .slice(0, limit)
164
+ }
165
+
166
+ async getMessages(chatId: number, limit = 25, start?: string): Promise<IMessageMessageSummary[]> {
167
+ const result = await this.rpc.request<{ messages: ImsgMessage[] }>('messages.history', {
168
+ chat_id: chatId,
169
+ limit,
170
+ ...(start ? { start } : {}),
171
+ })
172
+ return result.messages.map(summarizeMessage).sort((a, b) => Date.parse(a.timestamp) - Date.parse(b.timestamp))
173
+ }
174
+
175
+ async sendMessage(target: SendTarget, text: string): Promise<IMessageMessageSummary> {
176
+ const params: Record<string, unknown> = { text }
177
+ if (target.chatId !== undefined) params.chat_id = target.chatId
178
+ else if (target.chatGuid) params.chat_guid = target.chatGuid
179
+ else if (target.chatIdentifier) params.chat_identifier = target.chatIdentifier
180
+ else if (target.to) params.to = target.to
181
+ else throw new IMessageError('A chat id, guid, or recipient is required to send.', 'chat_not_found')
182
+ if (this.region) params.region = this.region
183
+
184
+ const result = await this.rpc.request<{ ok?: boolean; guid?: string; id?: number; service?: string }>(
185
+ 'send',
186
+ params,
187
+ )
188
+ return {
189
+ id: result.id ?? 0,
190
+ guid: result.guid ?? '',
191
+ chat_id: target.chatId ?? 0,
192
+ from: '',
193
+ is_outgoing: true,
194
+ timestamp: new Date().toISOString(),
195
+ text,
196
+ }
197
+ }
198
+
199
+ async sendReaction(chatId: number, reaction: string, messageGuid?: string): Promise<void> {
200
+ if (messageGuid) {
201
+ throw new IMessageError(
202
+ 'Reacting to a specific message requires the imsg Private API bridge.',
203
+ 'private_api_required',
204
+ { suggestion: 'Enable the bridge with "imsg launch" (requires SIP disabled).' },
205
+ )
206
+ }
207
+ if (!STANDARD_REACTIONS.has(reaction)) {
208
+ throw new IMessageError(
209
+ `Custom reactions require the imsg Private API bridge. Standard tapbacks: ${[...STANDARD_REACTIONS].join(', ')}.`,
210
+ 'private_api_required',
211
+ { suggestion: 'Enable the bridge with "imsg launch" (requires SIP disabled).' },
212
+ )
213
+ }
214
+ const result = await this.runner(this.binaryPath, [
215
+ 'react',
216
+ '--chat-id',
217
+ String(chatId),
218
+ '--reaction',
219
+ reaction,
220
+ '--json',
221
+ ])
222
+ if (result.code !== 0) {
223
+ const detail = result.stderr.trim() || result.stdout.trim() || 'Reaction failed.'
224
+ throw classifyImsgFailure(detail, 'send_failed')
225
+ }
226
+ }
227
+
228
+ async watch(
229
+ onMessage: (msg: IMessageMessageSummary) => void,
230
+ options: { chatId?: number; sinceRowId?: number; onError?: (msg: string) => void } = {},
231
+ ): Promise<() => Promise<void>> {
232
+ const params: Record<string, unknown> = { include_reactions: false }
233
+ if (options.chatId !== undefined) params.chat_id = options.chatId
234
+ if (options.sinceRowId !== undefined) params.since_rowid = options.sinceRowId
235
+
236
+ const subscription = await this.rpc.subscribe(
237
+ params,
238
+ (raw) => onMessage(summarizeMessage(raw as ImsgMessage)),
239
+ options.onError,
240
+ )
241
+ return async () => {
242
+ await this.rpc.unsubscribe(subscription)
243
+ }
244
+ }
245
+
246
+ async getProfile(): Promise<IMessageStatus> {
247
+ return this.getStatus()
248
+ }
249
+
250
+ async close(): Promise<void> {
251
+ this.rpc.close()
252
+ }
253
+ }