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
@@ -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
+ })
@@ -0,0 +1,196 @@
1
+ import { EventEmitter } from 'node:events'
2
+
3
+ import type { InstagramClient } from './client'
4
+ import { InstagramListener } from './listener'
5
+ import { InstagramRealtimeListener } from './realtime-listener'
6
+ import type { InstagramMessageSummary } from './types'
7
+
8
+ export interface InstagramHybridListenerEventMap {
9
+ message: [InstagramMessageSummary]
10
+ error: [Error]
11
+ connected: [{ userId: string; transport: 'realtime' | 'polling' }]
12
+ disconnected: []
13
+ }
14
+
15
+ type EventKey = keyof InstagramHybridListenerEventMap
16
+
17
+ const DEFAULT_REALTIME_RETRY_BASE_MS = 30_000
18
+ const DEFAULT_REALTIME_RETRY_MAX_MS = 5 * 60_000
19
+
20
+ export interface InstagramHybridListenerOptions {
21
+ pollInterval?: number
22
+ realtimeRetryBaseMs?: number
23
+ realtimeRetryMaxMs?: number
24
+ disableRealtime?: boolean
25
+ connackTimeoutMs?: number
26
+ }
27
+
28
+ export class InstagramHybridListener {
29
+ private client: InstagramClient
30
+ private emitter = new EventEmitter()
31
+ private realtime: InstagramRealtimeListener | null = null
32
+ private poller: InstagramListener | null = null
33
+ private options: Required<
34
+ Pick<InstagramHybridListenerOptions, 'realtimeRetryBaseMs' | 'realtimeRetryMaxMs' | 'disableRealtime'>
35
+ > &
36
+ Pick<InstagramHybridListenerOptions, 'pollInterval' | 'connackTimeoutMs'>
37
+ private running = false
38
+ private realtimeRetryTimer: ReturnType<typeof setTimeout> | null = null
39
+ private realtimeFailures = 0
40
+ private activeTransport: 'realtime' | 'polling' | null = null
41
+ private tearingDownRealtime = false
42
+
43
+ constructor(client: InstagramClient, options: InstagramHybridListenerOptions = {}) {
44
+ this.client = client
45
+ this.options = {
46
+ pollInterval: options.pollInterval,
47
+ realtimeRetryBaseMs: options.realtimeRetryBaseMs ?? DEFAULT_REALTIME_RETRY_BASE_MS,
48
+ realtimeRetryMaxMs: options.realtimeRetryMaxMs ?? DEFAULT_REALTIME_RETRY_MAX_MS,
49
+ disableRealtime: options.disableRealtime ?? false,
50
+ connackTimeoutMs: options.connackTimeoutMs,
51
+ }
52
+ }
53
+
54
+ async start(): Promise<void> {
55
+ if (this.running) return
56
+ this.running = true
57
+
58
+ if (this.options.disableRealtime) {
59
+ this.startPolling()
60
+ return
61
+ }
62
+
63
+ await this.tryRealtime()
64
+ }
65
+
66
+ stop(): void {
67
+ this.running = false
68
+ this.activeTransport = null
69
+ this.clearRealtimeRetry()
70
+ this.teardownRealtime()
71
+ this.teardownPolling()
72
+ this.emitter.emit('disconnected')
73
+ }
74
+
75
+ on<K extends EventKey>(event: K, listener: (...args: InstagramHybridListenerEventMap[K]) => void): this {
76
+ this.emitter.on(event, listener as (...args: unknown[]) => void)
77
+ return this
78
+ }
79
+
80
+ off<K extends EventKey>(event: K, listener: (...args: InstagramHybridListenerEventMap[K]) => void): this {
81
+ this.emitter.off(event, listener as (...args: unknown[]) => void)
82
+ return this
83
+ }
84
+
85
+ once<K extends EventKey>(event: K, listener: (...args: InstagramHybridListenerEventMap[K]) => void): this {
86
+ this.emitter.once(event, listener as (...args: unknown[]) => void)
87
+ return this
88
+ }
89
+
90
+ protected createRealtimeListener(): InstagramRealtimeListener {
91
+ return new InstagramRealtimeListener(this.client, { connackTimeoutMs: this.options.connackTimeoutMs })
92
+ }
93
+
94
+ private async tryRealtime(): Promise<void> {
95
+ const realtime = this.createRealtimeListener()
96
+ this.realtime = realtime
97
+
98
+ realtime.on('message', (message) => this.emitter.emit('message', message))
99
+ realtime.on('connected', ({ userId }) => {
100
+ this.realtimeFailures = 0
101
+ this.activeTransport = 'realtime'
102
+ this.teardownPolling()
103
+ this.emitter.emit('connected', { userId, transport: 'realtime' })
104
+ })
105
+ realtime.on('error', (error) => this.onRealtimeFailure(error))
106
+ realtime.on('disconnected', () => {
107
+ // Ignore the 'disconnected' that our own teardown triggers; only an
108
+ // unexpected close (still marked realtime-active) is a real failure.
109
+ if (this.tearingDownRealtime) return
110
+ if (this.running && this.activeTransport === 'realtime') {
111
+ this.onRealtimeFailure(new Error('Realtime connection closed'))
112
+ }
113
+ })
114
+
115
+ try {
116
+ await realtime.start()
117
+ // stop() may have run during the bootstrap await; if so, don't leave an MQTT
118
+ // connection alive after the hybrid listener was already torn down.
119
+ if (!this.running) {
120
+ realtime.stop()
121
+ if (this.realtime === realtime) this.realtime = null
122
+ }
123
+ } catch (error) {
124
+ this.onRealtimeFailure(error instanceof Error ? error : new Error(String(error)))
125
+ }
126
+ }
127
+
128
+ private onRealtimeFailure(error: Error): void {
129
+ if (!this.running) return
130
+
131
+ this.activeTransport = null
132
+ this.emitter.emit('error', error)
133
+ this.realtimeFailures++
134
+ // teardownRealtime() suppresses the 'disconnected' it triggers, so this path
135
+ // does not re-enter via the realtime disconnected handler.
136
+ this.teardownRealtime()
137
+ this.startPolling()
138
+ this.scheduleRealtimeRetry()
139
+ }
140
+
141
+ private scheduleRealtimeRetry(): void {
142
+ if (!this.running || this.realtimeRetryTimer) return
143
+
144
+ const delay = Math.min(
145
+ this.options.realtimeRetryBaseMs * 2 ** (this.realtimeFailures - 1),
146
+ this.options.realtimeRetryMaxMs,
147
+ )
148
+
149
+ this.realtimeRetryTimer = setTimeout(() => {
150
+ this.realtimeRetryTimer = null
151
+ if (this.running) void this.tryRealtime()
152
+ }, delay)
153
+ }
154
+
155
+ private startPolling(): void {
156
+ if (this.poller) return
157
+
158
+ const poller = new InstagramListener(this.client, { pollInterval: this.options.pollInterval })
159
+ this.poller = poller
160
+
161
+ poller.on('message', (message) => this.emitter.emit('message', message))
162
+ poller.on('error', (error) => this.emitter.emit('error', error))
163
+ poller.on('connected', ({ userId }) => {
164
+ this.activeTransport = 'polling'
165
+ this.emitter.emit('connected', { userId, transport: 'polling' })
166
+ })
167
+
168
+ void poller.start()
169
+ }
170
+
171
+ private teardownRealtime(): void {
172
+ if (this.realtime) {
173
+ this.tearingDownRealtime = true
174
+ try {
175
+ this.realtime.stop()
176
+ } finally {
177
+ this.tearingDownRealtime = false
178
+ }
179
+ this.realtime = null
180
+ }
181
+ }
182
+
183
+ private teardownPolling(): void {
184
+ if (this.poller) {
185
+ this.poller.stop()
186
+ this.poller = null
187
+ }
188
+ }
189
+
190
+ private clearRealtimeRetry(): void {
191
+ if (this.realtimeRetryTimer) {
192
+ clearTimeout(this.realtimeRetryTimer)
193
+ this.realtimeRetryTimer = null
194
+ }
195
+ }
196
+ }
@@ -1,6 +1,16 @@
1
1
  export { InstagramClient } from './client'
2
2
  export { InstagramCredentialManager } from './credential-manager'
3
+ export {
4
+ InstagramHybridListener,
5
+ type InstagramHybridListenerEventMap,
6
+ type InstagramHybridListenerOptions,
7
+ } from './hybrid-listener'
3
8
  export { InstagramListener, type InstagramListenerEventMap } from './listener'
9
+ export {
10
+ InstagramRealtimeListener,
11
+ type InstagramRealtimeListenerEventMap,
12
+ type InstagramRealtimeListenerOptions,
13
+ } from './realtime-listener'
4
14
  export { InstagramTokenExtractor, type ExtractedInstagramCookies } from './token-extractor'
5
15
  export {
6
16
  createAccountId,