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,285 @@
1
+ import { EventEmitter } from 'node:events'
2
+ import tls from 'node:tls'
3
+
4
+ const MQTTOT_HOST = 'edge-mqtt.facebook.com'
5
+ const MQTTOT_PORT = 443
6
+ const PROTOCOL_NAME = 'MQTToT'
7
+ const PROTOCOL_LEVEL = 3
8
+
9
+ // CONNECT flags 0xC2 = username present + clean session; the "password" travels
10
+ // inside the Thrift payload rather than the standard MQTT password field.
11
+ const CONNECT_FLAGS = 0xc2
12
+ const KEEPALIVE_SECONDS = 60
13
+ const CONNACK_TIMEOUT_MS = 15_000
14
+
15
+ const PacketType = {
16
+ Connect: 1,
17
+ ConnAck: 2,
18
+ Publish: 3,
19
+ PubAck: 4,
20
+ Subscribe: 8,
21
+ PingReq: 12,
22
+ PingResp: 13,
23
+ Disconnect: 14,
24
+ } as const
25
+
26
+ export interface MqttPublish {
27
+ topic: string
28
+ payload: Buffer
29
+ }
30
+
31
+ export interface MqttTransportEventMap {
32
+ connect: []
33
+ publish: [MqttPublish]
34
+ error: [Error]
35
+ close: []
36
+ }
37
+
38
+ type EventKey = keyof MqttTransportEventMap
39
+
40
+ export class MqttTransport {
41
+ private socket: tls.TLSSocket | null = null
42
+ private emitter = new EventEmitter()
43
+ private buffer = Buffer.alloc(0)
44
+ private nextPacketId = 1
45
+ private pingTimer: ReturnType<typeof setInterval> | null = null
46
+ private connackTimer: ReturnType<typeof setTimeout> | null = null
47
+ private connackTimeoutMs: number
48
+
49
+ constructor(options: { connackTimeoutMs?: number } = {}) {
50
+ this.connackTimeoutMs = options.connackTimeoutMs ?? CONNACK_TIMEOUT_MS
51
+ }
52
+
53
+ connect(connectPayload: Buffer): void {
54
+ const socket = tls.connect({ host: MQTTOT_HOST, port: MQTTOT_PORT, servername: MQTTOT_HOST }, () => {
55
+ socket.write(this.buildConnectPacket(connectPayload))
56
+ })
57
+ this.socket = socket
58
+
59
+ // Guard against a peer that accepts TLS but never sends CONNACK, which would
60
+ // otherwise leave a hybrid caller waiting forever instead of failing over.
61
+ this.connackTimer = setTimeout(() => {
62
+ this.connackTimer = null
63
+ this.emitter.emit('error', new Error('MQTToT CONNACK timed out'))
64
+ socket.destroy()
65
+ }, this.connackTimeoutMs)
66
+
67
+ socket.on('data', (chunk: Buffer) => this.onData(chunk))
68
+ socket.on('error', (err: Error) => this.emitter.emit('error', err))
69
+ socket.on('close', () => {
70
+ this.clearConnackTimer()
71
+ this.stopPing()
72
+ this.emitter.emit('close')
73
+ })
74
+ }
75
+
76
+ publish(topic: string, payload: Buffer, qos: 0 | 1 = 1): void {
77
+ if (!this.socket) throw new Error('MQTT transport not connected')
78
+ this.socket.write(this.buildPublishPacket(topic, payload, qos))
79
+ }
80
+
81
+ subscribe(topics: string[]): void {
82
+ if (!this.socket) throw new Error('MQTT transport not connected')
83
+ this.socket.write(this.buildSubscribePacket(topics))
84
+ }
85
+
86
+ disconnect(): void {
87
+ this.clearConnackTimer()
88
+ this.stopPing()
89
+ if (this.socket) {
90
+ try {
91
+ this.socket.write(Buffer.from([PacketType.Disconnect << 4, 0]))
92
+ } catch {
93
+ // socket may already be torn down; closing below is sufficient
94
+ }
95
+ this.socket.destroy()
96
+ this.socket = null
97
+ }
98
+ }
99
+
100
+ on<K extends EventKey>(event: K, listener: (...args: MqttTransportEventMap[K]) => void): this {
101
+ this.emitter.on(event, listener as (...args: unknown[]) => void)
102
+ return this
103
+ }
104
+
105
+ private onData(chunk: Buffer): void {
106
+ this.buffer = Buffer.concat([this.buffer, chunk])
107
+ this.drainPackets()
108
+ }
109
+
110
+ private drainPackets(): void {
111
+ while (this.buffer.length >= 2) {
112
+ const decoded = decodeRemainingLength(this.buffer, 1)
113
+ if (!decoded) return
114
+
115
+ const { length: remainingLength, bytesUsed } = decoded
116
+ const totalLength = 1 + bytesUsed + remainingLength
117
+ if (this.buffer.length < totalLength) return
118
+
119
+ const packet = this.buffer.subarray(0, totalLength)
120
+ this.buffer = this.buffer.subarray(totalLength)
121
+ this.handlePacket(packet, 1 + bytesUsed)
122
+ }
123
+ }
124
+
125
+ private handlePacket(packet: Buffer, bodyStart: number): void {
126
+ const type = (packet[0]! >> 4) & 0x0f
127
+
128
+ switch (type) {
129
+ case PacketType.ConnAck: {
130
+ this.clearConnackTimer()
131
+ const returnCode = packet[bodyStart + 1]
132
+ if (returnCode === 0) {
133
+ this.startPing()
134
+ this.emitter.emit('connect')
135
+ } else {
136
+ this.emitter.emit('error', new Error(`MQTToT CONNACK refused with return code ${returnCode}`))
137
+ }
138
+ break
139
+ }
140
+ case PacketType.Publish:
141
+ this.handlePublish(packet, bodyStart)
142
+ break
143
+ case PacketType.PingResp:
144
+ case PacketType.PubAck:
145
+ case PacketType.Subscribe + 1:
146
+ break
147
+ }
148
+ }
149
+
150
+ private handlePublish(packet: Buffer, bodyStart: number): void {
151
+ const qos = (packet[0]! >> 1) & 0x03
152
+
153
+ // A truncated/malformed frame must not throw out of the data handler, or it
154
+ // would crash the process instead of letting the listener fail over.
155
+ if (bodyStart + 2 > packet.length) return
156
+ const topicLength = packet.readUInt16BE(bodyStart)
157
+ let offset = bodyStart + 2
158
+ if (offset + topicLength > packet.length) return
159
+ const topic = packet.toString('utf8', offset, offset + topicLength)
160
+ offset += topicLength
161
+
162
+ let packetId: number | null = null
163
+ if (qos > 0) {
164
+ if (offset + 2 > packet.length) return
165
+ packetId = packet.readUInt16BE(offset)
166
+ offset += 2
167
+ }
168
+
169
+ const payload = packet.subarray(offset)
170
+ this.emitter.emit('publish', { topic, payload })
171
+
172
+ if (qos === 1 && packetId !== null) {
173
+ this.sendPubAck(packetId)
174
+ }
175
+ }
176
+
177
+ private sendPubAck(packetId: number): void {
178
+ const packet = Buffer.from([PacketType.PubAck << 4, 2, (packetId >> 8) & 0xff, packetId & 0xff])
179
+ this.socket?.write(packet)
180
+ }
181
+
182
+ private startPing(): void {
183
+ this.stopPing()
184
+ this.pingTimer = setInterval(() => {
185
+ this.socket?.write(Buffer.from([PacketType.PingReq << 4, 0]))
186
+ }, KEEPALIVE_SECONDS * 1000)
187
+ }
188
+
189
+ private stopPing(): void {
190
+ if (this.pingTimer) {
191
+ clearInterval(this.pingTimer)
192
+ this.pingTimer = null
193
+ }
194
+ }
195
+
196
+ private clearConnackTimer(): void {
197
+ if (this.connackTimer) {
198
+ clearTimeout(this.connackTimer)
199
+ this.connackTimer = null
200
+ }
201
+ }
202
+
203
+ private buildConnectPacket(payload: Buffer): Buffer {
204
+ const variableHeader: number[] = []
205
+ appendString(variableHeader, PROTOCOL_NAME)
206
+ variableHeader.push(PROTOCOL_LEVEL, CONNECT_FLAGS)
207
+ variableHeader.push((KEEPALIVE_SECONDS >> 8) & 0xff, KEEPALIVE_SECONDS & 0xff)
208
+
209
+ const body = Buffer.concat([Buffer.from(variableHeader), payload])
210
+ return Buffer.concat([Buffer.from([PacketType.Connect << 4, ...encodeRemainingLength(body.length)]), body])
211
+ }
212
+
213
+ private buildPublishPacket(topic: string, payload: Buffer, qos: 0 | 1): Buffer {
214
+ const variableHeader: number[] = []
215
+ appendString(variableHeader, topic)
216
+ if (qos > 0) {
217
+ const packetId = this.allocatePacketId()
218
+ variableHeader.push((packetId >> 8) & 0xff, packetId & 0xff)
219
+ }
220
+
221
+ const body = Buffer.concat([Buffer.from(variableHeader), payload])
222
+ const flags = qos << 1
223
+ return Buffer.concat([
224
+ Buffer.from([(PacketType.Publish << 4) | flags, ...encodeRemainingLength(body.length)]),
225
+ body,
226
+ ])
227
+ }
228
+
229
+ private buildSubscribePacket(topics: string[]): Buffer {
230
+ const packetId = this.allocatePacketId()
231
+ const body: number[] = [(packetId >> 8) & 0xff, packetId & 0xff]
232
+ for (const topic of topics) {
233
+ appendString(body, topic)
234
+ body.push(0)
235
+ }
236
+ // SUBSCRIBE has reserved flags 0010 in the fixed-header low nibble.
237
+ return Buffer.concat([
238
+ Buffer.from([(PacketType.Subscribe << 4) | 0x02, ...encodeRemainingLength(body.length)]),
239
+ Buffer.from(body),
240
+ ])
241
+ }
242
+
243
+ private allocatePacketId(): number {
244
+ const id = this.nextPacketId
245
+ this.nextPacketId = (this.nextPacketId % 0xffff) + 1
246
+ return id
247
+ }
248
+ }
249
+
250
+ function appendString(target: number[], value: string): void {
251
+ const buf = Buffer.from(value, 'utf8')
252
+ target.push((buf.length >> 8) & 0xff, buf.length & 0xff)
253
+ for (const b of buf) target.push(b)
254
+ }
255
+
256
+ function encodeRemainingLength(length: number): number[] {
257
+ const bytes: number[] = []
258
+ let value = length
259
+ do {
260
+ let digit = value & 0x7f
261
+ value >>= 7
262
+ if (value > 0) digit |= 0x80
263
+ bytes.push(digit)
264
+ } while (value > 0)
265
+ return bytes
266
+ }
267
+
268
+ function decodeRemainingLength(buffer: Buffer, offset: number): { length: number; bytesUsed: number } | null {
269
+ let length = 0
270
+ let multiplier = 1
271
+ let bytesUsed = 0
272
+
273
+ while (offset + bytesUsed < buffer.length) {
274
+ const byte = buffer[offset + bytesUsed]!
275
+ length += (byte & 0x7f) * multiplier
276
+ bytesUsed++
277
+ if ((byte & 0x80) === 0) {
278
+ return { length, bytesUsed }
279
+ }
280
+ multiplier *= 128
281
+ if (bytesUsed > 4) return null
282
+ }
283
+
284
+ return null
285
+ }
@@ -0,0 +1,34 @@
1
+ import { describe, expect, it } from 'bun:test'
2
+
3
+ import { InstagramRealtimeListener } from '@/platforms/instagram/realtime-listener'
4
+
5
+ function makeClient(overrides: Record<string, unknown> = {}) {
6
+ return {
7
+ getUserId: () => '123',
8
+ getSessionState: () => ({}) as never,
9
+ fetchIrisBootstrap: async () => ({ seqId: 1, snapshotAtMs: 1 }),
10
+ ...overrides,
11
+ } as never
12
+ }
13
+
14
+ describe('InstagramRealtimeListener', () => {
15
+ it('stays restartable after a bootstrap failure', async () => {
16
+ let calls = 0
17
+ const client = makeClient({
18
+ fetchIrisBootstrap: async () => {
19
+ calls++
20
+ throw new Error('bootstrap failed')
21
+ },
22
+ })
23
+ const listener = new InstagramRealtimeListener(client)
24
+
25
+ // given a failing bootstrap, when start() rejects, then running must be reset
26
+ await expect(listener.start()).rejects.toThrow('bootstrap failed')
27
+
28
+ // when start() is called again, then it must retry rather than no-op at the guard
29
+ await expect(listener.start()).rejects.toThrow('bootstrap failed')
30
+ expect(calls).toBe(2)
31
+
32
+ listener.stop()
33
+ })
34
+ })
@@ -0,0 +1,201 @@
1
+ import { EventEmitter } from 'node:events'
2
+ import { deflateSync, inflateSync } from 'node:zlib'
3
+
4
+ import type { InstagramClient } from './client'
5
+ import { buildConnectPayload } from './mqtt/connection'
6
+ import { MqttTransport } from './mqtt/transport'
7
+ import { extractMediaUrl, extractMessageText, getMessageType, type InstagramMessageSummary } from './types'
8
+
9
+ export interface InstagramRealtimeListenerEventMap {
10
+ message: [InstagramMessageSummary]
11
+ error: [Error]
12
+ connected: [{ userId: string }]
13
+ disconnected: []
14
+ }
15
+
16
+ export interface InstagramRealtimeListenerOptions {
17
+ connackTimeoutMs?: number
18
+ }
19
+
20
+ type EventKey = keyof InstagramRealtimeListenerEventMap
21
+
22
+ const TOPIC_PUBSUB = '88'
23
+ const TOPIC_REALTIME_SUB = '149'
24
+ const TOPIC_IRIS_SUB = '134'
25
+ const TOPIC_MESSAGE_SYNC = '146'
26
+
27
+ const ZLIB_MAGIC_BYTE = 0x78
28
+
29
+ const DM_THREAD_PATH_PATTERN = /^\/direct_v2\/(?:inbox\/)?threads\//
30
+ const THREAD_ID_PATTERN = /^\/direct_v2\/(?:inbox\/)?threads\/(\d+)/
31
+
32
+ interface IrisEnvelope {
33
+ data?: IrisPatch[]
34
+ }
35
+
36
+ interface IrisPatch {
37
+ op?: string
38
+ path?: string
39
+ value?: string
40
+ }
41
+
42
+ export class InstagramRealtimeListener {
43
+ private client: InstagramClient
44
+ private transport: MqttTransport | null = null
45
+ private emitter = new EventEmitter()
46
+ private running = false
47
+ private userId: string
48
+ private connackTimeoutMs: number | undefined
49
+
50
+ constructor(client: InstagramClient, options: InstagramRealtimeListenerOptions = {}) {
51
+ this.client = client
52
+ this.userId = client.getUserId() ?? ''
53
+ this.connackTimeoutMs = options.connackTimeoutMs
54
+ }
55
+
56
+ async start(): Promise<void> {
57
+ if (this.running) return
58
+ this.running = true
59
+
60
+ try {
61
+ const session = this.client.getSessionState()
62
+ const iris = await this.client.fetchIrisBootstrap()
63
+
64
+ if (!this.running) return
65
+
66
+ const connectPayload = buildConnectPayload(session)
67
+
68
+ const transport = new MqttTransport({ connackTimeoutMs: this.connackTimeoutMs })
69
+ this.transport = transport
70
+
71
+ transport.on('connect', () => {
72
+ this.sendSubscriptions(iris)
73
+ this.emitter.emit('connected', { userId: this.userId })
74
+ })
75
+ transport.on('publish', ({ topic, payload }) => this.handlePublish(topic, payload))
76
+ transport.on('error', (err) => this.emitter.emit('error', err))
77
+ transport.on('close', () => {
78
+ if (this.running) this.emitter.emit('disconnected')
79
+ })
80
+
81
+ transport.connect(connectPayload)
82
+ } catch (error) {
83
+ // Any setup failure must leave the listener cleanly restartable: reset running
84
+ // and discard the partial transport so a later start() does not no-op at the guard.
85
+ this.running = false
86
+ if (this.transport) {
87
+ this.transport.disconnect()
88
+ this.transport = null
89
+ }
90
+ throw error
91
+ }
92
+ }
93
+
94
+ stop(): void {
95
+ this.running = false
96
+ if (this.transport) {
97
+ this.transport.disconnect()
98
+ this.transport = null
99
+ }
100
+ this.emitter.emit('disconnected')
101
+ }
102
+
103
+ on<K extends EventKey>(event: K, listener: (...args: InstagramRealtimeListenerEventMap[K]) => void): this {
104
+ this.emitter.on(event, listener as (...args: unknown[]) => void)
105
+ return this
106
+ }
107
+
108
+ off<K extends EventKey>(event: K, listener: (...args: InstagramRealtimeListenerEventMap[K]) => void): this {
109
+ this.emitter.off(event, listener as (...args: unknown[]) => void)
110
+ return this
111
+ }
112
+
113
+ once<K extends EventKey>(event: K, listener: (...args: InstagramRealtimeListenerEventMap[K]) => void): this {
114
+ this.emitter.once(event, listener as (...args: unknown[]) => void)
115
+ return this
116
+ }
117
+
118
+ private sendSubscriptions(iris: { seqId: number; snapshotAtMs: number }): void {
119
+ const transport = this.transport
120
+ if (!transport) return
121
+
122
+ transport.publish(
123
+ TOPIC_IRIS_SUB,
124
+ deflateJson({
125
+ seq_id: iris.seqId,
126
+ snapshot_at_ms: iris.snapshotAtMs,
127
+ snapshot_app_version: APP_VERSION,
128
+ }),
129
+ )
130
+
131
+ transport.publish(TOPIC_PUBSUB, deflateJson({ sub: [`ig/u/v1/${this.userId}`] }))
132
+
133
+ transport.publish(
134
+ TOPIC_REALTIME_SUB,
135
+ deflateJson({
136
+ sub: [`1/graphqlsubscriptions/17867973967082385/{"input_data":{"user_id":"${this.userId}"}}`],
137
+ }),
138
+ )
139
+ }
140
+
141
+ private handlePublish(topic: string, payload: Buffer): void {
142
+ if (topic !== TOPIC_MESSAGE_SYNC) return
143
+
144
+ let envelopes: IrisEnvelope[]
145
+ try {
146
+ envelopes = JSON.parse(inflateIfNeeded(payload).toString('utf8')) as IrisEnvelope[]
147
+ } catch (error) {
148
+ this.emitter.emit('error', error instanceof Error ? error : new Error(String(error)))
149
+ return
150
+ }
151
+
152
+ for (const envelope of envelopes) {
153
+ for (const patch of envelope.data ?? []) {
154
+ const message = this.decodeDmPatch(patch)
155
+ if (message) this.emitter.emit('message', message)
156
+ }
157
+ }
158
+ }
159
+
160
+ private decodeDmPatch(patch: IrisPatch): InstagramMessageSummary | null {
161
+ if (patch.op !== 'add') return null
162
+ if (!patch.path || !patch.value || !DM_THREAD_PATH_PATTERN.test(patch.path)) return null
163
+
164
+ const threadId = patch.path.match(THREAD_ID_PATTERN)?.[1] ?? ''
165
+
166
+ let item: Record<string, unknown>
167
+ try {
168
+ item = JSON.parse(patch.value) as Record<string, unknown>
169
+ } catch {
170
+ return null
171
+ }
172
+
173
+ const itemId = String(item['item_id'] ?? '')
174
+ const fromUserId = String(item['user_id'] ?? '')
175
+ const timestampUs = item['timestamp'] as number | string | undefined
176
+
177
+ return {
178
+ id: itemId,
179
+ thread_id: threadId,
180
+ from: fromUserId,
181
+ timestamp: timestampUs ? new Date(Number(timestampUs) / 1000).toISOString() : new Date().toISOString(),
182
+ is_outgoing: fromUserId === this.userId,
183
+ type: getMessageType(item),
184
+ text: extractMessageText(item),
185
+ media_url: extractMediaUrl(item),
186
+ }
187
+ }
188
+ }
189
+
190
+ const APP_VERSION = '312.1.0.34.111'
191
+
192
+ function deflateJson(value: unknown): Buffer {
193
+ return deflateSync(JSON.stringify(value), { level: 9 })
194
+ }
195
+
196
+ function inflateIfNeeded(payload: Buffer): Buffer {
197
+ if (payload.length > 0 && payload[0] === ZLIB_MAGIC_BYTE) {
198
+ return inflateSync(payload)
199
+ }
200
+ return payload
201
+ }
@@ -540,6 +540,46 @@ describe('KakaoTalkClient', () => {
540
540
  client.close()
541
541
  })
