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.
- package/.claude-plugin/README.md +11 -1
- package/.claude-plugin/marketplace.json +14 -1
- package/.claude-plugin/plugin.json +4 -2
- package/AGENTS.md +4 -0
- package/README.md +40 -9
- package/dist/package.json +10 -2
- package/dist/src/cli.d.ts.map +1 -1
- package/dist/src/cli.js +3 -0
- package/dist/src/cli.js.map +1 -1
- package/dist/src/platforms/discordbot/cli.js +1 -1
- package/dist/src/platforms/imessage/cli.d.ts +5 -0
- package/dist/src/platforms/imessage/cli.d.ts.map +1 -0
- package/dist/src/platforms/imessage/cli.js +31 -0
- package/dist/src/platforms/imessage/cli.js.map +1 -0
- package/dist/src/platforms/imessage/client.d.ts +43 -0
- package/dist/src/platforms/imessage/client.d.ts.map +1 -0
- package/dist/src/platforms/imessage/client.js +190 -0
- package/dist/src/platforms/imessage/client.js.map +1 -0
- package/dist/src/platforms/imessage/commands/auth.d.ts +31 -0
- package/dist/src/platforms/imessage/commands/auth.d.ts.map +1 -0
- package/dist/src/platforms/imessage/commands/auth.js +100 -0
- package/dist/src/platforms/imessage/commands/auth.js.map +1 -0
- package/dist/src/platforms/imessage/commands/chat.d.ts +3 -0
- package/dist/src/platforms/imessage/commands/chat.d.ts.map +1 -0
- package/dist/src/platforms/imessage/commands/chat.js +42 -0
- package/dist/src/platforms/imessage/commands/chat.js.map +1 -0
- package/dist/src/platforms/imessage/commands/doctor.d.ts +25 -0
- package/dist/src/platforms/imessage/commands/doctor.d.ts.map +1 -0
- package/dist/src/platforms/imessage/commands/doctor.js +101 -0
- package/dist/src/platforms/imessage/commands/doctor.js.map +1 -0
- package/dist/src/platforms/imessage/commands/index.d.ts +7 -0
- package/dist/src/platforms/imessage/commands/index.d.ts.map +1 -0
- package/dist/src/platforms/imessage/commands/index.js +7 -0
- package/dist/src/platforms/imessage/commands/index.js.map +1 -0
- package/dist/src/platforms/imessage/commands/message.d.ts +5 -0
- package/dist/src/platforms/imessage/commands/message.d.ts.map +1 -0
- package/dist/src/platforms/imessage/commands/message.js +127 -0
- package/dist/src/platforms/imessage/commands/message.js.map +1 -0
- package/dist/src/platforms/imessage/commands/setup.d.ts +20 -0
- package/dist/src/platforms/imessage/commands/setup.d.ts.map +1 -0
- package/dist/src/platforms/imessage/commands/setup.js +57 -0
- package/dist/src/platforms/imessage/commands/setup.js.map +1 -0
- package/dist/src/platforms/imessage/commands/shared.d.ts +10 -0
- package/dist/src/platforms/imessage/commands/shared.d.ts.map +1 -0
- package/dist/src/platforms/imessage/commands/shared.js +53 -0
- package/dist/src/platforms/imessage/commands/shared.js.map +1 -0
- package/dist/src/platforms/imessage/commands/whoami.d.ts +3 -0
- package/dist/src/platforms/imessage/commands/whoami.d.ts.map +1 -0
- package/dist/src/platforms/imessage/commands/whoami.js +20 -0
- package/dist/src/platforms/imessage/commands/whoami.js.map +1 -0
- package/dist/src/platforms/imessage/credential-manager.d.ts +22 -0
- package/dist/src/platforms/imessage/credential-manager.d.ts.map +1 -0
- package/dist/src/platforms/imessage/credential-manager.js +111 -0
- package/dist/src/platforms/imessage/credential-manager.js.map +1 -0
- package/dist/src/platforms/imessage/ensure-auth.d.ts +2 -0
- package/dist/src/platforms/imessage/ensure-auth.d.ts.map +1 -0
- package/dist/src/platforms/imessage/ensure-auth.js +13 -0
- package/dist/src/platforms/imessage/ensure-auth.js.map +1 -0
- package/dist/src/platforms/imessage/errors.d.ts +3 -0
- package/dist/src/platforms/imessage/errors.d.ts.map +1 -0
- package/dist/src/platforms/imessage/errors.js +25 -0
- package/dist/src/platforms/imessage/errors.js.map +1 -0
- package/dist/src/platforms/imessage/index.d.ts +7 -0
- package/dist/src/platforms/imessage/index.d.ts.map +1 -0
- package/dist/src/platforms/imessage/index.js +5 -0
- package/dist/src/platforms/imessage/index.js.map +1 -0
- package/dist/src/platforms/imessage/rpc.d.ts +20 -0
- package/dist/src/platforms/imessage/rpc.d.ts.map +1 -0
- package/dist/src/platforms/imessage/rpc.js +133 -0
- package/dist/src/platforms/imessage/rpc.js.map +1 -0
- package/dist/src/platforms/imessage/types.d.ts +59 -0
- package/dist/src/platforms/imessage/types.d.ts.map +1 -0
- package/dist/src/platforms/imessage/types.js +29 -0
- package/dist/src/platforms/imessage/types.js.map +1 -0
- package/dist/src/platforms/instagram/cli.js +1 -1
- package/dist/src/platforms/instagram/client.d.ts +6 -1
- package/dist/src/platforms/instagram/client.d.ts.map +1 -1
- package/dist/src/platforms/instagram/client.js +17 -0
- package/dist/src/platforms/instagram/client.js.map +1 -1
- package/dist/src/platforms/instagram/hybrid-listener.d.ts +48 -0
- package/dist/src/platforms/instagram/hybrid-listener.d.ts.map +1 -0
- package/dist/src/platforms/instagram/hybrid-listener.js +154 -0
- package/dist/src/platforms/instagram/hybrid-listener.js.map +1 -0
- package/dist/src/platforms/instagram/index.d.ts +2 -0
- package/dist/src/platforms/instagram/index.d.ts.map +1 -1
- package/dist/src/platforms/instagram/index.js +2 -0
- package/dist/src/platforms/instagram/index.js.map +1 -1
- package/dist/src/platforms/instagram/mqtt/connection.d.ts +4 -0
- package/dist/src/platforms/instagram/mqtt/connection.d.ts.map +1 -0
- package/dist/src/platforms/instagram/mqtt/connection.js +66 -0
- package/dist/src/platforms/instagram/mqtt/connection.js.map +1 -0
- package/dist/src/platforms/instagram/mqtt/thrift.d.ts +22 -0
- package/dist/src/platforms/instagram/mqtt/thrift.d.ts.map +1 -0
- package/dist/src/platforms/instagram/mqtt/thrift.js +143 -0
- package/dist/src/platforms/instagram/mqtt/thrift.js.map +1 -0
- package/dist/src/platforms/instagram/mqtt/transport.d.ts +42 -0
- package/dist/src/platforms/instagram/mqtt/transport.d.ts.map +1 -0
- package/dist/src/platforms/instagram/mqtt/transport.js +246 -0
- package/dist/src/platforms/instagram/mqtt/transport.js.map +1 -0
- package/dist/src/platforms/instagram/realtime-listener.d.ts +33 -0
- package/dist/src/platforms/instagram/realtime-listener.d.ts.map +1 -0
- package/dist/src/platforms/instagram/realtime-listener.js +151 -0
- package/dist/src/platforms/instagram/realtime-listener.js.map +1 -0
- package/dist/src/platforms/kakaotalk/cli.js +1 -1
- package/dist/src/platforms/kakaotalk/client.d.ts.map +1 -1
- package/dist/src/platforms/kakaotalk/client.js +4 -4
- package/dist/src/platforms/kakaotalk/client.js.map +1 -1
- package/dist/src/platforms/line/cli.js +1 -1
- package/dist/src/platforms/webex/cli.js +1 -1
- package/dist/src/platforms/webexbot/cli.js +1 -1
- package/dist/src/platforms/wechatbot/cli.js +1 -1
- package/dist/src/platforms/whatsapp/cli.js +1 -1
- package/dist/src/platforms/whatsappbot/cli.js +1 -1
- package/dist/src/tui/adapters/imessage-adapter.d.ts +21 -0
- package/dist/src/tui/adapters/imessage-adapter.d.ts.map +1 -0
- package/dist/src/tui/adapters/imessage-adapter.js +110 -0
- package/dist/src/tui/adapters/imessage-adapter.js.map +1 -0
- package/dist/src/tui/app.d.ts.map +1 -1
- package/dist/src/tui/app.js +9 -0
- package/dist/src/tui/app.js.map +1 -1
- package/docs/content/docs/agent-skills.mdx +1 -1
- package/docs/content/docs/cli/imessage.mdx +99 -0
- package/docs/content/docs/cli/instagram.mdx +1 -1
- package/docs/content/docs/cli/meta.json +1 -0
- package/docs/content/docs/index.mdx +9 -6
- package/docs/content/docs/quick-start.mdx +2 -0
- package/docs/content/docs/sdk/instagram.mdx +90 -5
- package/docs/content/docs/tui.mdx +4 -3
- package/package.json +10 -2
- package/scripts/postbuild.ts +12 -15
- package/skills/agent-channeltalk/SKILL.md +1 -1
- package/skills/agent-channeltalkbot/SKILL.md +1 -1
- package/skills/agent-discord/SKILL.md +1 -1
- package/skills/agent-discordbot/SKILL.md +1 -1
- package/skills/agent-imessage/SKILL.md +133 -0
- package/skills/agent-imessage/references/authentication.md +66 -0
- package/skills/agent-imessage/references/permissions.md +31 -0
- package/skills/agent-imessage/references/setup.md +49 -0
- package/skills/agent-instagram/SKILL.md +72 -2
- package/skills/agent-instagram/references/common-patterns.md +36 -0
- package/skills/agent-instagram/templates/monitor-chat.sh +5 -0
- package/skills/agent-kakaotalk/SKILL.md +1 -1
- package/skills/agent-line/SKILL.md +1 -1
- package/skills/agent-slack/SKILL.md +1 -1
- package/skills/agent-slackbot/SKILL.md +1 -1
- package/skills/agent-teams/SKILL.md +1 -1
- package/skills/agent-telegram/SKILL.md +1 -1
- package/skills/agent-telegrambot/SKILL.md +1 -1
- package/skills/agent-webex/SKILL.md +1 -1
- package/skills/agent-webexbot/SKILL.md +1 -1
- package/skills/agent-wechatbot/SKILL.md +1 -1
- package/skills/agent-whatsapp/SKILL.md +1 -1
- package/skills/agent-whatsappbot/SKILL.md +1 -1
- package/src/cli.ts +4 -0
- package/src/platforms/imessage/cli.ts +39 -0
- package/src/platforms/imessage/client.test.ts +137 -0
- package/src/platforms/imessage/client.ts +253 -0
- package/src/platforms/imessage/commands/auth.ts +142 -0
- package/src/platforms/imessage/commands/chat.ts +51 -0
- package/src/platforms/imessage/commands/doctor.test.ts +80 -0
- package/src/platforms/imessage/commands/doctor.ts +139 -0
- package/src/platforms/imessage/commands/index.ts +6 -0
- package/src/platforms/imessage/commands/message.test.ts +20 -0
- package/src/platforms/imessage/commands/message.ts +151 -0
- package/src/platforms/imessage/commands/setup.test.ts +52 -0
- package/src/platforms/imessage/commands/setup.ts +76 -0
- package/src/platforms/imessage/commands/shared.test.ts +19 -0
- package/src/platforms/imessage/commands/shared.ts +69 -0
- package/src/platforms/imessage/commands/whoami.ts +22 -0
- package/src/platforms/imessage/credential-manager.test.ts +122 -0
- package/src/platforms/imessage/credential-manager.ts +123 -0
- package/src/platforms/imessage/ensure-auth.ts +18 -0
- package/src/platforms/imessage/errors.ts +27 -0
- package/src/platforms/imessage/index.test.ts +13 -0
- package/src/platforms/imessage/index.ts +16 -0
- package/src/platforms/imessage/rpc.ts +157 -0
- package/src/platforms/imessage/test-stub-imsg.mjs +147 -0
- package/src/platforms/imessage/types.test.ts +47 -0
- package/src/platforms/imessage/types.ts +95 -0
- package/src/platforms/instagram/client.ts +23 -0
- package/src/platforms/instagram/hybrid-listener.test.ts +135 -0
- package/src/platforms/instagram/hybrid-listener.ts +196 -0
- package/src/platforms/instagram/index.ts +10 -0
- package/src/platforms/instagram/mqtt/connection.ts +72 -0
- package/src/platforms/instagram/mqtt/thrift.test.ts +91 -0
- package/src/platforms/instagram/mqtt/thrift.ts +159 -0
- package/src/platforms/instagram/mqtt/transport.ts +285 -0
- package/src/platforms/instagram/realtime-listener.test.ts +34 -0
- package/src/platforms/instagram/realtime-listener.ts +201 -0
- package/src/platforms/kakaotalk/client.test.ts +40 -0
- package/src/platforms/kakaotalk/client.ts +6 -4
- package/src/platforms/webexbot/cli.ts +0 -0
- package/src/tui/adapters/imessage-adapter.ts +131 -0
- 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 =
|
|
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 =
|
|
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 =
|
|
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(
|
|
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
|