agent-messenger 2.27.1 → 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 (194) 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/kakaotalk/client.d.ts.map +1 -1
  106. package/dist/src/platforms/kakaotalk/client.js +4 -4
  107. package/dist/src/platforms/kakaotalk/client.js.map +1 -1
  108. package/dist/src/platforms/line/cli.js +1 -1
  109. package/dist/src/platforms/webex/cli.js +1 -1
  110. package/dist/src/platforms/webexbot/cli.js +1 -1
  111. package/dist/src/platforms/wechatbot/cli.js +1 -1
  112. package/dist/src/platforms/whatsapp/cli.js +1 -1
  113. package/dist/src/platforms/whatsappbot/cli.js +1 -1
  114. package/dist/src/tui/adapters/imessage-adapter.d.ts +21 -0
  115. package/dist/src/tui/adapters/imessage-adapter.d.ts.map +1 -0
  116. package/dist/src/tui/adapters/imessage-adapter.js +110 -0
  117. package/dist/src/tui/adapters/imessage-adapter.js.map +1 -0
  118. package/dist/src/tui/app.d.ts.map +1 -1
  119. package/dist/src/tui/app.js +9 -0
  120. package/dist/src/tui/app.js.map +1 -1
  121. package/docs/content/docs/agent-skills.mdx +1 -1
  122. package/docs/content/docs/cli/imessage.mdx +99 -0
  123. package/docs/content/docs/cli/instagram.mdx +1 -1
  124. package/docs/content/docs/cli/meta.json +1 -0
  125. package/docs/content/docs/index.mdx +9 -6
  126. package/docs/content/docs/quick-start.mdx +2 -0
  127. package/docs/content/docs/sdk/instagram.mdx +90 -5
  128. package/docs/content/docs/tui.mdx +4 -3
  129. package/package.json +10 -2
  130. package/scripts/postbuild.ts +12 -15
  131. package/skills/agent-channeltalk/SKILL.md +1 -1
  132. package/skills/agent-channeltalkbot/SKILL.md +1 -1
  133. package/skills/agent-discord/SKILL.md +1 -1
  134. package/skills/agent-discordbot/SKILL.md +1 -1
  135. package/skills/agent-imessage/SKILL.md +133 -0
  136. package/skills/agent-imessage/references/authentication.md +66 -0
  137. package/skills/agent-imessage/references/permissions.md +31 -0
  138. package/skills/agent-imessage/references/setup.md +49 -0
  139. package/skills/agent-instagram/SKILL.md +72 -2
  140. package/skills/agent-instagram/references/common-patterns.md +36 -0
  141. package/skills/agent-instagram/templates/monitor-chat.sh +5 -0
  142. package/skills/agent-kakaotalk/SKILL.md +1 -1
  143. package/skills/agent-line/SKILL.md +1 -1
  144. package/skills/agent-slack/SKILL.md +1 -1
  145. package/skills/agent-slackbot/SKILL.md +1 -1
  146. package/skills/agent-teams/SKILL.md +1 -1
  147. package/skills/agent-telegram/SKILL.md +1 -1
  148. package/skills/agent-telegrambot/SKILL.md +1 -1
  149. package/skills/agent-webex/SKILL.md +1 -1
  150. package/skills/agent-webexbot/SKILL.md +1 -1
  151. package/skills/agent-wechatbot/SKILL.md +1 -1
  152. package/skills/agent-whatsapp/SKILL.md +1 -1
  153. package/skills/agent-whatsappbot/SKILL.md +1 -1
  154. package/src/cli.ts +4 -0
  155. package/src/platforms/imessage/cli.ts +39 -0
  156. package/src/platforms/imessage/client.test.ts +137 -0
  157. package/src/platforms/imessage/client.ts +253 -0
  158. package/src/platforms/imessage/commands/auth.ts +142 -0
  159. package/src/platforms/imessage/commands/chat.ts +51 -0
  160. package/src/platforms/imessage/commands/doctor.test.ts +80 -0
  161. package/src/platforms/imessage/commands/doctor.ts +139 -0
  162. package/src/platforms/imessage/commands/index.ts +6 -0
  163. package/src/platforms/imessage/commands/message.test.ts +20 -0
  164. package/src/platforms/imessage/commands/message.ts +151 -0
  165. package/src/platforms/imessage/commands/setup.test.ts +52 -0
  166. package/src/platforms/imessage/commands/setup.ts +76 -0
  167. package/src/platforms/imessage/commands/shared.test.ts +19 -0
  168. package/src/platforms/imessage/commands/shared.ts +69 -0
  169. package/src/platforms/imessage/commands/whoami.ts +22 -0
  170. package/src/platforms/imessage/credential-manager.test.ts +122 -0
  171. package/src/platforms/imessage/credential-manager.ts +123 -0
  172. package/src/platforms/imessage/ensure-auth.ts +18 -0
  173. package/src/platforms/imessage/errors.ts +27 -0
  174. package/src/platforms/imessage/index.test.ts +13 -0
  175. package/src/platforms/imessage/index.ts +16 -0
  176. package/src/platforms/imessage/rpc.ts +157 -0
  177. package/src/platforms/imessage/test-stub-imsg.mjs +147 -0
  178. package/src/platforms/imessage/types.test.ts +47 -0
  179. package/src/platforms/imessage/types.ts +95 -0
  180. package/src/platforms/instagram/client.ts +23 -0
  181. package/src/platforms/instagram/hybrid-listener.test.ts +135 -0
  182. package/src/platforms/instagram/hybrid-listener.ts +196 -0
  183. package/src/platforms/instagram/index.ts +10 -0
  184. package/src/platforms/instagram/mqtt/connection.ts +72 -0
  185. package/src/platforms/instagram/mqtt/thrift.test.ts +91 -0
  186. package/src/platforms/instagram/mqtt/thrift.ts +159 -0
  187. package/src/platforms/instagram/mqtt/transport.ts +285 -0
  188. package/src/platforms/instagram/realtime-listener.test.ts +34 -0
  189. package/src/platforms/instagram/realtime-listener.ts +201 -0
  190. package/src/platforms/kakaotalk/client.test.ts +40 -0
  191. package/src/platforms/kakaotalk/client.ts +6 -4
  192. package/src/platforms/webexbot/cli.ts +0 -0
  193. package/src/tui/adapters/imessage-adapter.ts +131 -0
  194. package/src/tui/app.ts +13 -0
