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,72 @@
1
+ import { deflateSync } from 'node:zlib'
2
+
3
+ import type { InstagramSessionState } from '../types'
4
+ import { ThriftCompactWriter } from './thrift'
5
+
6
+ const IG_APP_ID = 567067343352427n
7
+ const IG_VERSION = '312.1.0.34.111'
8
+ const CLIENT_CAPABILITIES = 183n
9
+ const PUBLISH_FORMAT = 1
10
+ const NETWORK_TYPE_WIFI = 1
11
+ const CLIENT_STACK = 3
12
+ const CLIENT_IDENTIFIER_MAX_LENGTH = 20
13
+ const SESSION_ID_MASK = 0xffffffffn
14
+
15
+ const CONNECT_SUBSCRIBE_TOPIC_IDS = [88, 135, 149, 150, 133, 146]
16
+
17
+ export function extractSessionId(session: InstagramSessionState): string {
18
+ const authorization = session.authorization ?? ''
19
+ const igTokenMatch = authorization.match(/^Bearer IGT:2:(.+)$/)
20
+ if (igTokenMatch) {
21
+ try {
22
+ const decoded = JSON.parse(Buffer.from(igTokenMatch[1]!, 'base64').toString('utf8')) as { sessionid?: string }
23
+ if (decoded.sessionid) return decoded.sessionid
24
+ } catch {
25
+ // Malformed/stale token: fall through to the cookie-based sessionid below.
26
+ }
27
+ }
28
+
29
+ const cookieMatch = session.cookies.match(/sessionid=([^;]+)/)
30
+ if (cookieMatch) return cookieMatch[1]!
31
+
32
+ throw new Error('No sessionid found in Instagram session (checked authorization token and cookies)')
33
+ }
34
+
35
+ export function buildConnectPayload(session: InstagramSessionState): Buffer {
36
+ const sessionId = extractSessionId(session)
37
+ const userId = BigInt(session.user_id ?? '0')
38
+ const deviceId = session.device.phone_id
39
+ const userAgent = `Instagram ${IG_VERSION} Android (${session.device.device_string})`
40
+
41
+ const thrift = new ThriftCompactWriter()
42
+ .binary(1, deviceId.substring(0, CLIENT_IDENTIFIER_MAX_LENGTH))
43
+ .structStart(4)
44
+ .i64(1, userId)
45
+ .binary(2, userAgent)
46
+ .i64(3, CLIENT_CAPABILITIES)
47
+ .i64(4, 0)
48
+ .i32(5, PUBLISH_FORMAT)
49
+ .bool(6, false)
50
+ .bool(7, true)
51
+ .binary(8, deviceId)
52
+ .bool(9, true)
53
+ .i32(10, NETWORK_TYPE_WIFI)
54
+ .i32(11, 0)
55
+ .i64(12, BigInt(Date.now()) & SESSION_ID_MASK)
56
+ .listOfI32(14, CONNECT_SUBSCRIBE_TOPIC_IDS)
57
+ .binary(15, 'cookie_auth')
58
+ .i64(16, IG_APP_ID)
59
+ .binary(20, '')
60
+ .byte(21, CLIENT_STACK)
61
+ .structEnd()
62
+ .binary(5, `sessionid=${sessionId}`)
63
+ .mapBinaryBinary(10, {
64
+ platform: 'android',
65
+ ig_mqtt_route: 'django',
66
+ pubsub_msg_type_blacklist: 'direct, typing_type',
67
+ auth_cache_enabled: '0',
68
+ })
69
+ .finish()
70
+
71
+ return deflateSync(thrift, { level: 9 })
72
+ }
@@ -0,0 +1,91 @@
1
+ import { describe, expect, it } from 'bun:test'
2
+
3
+ import { ThriftCompactWriter } from '@/platforms/instagram/mqtt/thrift'
4
+
5
+ describe('ThriftCompactWriter', () => {
6
+ it('encodes a true bool as a packed field header with a trailing stop byte', () => {
7
+ // field id 1 (delta 1) << 4 | TYPE_TRUE(0x01) = 0x11, then struct stop 0x00
8
+ const out = new ThriftCompactWriter().bool(1, true).finish()
9
+ expect([...out]).toEqual([0x11, 0x00])
10
+ })
11
+
12
+ it('encodes a false bool with the false type code', () => {
13
+ // field id 1 (delta 1) << 4 | TYPE_FALSE(0x02) = 0x12
14
+ const out = new ThriftCompactWriter().bool(1, false).finish()
15
+ expect([...out]).toEqual([0x12, 0x00])
16
+ })
17
+
18
+ it('encodes i32 using zigzag varint', () => {
19
+ // field 1 i32(0x05) = 0x15, zigzag(1) = 2
20
+ const out = new ThriftCompactWriter().i32(1, 1).finish()
21
+ expect([...out]).toEqual([0x15, 0x02, 0x00])
22
+ })
23
+
24
+ it('encodes negative i32 via zigzag', () => {
25
+ // zigzag(-1) = 1
26
+ const out = new ThriftCompactWriter().i32(1, -1).finish()
27
+ expect([...out]).toEqual([0x15, 0x01, 0x00])
28
+ })
29
+
30
+ it('encodes a large positive i32 as an unsigned <=5-byte varint', () => {
31
+ // 0x40000000: zigzag(1073741824) = 2147483648 = 0x80000000 -> varint 80 80 80 80 08
32
+ const out = new ThriftCompactWriter().i32(1, 0x40000000).finish()
33
+ expect([...out]).toEqual([0x15, 0x80, 0x80, 0x80, 0x80, 0x08, 0x00])
34
+ })
35
+
36
+ it('encodes INT32_MIN without overflowing into a 64-bit varint', () => {
37
+ // zigzag(-2147483648) = 4294967295 = 0xFFFFFFFF -> varint ff ff ff ff 0f (5 bytes, not 9)
38
+ const out = new ThriftCompactWriter().i32(1, -2147483648).finish()
39
+ expect([...out]).toEqual([0x15, 0xff, 0xff, 0xff, 0xff, 0x0f, 0x00])
40
+ })
41
+
42
+ it('encodes binary as length-prefixed utf8', () => {
43
+ // field 1 binary(0x08) = 0x18, length 2, "hi"
44
+ const out = new ThriftCompactWriter().binary(1, 'hi').finish()
45
+ expect([...out]).toEqual([0x18, 0x02, 0x68, 0x69, 0x00])
46
+ })
47
+
48
+ it('packs small field-id deltas across multiple fields', () => {
49
+ // field 1 binary = 0x18, then field 3 binary uses delta 2 = 0x28
50
+ const out = new ThriftCompactWriter().binary(1, 'a').binary(3, 'b').finish()
51
+ expect([...out]).toEqual([0x18, 0x01, 0x61, 0x28, 0x01, 0x62, 0x00])
52
+ })
53
+
54
+ it('falls back to explicit zigzag id when delta exceeds 15', () => {
55
+ // field 20 binary: delta 20 > 15, so type byte 0x08 then zigzag(20)=40=0x28
56
+ const out = new ThriftCompactWriter().binary(20, 'x').finish()
57
+ expect([...out]).toEqual([0x08, 0x28, 0x01, 0x78, 0x00])
58
+ })
59
+
60
+ it('encodes a short i32 list with size packed into the header nibble', () => {
61
+ // field 1 list = 0x19, list header size 2 << 4 | i32(0x05) = 0x25, zigzag(88)=176=0xb0 0x01, zigzag(146)=292=0xa4 0x02
62
+ const out = new ThriftCompactWriter().listOfI32(1, [88, 146]).finish()
63
+ expect([...out]).toEqual([0x19, 0x25, 0xb0, 0x01, 0xa4, 0x02, 0x00])
64
+ })
65
+
66
+ it('encodes an empty map as a single zero byte', () => {
67
+ // field 1 map = 0x1b, empty map size 0x00
68
+ const out = new ThriftCompactWriter().mapBinaryBinary(1, {}).finish()
69
+ expect([...out]).toEqual([0x1b, 0x00, 0x00])
70
+ })
71
+
72
+ it('encodes a binary->binary map with the key/value type byte', () => {
73
+ // field 1 map = 0x1b, size 1, types (binary<<4|binary)=0x88, key "k", value "v"
74
+ const out = new ThriftCompactWriter().mapBinaryBinary(1, { k: 'v' }).finish()
75
+ expect([...out]).toEqual([0x1b, 0x01, 0x88, 0x01, 0x6b, 0x01, 0x76, 0x00])
76
+ })
77
+
78
+ it('resets field-id delta tracking inside nested structs', () => {
79
+ // field 4 struct = 0x4c, inner field 1 binary = 0x18 "a", inner stop 0x00, outer stop 0x00
80
+ const out = new ThriftCompactWriter().structStart(4).binary(1, 'a').structEnd().finish()
81
+ expect([...out]).toEqual([0x4c, 0x18, 0x01, 0x61, 0x00, 0x00])
82
+ })
83
+
84
+ it('encodes i64 values larger than 32 bits', () => {
85
+ // field 16 i64: delta 16 > 15 -> type 0x06, zigzag id(16)=32=0x20, then zigzag64(567067343352427)
86
+ const out = new ThriftCompactWriter().i64(16, 567067343352427n).finish()
87
+ expect(out[0]).toBe(0x06)
88
+ expect(out[1]).toBe(0x20)
89
+ expect(out[out.length - 1]).toBe(0x00)
90
+ })
91
+ })
@@ -0,0 +1,159 @@
1
+ const TYPE_TRUE = 0x01
2
+ const TYPE_FALSE = 0x02
3
+ const TYPE_BYTE = 0x03
4
+ const TYPE_I16 = 0x04
5
+ const TYPE_I32 = 0x05
6
+ const TYPE_I64 = 0x06
7
+ const TYPE_BINARY = 0x08
8
+ const TYPE_LIST = 0x09
9
+ const TYPE_MAP = 0x0b
10
+ const TYPE_STRUCT = 0x0c
11
+
12
+ const VARINT_CONTINUE = 0x80
13
+ const VARINT_MASK = 0x7f
14
+ const U64_MASK = 0xffffffffffffffffn
15
+
16
+ export class ThriftCompactWriter {
17
+ private bytes: number[] = []
18
+ private parentFieldStack: number[] = []
19
+ private lastFieldId = 0
20
+
21
+ bool(id: number, value: boolean): this {
22
+ this.writeFieldHeader(value ? TYPE_TRUE : TYPE_FALSE, id)
23
+ return this
24
+ }
25
+
26
+ byte(id: number, value: number): this {
27
+ this.writeFieldHeader(TYPE_BYTE, id)
28
+ this.bytes.push(value & 0xff)
29
+ return this
30
+ }
31
+
32
+ i16(id: number, value: number): this {
33
+ this.writeFieldHeader(TYPE_I16, id)
34
+ this.writeVarint(zigzag32(value))
35
+ return this
36
+ }
37
+
38
+ i32(id: number, value: number): this {
39
+ this.writeFieldHeader(TYPE_I32, id)
40
+ this.writeVarint(zigzag32(value))
41
+ return this
42
+ }
43
+
44
+ i64(id: number, value: number | bigint): this {
45
+ this.writeFieldHeader(TYPE_I64, id)
46
+ this.writeVarint(zigzag64(BigInt(value)))
47
+ return this
48
+ }
49
+
50
+ binary(id: number, value: string | Buffer): this {
51
+ this.writeFieldHeader(TYPE_BINARY, id)
52
+ this.writeBinaryValue(value)
53
+ return this
54
+ }
55
+
56
+ listOfI32(id: number, values: number[]): this {
57
+ this.writeFieldHeader(TYPE_LIST, id)
58
+ this.writeListHeader(TYPE_I32, values.length)
59
+ for (const value of values) this.writeVarint(zigzag32(value))
60
+ return this
61
+ }
62
+
63
+ listOfBinary(id: number, values: string[]): this {
64
+ this.writeFieldHeader(TYPE_LIST, id)
65
+ this.writeListHeader(TYPE_BINARY, values.length)
66
+ for (const value of values) this.writeBinaryValue(value)
67
+ return this
68
+ }
69
+
70
+ mapBinaryBinary(id: number, entries: Record<string, string>): this {
71
+ this.writeFieldHeader(TYPE_MAP, id)
72
+ const keys = Object.keys(entries)
73
+
74
+ // Compact protocol encodes an empty map as a single zero byte (size only).
75
+ if (keys.length === 0) {
76
+ this.bytes.push(0)
77
+ return this
78
+ }
79
+
80
+ this.writeVarint(keys.length)
81
+ this.bytes.push((TYPE_BINARY << 4) | TYPE_BINARY)
82
+ for (const key of keys) {
83
+ this.writeBinaryValue(key)
84
+ this.writeBinaryValue(entries[key]!)
85
+ }
86
+ return this
87
+ }
88
+
89
+ structStart(id: number): this {
90
+ this.writeFieldHeader(TYPE_STRUCT, id)
91
+ this.parentFieldStack.push(this.lastFieldId)
92
+ this.lastFieldId = 0
93
+ return this
94
+ }
95
+
96
+ structEnd(): this {
97
+ this.bytes.push(0)
98
+ this.lastFieldId = this.parentFieldStack.pop() ?? 0
99
+ return this
100
+ }
101
+
102
+ finish(): Buffer {
103
+ this.bytes.push(0)
104
+ return Buffer.from(this.bytes)
105
+ }
106
+
107
+ // Compact protocol packs a small field-id delta and the type into one byte
108
+ // (delta << 4 | type); deltas outside 1..15 fall back to a zigzag varint id.
109
+ private writeFieldHeader(type: number, id: number): void {
110
+ const delta = id - this.lastFieldId
111
+ if (delta > 0 && delta <= 15) {
112
+ this.bytes.push((delta << 4) | type)
113
+ } else {
114
+ this.bytes.push(type)
115
+ this.writeVarint(zigzag32(id))
116
+ }
117
+ this.lastFieldId = id
118
+ }
119
+
120
+ // Lists with fewer than 15 elements pack size into the header nibble;
121
+ // larger lists set the nibble to 0xf and append the size as a varint.
122
+ private writeListHeader(elementType: number, size: number): void {
123
+ if (size < 15) {
124
+ this.bytes.push((size << 4) | elementType)
125
+ } else {
126
+ this.bytes.push(0xf0 | elementType)
127
+ this.writeVarint(size)
128
+ }
129
+ }
130
+
131
+ private writeBinaryValue(value: string | Buffer): void {
132
+ const buf = Buffer.isBuffer(value) ? value : Buffer.from(value, 'utf8')
133
+ this.writeVarint(buf.length)
134
+ for (const b of buf) this.bytes.push(b)
135
+ }
136
+
137
+ private writeVarint(value: number | bigint): void {
138
+ let remaining = BigInt(value) & U64_MASK
139
+ while (true) {
140
+ const chunk = Number(remaining & BigInt(VARINT_MASK))
141
+ remaining >>= 7n
142
+ if (remaining === 0n) {
143
+ this.bytes.push(chunk)
144
+ return
145
+ }
146
+ this.bytes.push(chunk | VARINT_CONTINUE)
147
+ }
148
+ }
149
+ }
150
+
151
+ function zigzag32(value: number): number {
152
+ // `>>> 0` coerces the signed int32 bitwise result to an unsigned 32-bit value,
153
+ // so large positives and INT32_MIN encode as a <=5-byte varint, not a 64-bit one.
154
+ return ((value << 1) ^ (value >> 31)) >>> 0
155
+ }
156
+
157
+ function zigzag64(value: bigint): bigint {
158
+ return BigInt.asUintN(64, value << 1n) ^ (value >> 63n)
159
+ }
@@ -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
+ })