542
542
 
543
+ it('normalizes Long-like chat ids from LCHATLIST pages', async () => {
544
+ const loginResult = {
545
+ ...DEFAULT_LOGIN_RESULT,
546
+ eof: false,
547
+ }
548
+ mockLogin.mockResolvedValue(loginResult)
549
+
550
+ mockGetChatList.mockResolvedValueOnce({
551
+ body: {
552
+ chatDatas: [
553
+ {
554
+ c: makeLong(300),
555
+ t: 1,
556
+ k: ['Dave'],
557
+ i: [4],
558
+ a: 1,
559
+ n: 0,
560
+ o: 1698000000,
561
+ l: { authorId: 4, message: 'from paginated chat', sendAt: 1698000000 },
562
+ ll: makeLong(100),
563
+ },
564
+ ],
565
+ lastTokenId: makeLong(1),
566
+ lastChatId: makeLong(300),
567
+ eof: true,
568
+ },
569
+ })
570
+
571
+ const client = await new KakaoTalkClient().login({ oauthToken: 'token', userId: 'user1', deviceUuid: 'device1' })
572
+ const chats = await client.getChats({ all: true })
573
+
574
+ const paginatedChat = chats.find((chat) => chat.chat_id === '300')
575
+ expect(paginatedChat?.chat_id).toBe('300')
576
+ expect(paginatedChat?.display_name).toBe('Dave')
577
+ expect(paginatedChat?.last_message?.author_name).toBe('Dave')
578
+ expect(chats.some((chat) => chat.chat_id === '[object Object]')).toBe(false)
579
+
580
+ client.close()
581
+ })
582
+
543
583
  it('wraps errors as KakaoTalkError', async () => {
544
584
  mockLogin.mockRejectedValue(new Error('Connection refused'))
545
585
 
@@ -62,7 +62,7 @@ class MemberNameCache {
62
62
  const names = chat.k as string[] | undefined
63
63
  if (!Array.isArray(ids) || !Array.isArray(names)) continue
64
64
 
65
- const chatId = String(chat.c)
65
+ const chatId = longToString(chat.c)
66
66
  let map = this.byChatId.get(chatId)
67
67
  if (!map) {
68
68
  map = new Map()
@@ -176,7 +176,7 @@ function formatChat(chat: ChatData, title: string | null, nameCache: MemberNameC
176
176
  const memberNames = (chat.k ?? []) as string[]
177
177
  const lastLog = chat.l as Record<string, unknown> | null
178
178
  const displayName = memberNames.join(', ') || null
179
- const chatId = String(chat.c)
179
+ const chatId = longToString(chat.c)
180
180
 
181
181
  return {
182
182
  chat_id: chatId,
@@ -256,7 +256,7 @@ function findMaxLogId(logs: Array<Record<string, unknown>>, field: string): Long
256
256
 
257
257
  function collectChats(chatDatas: ChatData[], into: ChatData[], seen: Set<string>): void {
258
258
  for (const chat of chatDatas) {
259
- const id = String(chat.c)
259
+ const id = longToString(chat.c)
260
260
  if (!seen.has(id)) {
261
261
  seen.add(id)
262
262
  into.push(chat)
@@ -771,7 +771,9 @@ export class KakaoTalkClient {
771
771
  }
772
772
 
773
773
  const titles = options?.resolveTitles
774
- ? await Promise.all(results.map((chat) => this.fetchChatTitle(session, parseLong(String(chat.c)), chat)))
774
+ ? await Promise.all(
775
+ results.map((chat) => this.fetchChatTitle(session, parseLong(longToString(chat.c)), chat)),
776
+ )
775
777
  : null
776
778
 
777
779
  return results.map((chat, i) => formatChat(chat, titles ? titles[i] : null, this.nameCache))
File without changes