@@ -0,0 +1,13 @@
1
+ import { describe, expect, it } from 'bun:test'
2
+
3
+ import * as imessage from './index'
4
+
5
+ describe('imessage SDK barrel', () => {
6
+ it('exports client, rpc, credential manager, error, helper', () => {
7
+ expect(typeof imessage.ImsgClient).toBe('function')
8
+ expect(typeof imessage.ImsgRpc).toBe('function')
9
+ expect(typeof imessage.IMessageCredentialManager).toBe('function')
10
+ expect(typeof imessage.IMessageError).toBe('function')
11
+ expect(typeof imessage.createAccountId).toBe('function')
12
+ })
13
+ })
@@ -0,0 +1,16 @@
1
+ export { ImsgClient } from './client'
2
+ export type { OneShotRunner } from './client'
3
+ export { ImsgRpc } from './rpc'
4
+ export { IMessageCredentialManager } from './credential-manager'
5
+ export type { ResolvedAccount } from './credential-manager'
6
+ export {
7
+ createAccountId,
8
+ IMessageError,
9
+ type IMessageAccount,
10
+ type IMessageChatSummary,
11
+ type IMessageConfig,
12
+ type IMessageErrorCode,
13
+ type IMessageMessageSummary,
14
+ type IMessageProvider,
15
+ type IMessageStatus,
16
+ } from './types'
@@ -0,0 +1,157 @@
1
+ import { type ChildProcessWithoutNullStreams, spawn } from 'node:child_process'
2
+ import { createInterface, type Interface } from 'node:readline'
3
+
4
+ import { classifyImsgFailure } from './errors'
5
+ import { IMessageError } from './types'
6
+
7
+ interface JsonRpcResponse {
8
+ jsonrpc: '2.0'
9
+ id?: string | number
10
+ result?: unknown
11
+ error?: { code: number; message: string; data?: unknown }
12
+ method?: string
13
+ params?: { subscription?: number; message?: unknown; error?: { message?: string } }
14
+ }
15
+
16
+ type Pending = { resolve: (value: unknown) => void; reject: (err: Error) => void }
17
+ type MessageHandler = (message: unknown) => void
18
+ type ErrorHandler = (message: string) => void
19
+
20
+ function normalizeRpcError(error: { code: number; message: string; data?: unknown }): IMessageError {
21
+ const detail = typeof error.data === 'string' ? error.data : error.message
22
+ const text = `${error.message}${typeof error.data === 'string' ? `: ${error.data}` : ''}`
23
+ return classifyImsgFailure(detail.length > 0 ? `${detail} ${text}` : text, 'rpc_error')
24
+ }
25
+
26
+ export class ImsgRpc {
27
+ private child: ChildProcessWithoutNullStreams | null = null
28
+ private reader: Interface | null = null
29
+ private nextId = 1
30
+ private pending = new Map<string | number, Pending>()
31
+ private subscriptions = new Map<number, { onMessage: MessageHandler; onError?: ErrorHandler }>()
32
+ private stderrBuffer = ''
33
+
34
+ async start(binaryPath = 'imsg'): Promise<void> {
35
+ if (this.child) return
36
+
37
+ const child = await new Promise<ChildProcessWithoutNullStreams>((resolve, reject) => {
38
+ let proc: ChildProcessWithoutNullStreams
39
+ try {
40
+ proc = spawn(binaryPath, ['rpc'], { stdio: ['pipe', 'pipe', 'pipe'] })
41
+ } catch {
42
+ reject(this.spawnError(binaryPath))
43
+ return
44
+ }
45
+ proc.once('error', (err: NodeJS.ErrnoException) => {
46
+ reject(err.code === 'ENOENT' ? this.spawnError(binaryPath) : err)
47
+ })
48
+ proc.once('spawn', () => resolve(proc))
49
+ })
50
+
51
+ this.child = child
52
+ child.stderr.setEncoding('utf8')
53
+ child.stderr.on('data', (chunk: string) => {
54
+ this.stderrBuffer = (this.stderrBuffer + chunk).slice(-4096)
55
+ })
56
+
57
+ this.reader = createInterface({ input: child.stdout })
58
+ this.reader.on('line', (line: string) => this.handleLine(line))
59
+
60
+ child.once('exit', () => this.handleExit())
61
+ }
62
+
63
+ private spawnError(binaryPath: string): IMessageError {
64
+ return new IMessageError(`Could not run "${binaryPath}".`, 'imsg_not_found', {
65
+ suggestion: 'Install imsg: "brew install steipete/tap/imsg", or set --bin / AGENT_IMESSAGE_BIN.',
66
+ doctorCommand: 'agent-imessage doctor',
67
+ })
68
+ }
69
+
70
+ private handleLine(line: string): void {
71
+ const trimmed = line.trim()
72
+ if (!trimmed) return
73
+
74
+ let frame: JsonRpcResponse
75
+ try {
76
+ frame = JSON.parse(trimmed) as JsonRpcResponse
77
+ } catch {
78
+ return
79
+ }
80
+
81
+ if (frame.method === 'message' && frame.params?.subscription !== undefined) {
82
+ this.subscriptions.get(frame.params.subscription)?.onMessage(frame.params.message)
83
+ return
84
+ }
85
+ if (frame.method === 'error' && frame.params?.subscription !== undefined) {
86
+ this.subscriptions.get(frame.params.subscription)?.onError?.(frame.params.error?.message ?? 'watch error')
87
+ return
88
+ }
89
+
90
+ if (frame.id === undefined) return
91
+ const pending = this.pending.get(frame.id)
92
+ if (!pending) return
93
+ this.pending.delete(frame.id)
94
+
95
+ if (frame.error) {
96
+ pending.reject(normalizeRpcError(frame.error))
97
+ } else {
98
+ pending.resolve(frame.result)
99
+ }
100
+ }
101
+
102
+ private handleExit(): void {
103
+ const err = new IMessageError(
104
+ `imsg rpc process exited.${this.stderrBuffer ? ` ${this.stderrBuffer.trim()}` : ''}`,
105
+ 'rpc_error',
106
+ )
107
+ for (const pending of this.pending.values()) pending.reject(err)
108
+ this.pending.clear()
109
+ this.child = null
110
+ this.reader?.close()
111
+ this.reader = null
112
+ }
113
+
114
+ async request<T>(method: string, params: Record<string, unknown> = {}): Promise<T> {
115
+ if (!this.child) throw new IMessageError('imsg rpc is not running. Call start() first.', 'rpc_error')
116
+ const id = this.nextId++
117
+ const frame = `${JSON.stringify({ jsonrpc: '2.0', id, method, params })}\n`
118
+
119
+ return await new Promise<T>((resolve, reject) => {
120
+ this.pending.set(id, { resolve: resolve as (v: unknown) => void, reject })
121
+ this.child!.stdin.write(frame, (err) => {
122
+ if (err) {
123
+ this.pending.delete(id)
124
+ reject(err)
125
+ }
126
+ })
127
+ })
128
+ }
129
+
130
+ async subscribe(params: Record<string, unknown>, onMessage: MessageHandler, onError?: ErrorHandler): Promise<number> {
131
+ const result = await this.request<{ subscription: number }>('watch.subscribe', params)
132
+ this.subscriptions.set(result.subscription, { onMessage, onError })
133
+ return result.subscription
134
+ }
135
+
136
+ async unsubscribe(subscription: number): Promise<void> {
137
+ this.subscriptions.delete(subscription)
138
+ try {
139
+ await this.request('watch.unsubscribe', { subscription })
140
+ } catch {
141
+ this.subscriptions.delete(subscription)
142
+ }
143
+ }
144
+
145
+ close(): void {
146
+ const err = new IMessageError('imsg rpc connection closed.', 'rpc_error')
147
+ for (const pending of this.pending.values()) pending.reject(err)
148
+ this.pending.clear()
149
+ this.subscriptions.clear()
150
+ if (this.child) {
151
+ this.child.stdin.end()
152
+ this.child = null
153
+ }
154
+ this.reader?.close()
155
+ this.reader = null
156
+ }
157
+ }
@@ -0,0 +1,147 @@
1
+ #!/usr/bin/env node
2
+ // Test stub for the imsg binary. Behavior controlled by IMSG_STUB_MODE.
3
+ // Modes: ok (default), fda_denied, send_fail
4
+ const mode = process.env.IMSG_STUB_MODE || 'ok'
5
+ const arg = process.argv[2]
6
+
7
+ if (arg === '--version') {
8
+ if (mode === 'novers') {
9
+ process.exit(1)
10
+ }
11
+ process.stdout.write('0.11.1\n')
12
+ process.exit(0)
13
+ }
14
+
15
+ if (arg === 'react') {
16
+ if (mode === 'react_automation_denied') {
17
+ process.stderr.write('AppleScript error: Messages got an error: Not authorized to send Apple events\n')
18
+ process.exit(1)
19
+ }
20
+ if (mode === 'react_fail') {
21
+ process.stdout.write('error: could not react\n')
22
+ process.exit(1)
23
+ }
24
+ process.stdout.write(JSON.stringify({ success: true, chat_id: 42, reaction_type: 'love' }) + '\n')
25
+ process.exit(0)
26
+ }
27
+
28
+ if (arg === 'rpc') {
29
+ let buf = ''
30
+ process.stdin.on('data', (d) => {
31
+ buf += d
32
+ let i
33
+ while ((i = buf.indexOf('\n')) >= 0) {
34
+ const line = buf.slice(0, i).trim()
35
+ buf = buf.slice(i + 1)
36
+ if (!line) continue
37
+ const req = JSON.parse(line)
38
+ const reply = (result) => process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: req.id, result }) + '\n')
39
+ const fail = (code, message, data) =>
40
+ process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: req.id, error: { code, message, data } }) + '\n')
41
+
42
+ if (req.method === 'chats.list') {
43
+ if (mode === 'fda_denied') {
44
+ fail(-32603, 'Internal error', 'Permission Error: Cannot access Messages database')
45
+ } else if (mode === 'connect_rpc_fail') {
46
+ fail(-32603, 'Internal error', 'some unexpected internal failure')
47
+ } else {
48
+ reply({
49
+ chats: [
50
+ {
51
+ id: 42,
52
+ name: 'Jane',
53
+ identifier: 'iMessage;-;+15551234567',
54
+ guid: 'iMessage;-;+15551234567',
55
+ service: 'iMessage',
56
+ is_group: false,
57
+ participants: ['+15551234567'],
58
+ last_message: {
59
+ id: 1,
60
+ chat_id: 42,
61
+ guid: 'm1',
62
+ sender: '+15551234567',
63
+ is_from_me: false,
64
+ text: 'hi',
65
+ created_at: '2026-06-24T00:00:00.000Z',
66
+ },
67
+ },
68
+ {
69
+ id: 43,
70
+ name: 'Crew',
71
+ identifier: 'iMessage;+;chat99',
72
+ guid: 'iMessage;+;chat99',
73
+ service: 'iMessage',
74
+ is_group: true,
75
+ participants: ['+15551111111', '+15552222222'],
76
+ },
77
+ ],
78
+ })
79
+ }
80
+ } else if (req.method === 'messages.history') {
81
+ reply({
82
+ messages: [
83
+ {
84
+ id: 2,
85
+ chat_id: 42,
86
+ guid: 'm2',
87
+ sender: '',
88
+ is_from_me: true,
89
+ text: 'yo',
90
+ created_at: '2026-06-24T00:01:00.000Z',
91
+ },
92
+ {
93
+ id: 1,
94
+ chat_id: 42,
95
+ guid: 'm1',
96
+ sender: '+15551234567',
97
+ is_from_me: false,
98
+ text: 'hi',
99
+ created_at: '2026-06-24T00:00:00.000Z',
100
+ },
101
+ ],
102
+ })
103
+ } else if (req.method === 'send') {
104
+ if (mode === 'send_fail') {
105
+ fail(-32603, 'Internal error', 'Messages accepted the chat send but wrote an unjoined empty outgoing row')
106
+ } else if (mode === 'automation_denied') {
107
+ fail(
108
+ -32603,
109
+ 'Internal error',
110
+ 'AppleScript error: Messages got an error: Not authorized to send Apple events',
111
+ )
112
+ } else {
113
+ reply({ ok: true, id: 99, guid: 'sent-guid', service: 'iMessage' })
114
+ }
115
+ } else if (req.method === 'watch.subscribe') {
116
+ reply({ subscription: 1 })
117
+ setTimeout(
118
+ () =>
119
+ process.stdout.write(
120
+ JSON.stringify({
121
+ jsonrpc: '2.0',
122
+ method: 'message',
123
+ params: {
124
+ subscription: 1,
125
+ message: {
126
+ id: 3,
127
+ chat_id: 42,
128
+ guid: 'm3',
129
+ sender: '+15551234567',
130
+ is_from_me: false,
131
+ text: 'new!',
132
+ created_at: '2026-06-24T00:02:00.000Z',
133
+ },
134
+ },
135
+ }) + '\n',
136
+ ),
137
+ 30,
138
+ )
139
+ } else if (req.method === 'watch.unsubscribe') {
140
+ reply({ ok: true })
141
+ } else {
142
+ fail(-32601, 'Method not found')
143
+ }
144
+ }
145
+ })
146
+ process.stdin.on('end', () => process.exit(0))
147
+ }
@@ -0,0 +1,47 @@
1
+ import { describe, expect, it } from 'bun:test'
2
+
3
+ import { createAccountId, IMessageError, type IMessageErrorCode } from './types'
4
+
5
+ describe('createAccountId', () => {
6
+ it('slugifies deterministically and is filesystem-safe', () => {
7
+ expect(createAccountId('/opt/homebrew/bin/imsg')).toBe(createAccountId('/opt/homebrew/bin/imsg'))
8
+ expect(createAccountId('My Mac')).toBe('my-mac')
9
+ })
10
+
11
+ it('falls back to default for empty input', () => {
12
+ expect(createAccountId(' ')).toBe('default')
13
+ })
14
+ })
15
+
16
+ describe('IMessageError', () => {
17
+ const codes: IMessageErrorCode[] = [
18
+ 'imsg_not_found',
19
+ 'full_disk_access',
20
+ 'automation_denied',
21
+ 'rpc_error',
22
+ 'send_failed',
23
+ 'not_authenticated',
24
+ 'invalid_limit',
25
+ 'chat_not_found',
26
+ 'private_api_required',
27
+ 'imessage_error',
28
+ ]
29
+
30
+ it('is instantiable with every code and exposes .code', () => {
31
+ for (const code of codes) {
32
+ const err = new IMessageError('m', code)
33
+ expect(err.code).toBe(code)
34
+ expect(err).toBeInstanceOf(Error)
35
+ }
36
+ })
37
+
38
+ it('toJSON includes suggestion/doctorCommand only when present', () => {
39
+ expect(new IMessageError('m', 'rpc_error').toJSON()).toEqual({ error: 'm', code: 'rpc_error' })
40
+ expect(new IMessageError('m', 'imsg_not_found', { suggestion: 's', doctorCommand: 'd' }).toJSON()).toEqual({
41
+ error: 'm',
42
+ code: 'imsg_not_found',
43
+ suggestion: 's',
44
+ doctorCommand: 'd',
45
+ })
46
+ })
47
+ })
@@ -0,0 +1,95 @@
1
+ export type IMessageProvider = 'imsg'
2
+
3
+ export interface IMessageAccount {
4
+ account_id: string
5
+ provider: IMessageProvider
6
+ label?: string
7
+ binary_path?: string
8
+ region?: string
9
+ created_at: string
10
+ updated_at: string
11
+ }
12
+
13
+ export interface IMessageConfig {
14
+ current: string | null
15
+ accounts: Record<string, IMessageAccount>
16
+ }
17
+
18
+ export interface IMessageChatSummary {
19
+ id: number
20
+ guid: string | null
21
+ identifier: string | null
22
+ name: string
23
+ service: string
24
+ is_group: boolean
25
+ participants: string[]
26
+ last_message?: IMessageMessageSummary
27
+ }
28
+
29
+ export interface IMessageMessageSummary {
30
+ id: number
31
+ guid: string
32
+ chat_id: number
33
+ from: string
34
+ from_name?: string
35
+ is_outgoing: boolean
36
+ timestamp: string
37
+ text?: string
38
+ }
39
+
40
+ export interface IMessageStatus {
41
+ imsg_version: string | null
42
+ binary_path: string
43
+ full_disk_access: boolean
44
+ automation: 'ok' | 'unknown' | 'denied'
45
+ bridge_available: boolean
46
+ }
47
+
48
+ export type IMessageErrorCode =
49
+ | 'imsg_not_found'
50
+ | 'full_disk_access'
51
+ | 'automation_denied'
52
+ | 'rpc_error'
53
+ | 'send_failed'
54
+ | 'not_authenticated'
55
+ | 'invalid_limit'
56
+ | 'chat_not_found'
57
+ | 'private_api_required'
58
+ | 'imessage_error'
59
+
60
+ export class IMessageError extends Error {
61
+ code: IMessageErrorCode
62
+ suggestion?: string
63
+ doctorCommand?: string
64
+
65
+ constructor(
66
+ message: string,
67
+ code: IMessageErrorCode = 'imessage_error',
68
+ extra?: { suggestion?: string; doctorCommand?: string },
69
+ ) {
70
+ super(message)
71
+ this.name = 'IMessageError'
72
+ this.code = code
73
+ this.suggestion = extra?.suggestion
74
+ this.doctorCommand = extra?.doctorCommand
75
+ }
76
+
77
+ toJSON(): { error: string; code: IMessageErrorCode; suggestion?: string; doctorCommand?: string } {
78
+ return {
79
+ error: this.message,
80
+ code: this.code,
81
+ ...(this.suggestion ? { suggestion: this.suggestion } : {}),
82
+ ...(this.doctorCommand ? { doctorCommand: this.doctorCommand } : {}),
83
+ }
84
+ }
85
+ }
86
+
87
+ export function createAccountId(input: string): string {
88
+ const normalized = input
89
+ .trim()
90
+ .toLowerCase()
91
+ .replace(/[^a-z0-9]+/g, '-')
92
+ .replace(/^-+|-+$/g, '')
93
+
94
+ return normalized || 'default'
95
+ }
@@ -298,6 +298,24 @@ export class InstagramClient {
298
298
  return threads.map((t) => this.mapThread(t))
299
299
  }
300
300
 
301
+ async fetchIrisBootstrap(): Promise<{ seqId: number; snapshotAtMs: number }> {
302
+ this.ensureSession()
303
+ const { data } = await this.request('GET', '/direct_v2/inbox/?limit=1')
304
+
305
+ if (data['status'] !== 'ok') {
306
+ throw new InstagramError('Failed to fetch iris bootstrap', 'inbox_error')
307
+ }
308
+
309
+ const seqId = data['seq_id'] as number | undefined
310
+ const snapshotAtMs = data['snapshot_at_ms'] as number | undefined
311
+
312
+ if (typeof seqId !== 'number' || typeof snapshotAtMs !== 'number') {
313
+ throw new InstagramError('Iris bootstrap missing seq_id or snapshot_at_ms', 'iris_bootstrap_missing')
314
+ }
315
+
316
+ return { seqId, snapshotAtMs }
317
+ }
318
+
301
319
  async searchChats(query: string, limit = 20): Promise<InstagramChatSummary[]> {
302
320
  const allChats = await this.listChats(Math.max(limit, 50))
303
321
  const lower = query.toLowerCase()
@@ -405,6 +423,11 @@ export class InstagramClient {
405
423
  return this.userId
406
424
  }
407
425
 
426
+ getSessionState(): InstagramSessionState {
427
+ this.ensureSession()
428
+ return this.session!
429
+ }
430
+
408
431
  async getProfile(): Promise<{
409
432
  user_id: string
410
433
  username: string
@@ -0,0 +1,135 @@
1
+ import { afterEach, describe, expect, it, mock } from 'bun:test'
2
+ import { EventEmitter } from 'node:events'
3
+
4
+ import { InstagramHybridListener } from '@/platforms/instagram/hybrid-listener'
5
+ import type { InstagramRealtimeListener } from '@/platforms/instagram/realtime-listener'
6
+
7
+ function makeRealtimeFailingClient() {
8
+ return {
9
+ getUserId: () => '123',
10
+ getSessionState: () => {
11
+ throw new Error('realtime bootstrap failed')
12
+ },
13
+ fetchIrisBootstrap: async () => {
14
+ throw new Error('realtime bootstrap failed')
15
+ },
16
+ listChats: mock(async () => []),
17
+ } as never
18
+ }
19
+
20
+ class FakeRealtimeListener {
21
+ private emitter = new EventEmitter()
22
+ stopped = false
23
+
24
+ on(event: string, handler: (...args: unknown[]) => void) {
25
+ this.emitter.on(event, handler)
26
+ return this
27
+ }
28
+ off() {
29
+ return this
30
+ }
31
+ once() {
32
+ return this
33
+ }
34
+ async start() {
35
+ this.emitter.emit('connected', { userId: '123' })
36
+ }
37
+ stop() {
38
+ this.stopped = true
39
+ this.emitter.emit('disconnected')
40
+ }
41
+ fail() {
42
+ this.emitter.emit('error', new Error('realtime connection lost'))
43
+ }
44
+ }
45
+
46
+ class TestableHybridListener extends InstagramHybridListener {
47
+ lastFake: FakeRealtimeListener | null = null
48
+ protected override createRealtimeListener(): InstagramRealtimeListener {
49
+ const fake = new FakeRealtimeListener()
50
+ this.lastFake = fake
51
+ return fake as unknown as InstagramRealtimeListener
52
+ }
53
+ }
54
+
55
+ describe('InstagramHybridListener', () => {
56
+ let listener: InstagramHybridListener
57
+
58
+ afterEach(() => {
59
+ listener?.stop()
60
+ })
61
+
62
+ it('falls back to polling and emits a single error when realtime startup fails', async () => {
63
+ const client = makeRealtimeFailingClient()
64
+ listener = new InstagramHybridListener(client, {
65
+ pollInterval: 60_000,
66
+ realtimeRetryBaseMs: 60_000,
67
+ realtimeRetryMaxMs: 60_000,
68
+ })
69
+
70
+ const errors: Error[] = []
71
+ listener.on('error', (err) => errors.push(err))
72
+
73
+ const connected = new Promise<{ userId: string; transport: string }>((resolve) => {
74
+ listener.on('connected', resolve)
75
+ })
76
+
77
+ await listener.start()
78
+ const info = await connected
79
+
80
+ // given a realtime failure, when startup fails, then it falls back to polling
81
+ expect(info.transport).toBe('polling')
82
+ // and the failure is reported exactly once (no recursive re-entry)
83
+ expect(errors).toHaveLength(1)
84
+ })
85
+
86
+ it('falls back to polling exactly once when a connected realtime listener fails', async () => {
87
+ const client = {
88
+ getUserId: () => '123',
89
+ listChats: mock(async () => []),
90
+ } as never
91
+ const testable = new TestableHybridListener(client, {
92
+ pollInterval: 60_000,
93
+ realtimeRetryBaseMs: 60_000,
94
+ realtimeRetryMaxMs: 60_000,
95
+ })
96
+ listener = testable
97
+
98
+ const errors: Error[] = []
99
+ const transports: string[] = []
100
+ testable.on('error', (err) => errors.push(err))
101
+ testable.on('connected', ({ transport }) => transports.push(transport))
102
+
103
+ await testable.start()
104
+ // given an established realtime connection
105
+ expect(transports).toContain('realtime')
106
+
107
+ const pollingConnected = new Promise<void>((resolve) => {
108
+ testable.on('connected', ({ transport }) => {
109
+ if (transport === 'polling') resolve()
110
+ })
111
+ })
112
+
113
+ // when the connected realtime listener fails
114
+ testable.lastFake!.fail()
115
+ await pollingConnected
116
+
117
+ // then the failure is reported once and it falls back to polling without recursion
118
+ expect(errors).toHaveLength(1)
119
+ expect(transports.filter((t) => t === 'polling')).toHaveLength(1)
120
+ })
121
+
122
+ it('uses polling directly when realtime is disabled', async () => {
123
+ const client = makeRealtimeFailingClient()
124
+ listener = new InstagramHybridListener(client, { pollInterval: 60_000, disableRealtime: true })
125
+
126
+ const connected = new Promise<{ transport: string }>((resolve) => {
127
+ listener.on('connected', resolve)
128
+ })
129
+
130
+ await listener.start()
131
+ const info = await connected
132
+
133
+ expect(info.transport).toBe('polling')
134
+ })
135
+ })