agent-messenger 2.27.2 → 2.29.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 (316) 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 +58 -10
  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/discord/client.d.ts +6 -2
  11. package/dist/src/platforms/discord/client.d.ts.map +1 -1
  12. package/dist/src/platforms/discord/client.js +10 -3
  13. package/dist/src/platforms/discord/client.js.map +1 -1
  14. package/dist/src/platforms/discordbot/cli.js +1 -1
  15. package/dist/src/platforms/imessage/cli.d.ts +5 -0
  16. package/dist/src/platforms/imessage/cli.d.ts.map +1 -0
  17. package/dist/src/platforms/imessage/cli.js +31 -0
  18. package/dist/src/platforms/imessage/cli.js.map +1 -0
  19. package/dist/src/platforms/imessage/client.d.ts +43 -0
  20. package/dist/src/platforms/imessage/client.d.ts.map +1 -0
  21. package/dist/src/platforms/imessage/client.js +190 -0
  22. package/dist/src/platforms/imessage/client.js.map +1 -0
  23. package/dist/src/platforms/imessage/commands/auth.d.ts +31 -0
  24. package/dist/src/platforms/imessage/commands/auth.d.ts.map +1 -0
  25. package/dist/src/platforms/imessage/commands/auth.js +100 -0
  26. package/dist/src/platforms/imessage/commands/auth.js.map +1 -0
  27. package/dist/src/platforms/imessage/commands/chat.d.ts +3 -0
  28. package/dist/src/platforms/imessage/commands/chat.d.ts.map +1 -0
  29. package/dist/src/platforms/imessage/commands/chat.js +42 -0
  30. package/dist/src/platforms/imessage/commands/chat.js.map +1 -0
  31. package/dist/src/platforms/imessage/commands/doctor.d.ts +25 -0
  32. package/dist/src/platforms/imessage/commands/doctor.d.ts.map +1 -0
  33. package/dist/src/platforms/imessage/commands/doctor.js +101 -0
  34. package/dist/src/platforms/imessage/commands/doctor.js.map +1 -0
  35. package/dist/src/platforms/imessage/commands/index.d.ts +7 -0
  36. package/dist/src/platforms/imessage/commands/index.d.ts.map +1 -0
  37. package/dist/src/platforms/imessage/commands/index.js +7 -0
  38. package/dist/src/platforms/imessage/commands/index.js.map +1 -0
  39. package/dist/src/platforms/imessage/commands/message.d.ts +5 -0
  40. package/dist/src/platforms/imessage/commands/message.d.ts.map +1 -0
  41. package/dist/src/platforms/imessage/commands/message.js +127 -0
  42. package/dist/src/platforms/imessage/commands/message.js.map +1 -0
  43. package/dist/src/platforms/imessage/commands/setup.d.ts +20 -0
  44. package/dist/src/platforms/imessage/commands/setup.d.ts.map +1 -0
  45. package/dist/src/platforms/imessage/commands/setup.js +57 -0
  46. package/dist/src/platforms/imessage/commands/setup.js.map +1 -0
  47. package/dist/src/platforms/imessage/commands/shared.d.ts +10 -0
  48. package/dist/src/platforms/imessage/commands/shared.d.ts.map +1 -0
  49. package/dist/src/platforms/imessage/commands/shared.js +53 -0
  50. package/dist/src/platforms/imessage/commands/shared.js.map +1 -0
  51. package/dist/src/platforms/imessage/commands/whoami.d.ts +3 -0
  52. package/dist/src/platforms/imessage/commands/whoami.d.ts.map +1 -0
  53. package/dist/src/platforms/imessage/commands/whoami.js +20 -0
  54. package/dist/src/platforms/imessage/commands/whoami.js.map +1 -0
  55. package/dist/src/platforms/imessage/credential-manager.d.ts +22 -0
  56. package/dist/src/platforms/imessage/credential-manager.d.ts.map +1 -0
  57. package/dist/src/platforms/imessage/credential-manager.js +111 -0
  58. package/dist/src/platforms/imessage/credential-manager.js.map +1 -0
  59. package/dist/src/platforms/imessage/ensure-auth.d.ts +2 -0
  60. package/dist/src/platforms/imessage/ensure-auth.d.ts.map +1 -0
  61. package/dist/src/platforms/imessage/ensure-auth.js +13 -0
  62. package/dist/src/platforms/imessage/ensure-auth.js.map +1 -0
  63. package/dist/src/platforms/imessage/errors.d.ts +3 -0
  64. package/dist/src/platforms/imessage/errors.d.ts.map +1 -0
  65. package/dist/src/platforms/imessage/errors.js +25 -0
  66. package/dist/src/platforms/imessage/errors.js.map +1 -0
  67. package/dist/src/platforms/imessage/index.d.ts +7 -0
  68. package/dist/src/platforms/imessage/index.d.ts.map +1 -0
  69. package/dist/src/platforms/imessage/index.js +5 -0
  70. package/dist/src/platforms/imessage/index.js.map +1 -0
  71. package/dist/src/platforms/imessage/rpc.d.ts +20 -0
  72. package/dist/src/platforms/imessage/rpc.d.ts.map +1 -0
  73. package/dist/src/platforms/imessage/rpc.js +133 -0
  74. package/dist/src/platforms/imessage/rpc.js.map +1 -0
  75. package/dist/src/platforms/imessage/types.d.ts +59 -0
  76. package/dist/src/platforms/imessage/types.d.ts.map +1 -0
  77. package/dist/src/platforms/imessage/types.js +29 -0
  78. package/dist/src/platforms/imessage/types.js.map +1 -0
  79. package/dist/src/platforms/instagram/cli.js +1 -1
  80. package/dist/src/platforms/instagram/client.d.ts +29 -2
  81. package/dist/src/platforms/instagram/client.d.ts.map +1 -1
  82. package/dist/src/platforms/instagram/client.js +360 -53
  83. package/dist/src/platforms/instagram/client.js.map +1 -1
  84. package/dist/src/platforms/instagram/commands/auth.d.ts +1 -0
  85. package/dist/src/platforms/instagram/commands/auth.d.ts.map +1 -1
  86. package/dist/src/platforms/instagram/commands/auth.js +123 -11
  87. package/dist/src/platforms/instagram/commands/auth.js.map +1 -1
  88. package/dist/src/platforms/instagram/credential-manager.d.ts +4 -1
  89. package/dist/src/platforms/instagram/credential-manager.d.ts.map +1 -1
  90. package/dist/src/platforms/instagram/credential-manager.js +17 -1
  91. package/dist/src/platforms/instagram/credential-manager.js.map +1 -1
  92. package/dist/src/platforms/instagram/hybrid-listener.d.ts +48 -0
  93. package/dist/src/platforms/instagram/hybrid-listener.d.ts.map +1 -0
  94. package/dist/src/platforms/instagram/hybrid-listener.js +154 -0
  95. package/dist/src/platforms/instagram/hybrid-listener.js.map +1 -0
  96. package/dist/src/platforms/instagram/index.d.ts +2 -0
  97. package/dist/src/platforms/instagram/index.d.ts.map +1 -1
  98. package/dist/src/platforms/instagram/index.js +2 -0
  99. package/dist/src/platforms/instagram/index.js.map +1 -1
  100. package/dist/src/platforms/instagram/mqtt/connection.d.ts +4 -0
  101. package/dist/src/platforms/instagram/mqtt/connection.d.ts.map +1 -0
  102. package/dist/src/platforms/instagram/mqtt/connection.js +66 -0
  103. package/dist/src/platforms/instagram/mqtt/connection.js.map +1 -0
  104. package/dist/src/platforms/instagram/mqtt/thrift.d.ts +22 -0
  105. package/dist/src/platforms/instagram/mqtt/thrift.d.ts.map +1 -0
  106. package/dist/src/platforms/instagram/mqtt/thrift.js +143 -0
  107. package/dist/src/platforms/instagram/mqtt/thrift.js.map +1 -0
  108. package/dist/src/platforms/instagram/mqtt/transport.d.ts +42 -0
  109. package/dist/src/platforms/instagram/mqtt/transport.d.ts.map +1 -0
  110. package/dist/src/platforms/instagram/mqtt/transport.js +246 -0
  111. package/dist/src/platforms/instagram/mqtt/transport.js.map +1 -0
  112. package/dist/src/platforms/instagram/realtime-listener.d.ts +33 -0
  113. package/dist/src/platforms/instagram/realtime-listener.d.ts.map +1 -0
  114. package/dist/src/platforms/instagram/realtime-listener.js +151 -0
  115. package/dist/src/platforms/instagram/realtime-listener.js.map +1 -0
  116. package/dist/src/platforms/instagram/types.d.ts +10 -8
  117. package/dist/src/platforms/instagram/types.d.ts.map +1 -1
  118. package/dist/src/platforms/instagram/types.js.map +1 -1
  119. package/dist/src/platforms/kakaotalk/cli.js +1 -1
  120. package/dist/src/platforms/line/cli.js +1 -1
  121. package/dist/src/platforms/teams/app-config.d.ts +36 -0
  122. package/dist/src/platforms/teams/app-config.d.ts.map +1 -0
  123. package/dist/src/platforms/teams/app-config.js +69 -0
  124. package/dist/src/platforms/teams/app-config.js.map +1 -0
  125. package/dist/src/platforms/teams/client.d.ts +19 -2
  126. package/dist/src/platforms/teams/client.d.ts.map +1 -1
  127. package/dist/src/platforms/teams/client.js +295 -7
  128. package/dist/src/platforms/teams/client.js.map +1 -1
  129. package/dist/src/platforms/teams/commands/auth.d.ts +10 -0
  130. package/dist/src/platforms/teams/commands/auth.d.ts.map +1 -1
  131. package/dist/src/platforms/teams/commands/auth.js +137 -3
  132. package/dist/src/platforms/teams/commands/auth.js.map +1 -1
  133. package/dist/src/platforms/teams/commands/file.d.ts +3 -0
  134. package/dist/src/platforms/teams/commands/file.d.ts.map +1 -1
  135. package/dist/src/platforms/teams/commands/file.js +59 -2
  136. package/dist/src/platforms/teams/commands/file.js.map +1 -1
  137. package/dist/src/platforms/teams/commands/message.d.ts +11 -0
  138. package/dist/src/platforms/teams/commands/message.d.ts.map +1 -1
  139. package/dist/src/platforms/teams/commands/message.js +121 -2
  140. package/dist/src/platforms/teams/commands/message.js.map +1 -1
  141. package/dist/src/platforms/teams/credential-manager.d.ts +23 -1
  142. package/dist/src/platforms/teams/credential-manager.d.ts.map +1 -1
  143. package/dist/src/platforms/teams/credential-manager.js +46 -2
  144. package/dist/src/platforms/teams/credential-manager.js.map +1 -1
  145. package/dist/src/platforms/teams/device-code.d.ts +48 -0
  146. package/dist/src/platforms/teams/device-code.d.ts.map +1 -0
  147. package/dist/src/platforms/teams/device-code.js +199 -0
  148. package/dist/src/platforms/teams/device-code.js.map +1 -0
  149. package/dist/src/platforms/teams/device-login.d.ts +37 -0
  150. package/dist/src/platforms/teams/device-login.d.ts.map +1 -0
  151. package/dist/src/platforms/teams/device-login.js +129 -0
  152. package/dist/src/platforms/teams/device-login.js.map +1 -0
  153. package/dist/src/platforms/teams/ensure-auth.d.ts.map +1 -1
  154. package/dist/src/platforms/teams/ensure-auth.js +12 -0
  155. package/dist/src/platforms/teams/ensure-auth.js.map +1 -1
  156. package/dist/src/platforms/teams/index.d.ts +7 -3
  157. package/dist/src/platforms/teams/index.d.ts.map +1 -1
  158. package/dist/src/platforms/teams/index.js +5 -2
  159. package/dist/src/platforms/teams/index.js.map +1 -1
  160. package/dist/src/platforms/teams/listener.d.ts +35 -0
  161. package/dist/src/platforms/teams/listener.d.ts.map +1 -0
  162. package/dist/src/platforms/teams/listener.js +236 -0
  163. package/dist/src/platforms/teams/listener.js.map +1 -0
  164. package/dist/src/platforms/teams/realm-discovery.d.ts +3 -0
  165. package/dist/src/platforms/teams/realm-discovery.d.ts.map +1 -0
  166. package/dist/src/platforms/teams/realm-discovery.js +33 -0
  167. package/dist/src/platforms/teams/realm-discovery.js.map +1 -0
  168. package/dist/src/platforms/teams/token-extractor.d.ts +3 -0
  169. package/dist/src/platforms/teams/token-extractor.d.ts.map +1 -1
  170. package/dist/src/platforms/teams/token-extractor.js +60 -0
  171. package/dist/src/platforms/teams/token-extractor.js.map +1 -1
  172. package/dist/src/platforms/teams/token-provider.d.ts +25 -0
  173. package/dist/src/platforms/teams/token-provider.d.ts.map +1 -0
  174. package/dist/src/platforms/teams/token-provider.js +190 -0
  175. package/dist/src/platforms/teams/token-provider.js.map +1 -0
  176. package/dist/src/platforms/teams/trouter.d.ts +31 -0
  177. package/dist/src/platforms/teams/trouter.d.ts.map +1 -0
  178. package/dist/src/platforms/teams/trouter.js +181 -0
  179. package/dist/src/platforms/teams/trouter.js.map +1 -0
  180. package/dist/src/platforms/teams/types.d.ts +94 -0
  181. package/dist/src/platforms/teams/types.d.ts.map +1 -1
  182. package/dist/src/platforms/teams/types.js +30 -0
  183. package/dist/src/platforms/teams/types.js.map +1 -1
  184. package/dist/src/platforms/webex/cli.js +1 -1
  185. package/dist/src/platforms/webex/types.d.ts +1 -1
  186. package/dist/src/platforms/webexbot/cli.js +1 -1
  187. package/dist/src/platforms/wechatbot/cli.js +1 -1
  188. package/dist/src/platforms/whatsapp/cli.js +1 -1
  189. package/dist/src/platforms/whatsappbot/cli.js +1 -1
  190. package/dist/src/tui/adapters/imessage-adapter.d.ts +21 -0
  191. package/dist/src/tui/adapters/imessage-adapter.d.ts.map +1 -0
  192. package/dist/src/tui/adapters/imessage-adapter.js +110 -0
  193. package/dist/src/tui/adapters/imessage-adapter.js.map +1 -0
  194. package/dist/src/tui/adapters/instagram-adapter.d.ts.map +1 -1
  195. package/dist/src/tui/adapters/instagram-adapter.js +16 -0
  196. package/dist/src/tui/adapters/instagram-adapter.js.map +1 -1
  197. package/dist/src/tui/adapters/teams-adapter.d.ts +3 -0
  198. package/dist/src/tui/adapters/teams-adapter.d.ts.map +1 -1
  199. package/dist/src/tui/adapters/teams-adapter.js +21 -0
  200. package/dist/src/tui/adapters/teams-adapter.js.map +1 -1
  201. package/dist/src/tui/app.d.ts.map +1 -1
  202. package/dist/src/tui/app.js +12 -3
  203. package/dist/src/tui/app.js.map +1 -1
  204. package/docs/content/docs/agent-skills.mdx +1 -1
  205. package/docs/content/docs/cli/imessage.mdx +99 -0
  206. package/docs/content/docs/cli/instagram.mdx +2 -1
  207. package/docs/content/docs/cli/meta.json +1 -0
  208. package/docs/content/docs/cli/teams.mdx +22 -0
  209. package/docs/content/docs/index.mdx +9 -6
  210. package/docs/content/docs/quick-start.mdx +2 -0
  211. package/docs/content/docs/sdk/instagram.mdx +90 -5
  212. package/docs/content/docs/tui.mdx +4 -3
  213. package/package.json +10 -2
  214. package/scripts/postbuild.ts +12 -15
  215. package/skills/agent-channeltalk/SKILL.md +1 -1
  216. package/skills/agent-channeltalkbot/SKILL.md +1 -1
  217. package/skills/agent-discord/SKILL.md +1 -1
  218. package/skills/agent-discordbot/SKILL.md +1 -1
  219. package/skills/agent-imessage/SKILL.md +133 -0
  220. package/skills/agent-imessage/references/authentication.md +66 -0
  221. package/skills/agent-imessage/references/permissions.md +31 -0
  222. package/skills/agent-imessage/references/setup.md +49 -0
  223. package/skills/agent-instagram/SKILL.md +72 -2
  224. package/skills/agent-instagram/references/authentication.md +11 -0
  225. package/skills/agent-instagram/references/common-patterns.md +36 -0
  226. package/skills/agent-instagram/templates/monitor-chat.sh +5 -0
  227. package/skills/agent-kakaotalk/SKILL.md +1 -1
  228. package/skills/agent-line/SKILL.md +1 -1
  229. package/skills/agent-slack/SKILL.md +1 -1
  230. package/skills/agent-slackbot/SKILL.md +1 -1
  231. package/skills/agent-teams/SKILL.md +59 -6
  232. package/skills/agent-teams/references/authentication.md +31 -1
  233. package/skills/agent-telegram/SKILL.md +1 -1
  234. package/skills/agent-telegrambot/SKILL.md +1 -1
  235. package/skills/agent-webex/SKILL.md +1 -1
  236. package/skills/agent-webexbot/SKILL.md +1 -1
  237. package/skills/agent-wechatbot/SKILL.md +1 -1
  238. package/skills/agent-whatsapp/SKILL.md +1 -1
  239. package/skills/agent-whatsappbot/SKILL.md +1 -1
  240. package/src/cli.ts +4 -0
  241. package/src/platforms/discord/client.test.ts +86 -0
  242. package/src/platforms/discord/client.ts +10 -3
  243. package/src/platforms/imessage/cli.ts +39 -0
  244. package/src/platforms/imessage/client.test.ts +137 -0
  245. package/src/platforms/imessage/client.ts +253 -0
  246. package/src/platforms/imessage/commands/auth.ts +142 -0
  247. package/src/platforms/imessage/commands/chat.ts +51 -0
  248. package/src/platforms/imessage/commands/doctor.test.ts +80 -0
  249. package/src/platforms/imessage/commands/doctor.ts +139 -0
  250. package/src/platforms/imessage/commands/index.ts +6 -0
  251. package/src/platforms/imessage/commands/message.test.ts +20 -0
  252. package/src/platforms/imessage/commands/message.ts +151 -0
  253. package/src/platforms/imessage/commands/setup.test.ts +52 -0
  254. package/src/platforms/imessage/commands/setup.ts +76 -0
  255. package/src/platforms/imessage/commands/shared.test.ts +19 -0
  256. package/src/platforms/imessage/commands/shared.ts +69 -0
  257. package/src/platforms/imessage/commands/whoami.ts +22 -0
  258. package/src/platforms/imessage/credential-manager.test.ts +122 -0
  259. package/src/platforms/imessage/credential-manager.ts +123 -0
  260. package/src/platforms/imessage/ensure-auth.ts +18 -0
  261. package/src/platforms/imessage/errors.ts +27 -0
  262. package/src/platforms/imessage/index.test.ts +13 -0
  263. package/src/platforms/imessage/index.ts +16 -0
  264. package/src/platforms/imessage/rpc.ts +157 -0
  265. package/src/platforms/imessage/test-stub-imsg.mjs +147 -0
  266. package/src/platforms/imessage/types.test.ts +47 -0
  267. package/src/platforms/imessage/types.ts +95 -0
  268. package/src/platforms/instagram/client.test.ts +478 -33
  269. package/src/platforms/instagram/client.ts +472 -68
  270. package/src/platforms/instagram/commands/auth.test.ts +177 -1
  271. package/src/platforms/instagram/commands/auth.ts +182 -11
  272. package/src/platforms/instagram/credential-manager.test.ts +30 -0
  273. package/src/platforms/instagram/credential-manager.ts +23 -1
  274. package/src/platforms/instagram/hybrid-listener.test.ts +135 -0
  275. package/src/platforms/instagram/hybrid-listener.ts +196 -0
  276. package/src/platforms/instagram/index.ts +10 -0
  277. package/src/platforms/instagram/mqtt/connection.ts +72 -0
  278. package/src/platforms/instagram/mqtt/thrift.test.ts +91 -0
  279. package/src/platforms/instagram/mqtt/thrift.ts +159 -0
  280. package/src/platforms/instagram/mqtt/transport.ts +285 -0
  281. package/src/platforms/instagram/realtime-listener.test.ts +34 -0
  282. package/src/platforms/instagram/realtime-listener.ts +201 -0
  283. package/src/platforms/instagram/types.ts +11 -8
  284. package/src/platforms/teams/app-config.ts +92 -0
  285. package/src/platforms/teams/client.test.ts +335 -1
  286. package/src/platforms/teams/client.ts +342 -7
  287. package/src/platforms/teams/commands/auth.test.ts +93 -1
  288. package/src/platforms/teams/commands/auth.ts +182 -3
  289. package/src/platforms/teams/commands/file.test.ts +92 -1
  290. package/src/platforms/teams/commands/file.ts +76 -1
  291. package/src/platforms/teams/commands/message.test.ts +95 -1
  292. package/src/platforms/teams/commands/message.ts +145 -3
  293. package/src/platforms/teams/credential-manager.test.ts +20 -0
  294. package/src/platforms/teams/credential-manager.ts +68 -3
  295. package/src/platforms/teams/device-code.test.ts +311 -0
  296. package/src/platforms/teams/device-code.ts +277 -0
  297. package/src/platforms/teams/device-login.test.ts +288 -0
  298. package/src/platforms/teams/device-login.ts +206 -0
  299. package/src/platforms/teams/ensure-auth.ts +11 -0
  300. package/src/platforms/teams/index.test.ts +10 -0
  301. package/src/platforms/teams/index.ts +16 -1
  302. package/src/platforms/teams/listener.ts +295 -0
  303. package/src/platforms/teams/realm-discovery.test.ts +73 -0
  304. package/src/platforms/teams/realm-discovery.ts +42 -0
  305. package/src/platforms/teams/token-extractor.ts +69 -0
  306. package/src/platforms/teams/token-provider.test.ts +227 -0
  307. package/src/platforms/teams/token-provider.ts +284 -0
  308. package/src/platforms/teams/trouter.test.ts +114 -0
  309. package/src/platforms/teams/trouter.ts +228 -0
  310. package/src/platforms/teams/types.test.ts +17 -0
  311. package/src/platforms/teams/types.ts +86 -0
  312. package/src/platforms/webexbot/cli.ts +0 -0
  313. package/src/tui/adapters/imessage-adapter.ts +131 -0
  314. package/src/tui/adapters/instagram-adapter.ts +13 -0
  315. package/src/tui/adapters/teams-adapter.ts +23 -0
  316. package/src/tui/app.ts +16 -3
@@ -3,6 +3,7 @@ import { afterEach, beforeEach, describe, expect, mock, spyOn, it } from 'bun:te
3
3
  const originalConsoleLog = console.log
4
4
  import type { Command } from 'commander'
5
5
 
6
+ import { InstagramClient } from '../client'
6
7
  import { InstagramCredentialManager } from '../credential-manager'
7
8
 
8
9
  const mockGetAccount = mock(() => Promise.resolve(null))
@@ -10,7 +11,7 @@ const mockListAccounts = mock(() => Promise.resolve([]))
10
11
  const mockSetCurrent = mock(() => Promise.resolve(true))
11
12
  const mockRemoveAccount = mock(() => Promise.resolve(true))
12
13
 
13
- import { authCommand } from './auth'
14
+ import { authCommand, sanitizePassword } from './auth'
14
15
 
15
16
  function resetCommandState(cmd: Command): void {
16
17
  for (const sub of cmd.commands) {
@@ -216,4 +217,179 @@ describe('auth commands', () => {
216
217
  expect(output.error).toContain('missing_account')
217
218
  })
218
219
  })
220
+
221
+ describe('sanitizePassword', () => {
222
+ it('preserves leading and trailing whitespace', () => {
223
+ expect(sanitizePassword(' pass word ')).toBe(' pass word ')
224
+ expect(sanitizePassword('\tpw\t')).toBe('\tpw\t')
225
+ })
226
+
227
+ it('preserves internal characters exactly', () => {
228
+ expect(sanitizePassword('p@$$ w0rd!#')).toBe('p@$$ w0rd!#')
229
+ })
230
+
231
+ it('strips only a trailing carriage return', () => {
232
+ expect(sanitizePassword('secret\r')).toBe('secret')
233
+ expect(sanitizePassword('sec\rret')).toBe('sec\rret')
234
+ })
235
+
236
+ it('returns undefined for empty input', () => {
237
+ expect(sanitizePassword('')).toBeUndefined()
238
+ expect(sanitizePassword('\r')).toBeUndefined()
239
+ })
240
+
241
+ it('keeps a whitespace-only password (not treated as empty)', () => {
242
+ expect(sanitizePassword(' ')).toBe(' ')
243
+ })
244
+ })
245
+
246
+ describe('login-email', () => {
247
+ it('redeems uid/token non-interactively and saves the account', async () => {
248
+ const oneClickSpy = spyOn(InstagramClient.prototype, 'oneClickLogin').mockResolvedValue({ userId: '4242' })
249
+ const setSessionPathSpy = spyOn(InstagramClient.prototype, 'setSessionPath').mockImplementation(() => {})
250
+ const ensurePathsSpy = spyOn(InstagramCredentialManager.prototype, 'ensureAccountPaths').mockResolvedValue({
251
+ account_dir: '/tmp/x',
252
+ session_path: '/tmp/x/session.json',
253
+ })
254
+ const setAccountSpy = spyOn(InstagramCredentialManager.prototype, 'setAccount').mockResolvedValue(undefined)
255
+
256
+ await authCommand.parseAsync(
257
+ ['login-email', '--username', 'alice', '--uid', 'ENCODED_UID', '--token', 'NONCE123', '--pretty'],
258
+ { from: 'user' },
259
+ )
260
+
261
+ expect(oneClickSpy).toHaveBeenCalledWith('ENCODED_UID', 'NONCE123')
262
+ expect(setAccountSpy).toHaveBeenCalled()
263
+ const output = JSON.parse(consoleLogSpy.mock.calls[0]![0] as string)
264
+ expect(output.authenticated).toBe(true)
265
+ expect(output.username).toBe('alice')
266
+
267
+ for (const spy of [oneClickSpy, setSessionPathSpy, ensurePathsSpy, setAccountSpy]) spy.mockRestore()
268
+ })
269
+
270
+ it('parses uid/token from a pasted --link', async () => {
271
+ const oneClickSpy = spyOn(InstagramClient.prototype, 'oneClickLogin').mockResolvedValue({ userId: '7' })
272
+ const setSessionPathSpy = spyOn(InstagramClient.prototype, 'setSessionPath').mockImplementation(() => {})
273
+ const ensurePathsSpy = spyOn(InstagramCredentialManager.prototype, 'ensureAccountPaths').mockResolvedValue({
274
+ account_dir: '/tmp/x',
275
+ session_path: '/tmp/x/session.json',
276
+ })
277
+ const setAccountSpy = spyOn(InstagramCredentialManager.prototype, 'setAccount').mockResolvedValue(undefined)
278
+
279
+ await authCommand.parseAsync(
280
+ [
281
+ 'login-email',
282
+ '--username',
283
+ 'bob',
284
+ '--link',
285
+ 'https://www.instagram.com/_n/web_emaillogin?uid=U1&token=T1&auto_send=0',
286
+ ],
287
+ { from: 'user' },
288
+ )
289
+
290
+ expect(oneClickSpy).toHaveBeenCalledWith('U1', 'T1')
291
+
292
+ for (const spy of [oneClickSpy, setSessionPathSpy, ensurePathsSpy, setAccountSpy]) spy.mockRestore()
293
+ })
294
+
295
+ it('errors on an invalid --link instead of sending a new email', async () => {
296
+ const oneClickSpy = spyOn(InstagramClient.prototype, 'oneClickLogin').mockResolvedValue({ userId: '1' })
297
+ const sendEmailSpy = spyOn(InstagramClient.prototype, 'sendOneClickLoginEmail').mockResolvedValue({
298
+ sent: true,
299
+ contactPoint: '',
300
+ })
301
+
302
+ await expect(
303
+ authCommand.parseAsync(['login-email', '--username', 'alice', '--link', 'not-a-link', '--pretty'], {
304
+ from: 'user',
305
+ }),
306
+ ).rejects.toThrow('process.exit called')
307
+
308
+ expect(sendEmailSpy).not.toHaveBeenCalled()
309
+ expect(oneClickSpy).not.toHaveBeenCalled()
310
+ const output = JSON.parse(consoleLogSpy.mock.calls[0]![0] as string)
311
+ expect(output.error).toContain('Invalid login link')
312
+
313
+ for (const spy of [oneClickSpy, sendEmailSpy]) spy.mockRestore()
314
+ })
315
+
316
+ it('errors when only --uid is provided without --token', async () => {
317
+ const sendEmailSpy = spyOn(InstagramClient.prototype, 'sendOneClickLoginEmail').mockResolvedValue({
318
+ sent: true,
319
+ contactPoint: '',
320
+ })
321
+
322
+ await expect(
323
+ authCommand.parseAsync(['login-email', '--username', 'alice', '--uid', 'U1', '--pretty'], { from: 'user' }),
324
+ ).rejects.toThrow('process.exit called')
325
+
326
+ expect(sendEmailSpy).not.toHaveBeenCalled()
327
+ const output = JSON.parse(consoleLogSpy.mock.calls[0]![0] as string)
328
+ expect(output.error).toContain('Invalid login link')
329
+
330
+ sendEmailSpy.mockRestore()
331
+ })
332
+
333
+ it('treats an explicit empty --link as redeem intent and fails closed', async () => {
334
+ const oneClickSpy = spyOn(InstagramClient.prototype, 'oneClickLogin').mockResolvedValue({ userId: '1' })
335
+ const sendEmailSpy = spyOn(InstagramClient.prototype, 'sendOneClickLoginEmail').mockResolvedValue({
336
+ sent: true,
337
+ contactPoint: '',
338
+ })
339
+
340
+ await expect(
341
+ authCommand.parseAsync(['login-email', '--username', 'alice', '--link', '', '--pretty'], { from: 'user' }),
342
+ ).rejects.toThrow('process.exit called')
343
+
344
+ expect(sendEmailSpy).not.toHaveBeenCalled()
345
+ expect(oneClickSpy).not.toHaveBeenCalled()
346
+ const output = JSON.parse(consoleLogSpy.mock.calls[0]![0] as string)
347
+ expect(output.error).toContain('Invalid login link')
348
+
349
+ for (const spy of [oneClickSpy, sendEmailSpy]) spy.mockRestore()
350
+ })
351
+
352
+ it('requires --username when redeeming non-interactively', async () => {
353
+ const oneClickSpy = spyOn(InstagramClient.prototype, 'oneClickLogin').mockResolvedValue({ userId: '1' })
354
+
355
+ await expect(
356
+ authCommand.parseAsync(['login-email', '--uid', 'U1', '--token', 'T1', '--pretty'], { from: 'user' }),
357
+ ).rejects.toThrow('process.exit called')
358
+
359
+ expect(oneClickSpy).not.toHaveBeenCalled()
360
+ const output = JSON.parse(consoleLogSpy.mock.calls[0]![0] as string)
361
+ expect(output.error).toContain('--username is required')
362
+
363
+ oneClickSpy.mockRestore()
364
+ })
365
+ })
366
+
367
+ describe('login email fallback', () => {
368
+ it('non-interactively sends the login email when password login offers it', async () => {
369
+ const authSpy = spyOn(InstagramClient.prototype, 'authenticate').mockResolvedValue({
370
+ userId: '',
371
+ oneClickEmailAvailable: true,
372
+ })
373
+ const sendEmailSpy = spyOn(InstagramClient.prototype, 'sendOneClickLoginEmail').mockResolvedValue({
374
+ sent: true,
375
+ contactPoint: 'j***@example.com',
376
+ })
377
+ const ensurePathsSpy = spyOn(InstagramCredentialManager.prototype, 'ensureAccountPaths').mockResolvedValue({
378
+ account_dir: '/tmp/x',
379
+ session_path: '/tmp/x/session.json',
380
+ })
381
+
382
+ await authCommand.parseAsync(['login', '--username', 'alice', '--password', 'wrong', '--pretty'], {
383
+ from: 'user',
384
+ })
385
+
386
+ expect(sendEmailSpy).toHaveBeenCalledWith('alice')
387
+ const output = JSON.parse(consoleLogSpy.mock.calls[0]![0] as string)
388
+ expect(output.one_click_email_available).toBe(true)
389
+ expect(output.email_sent).toBe(true)
390
+ expect(output.contact_point).toBe('j***@example.com')
391
+
392
+ for (const spy of [authSpy, sendEmailSpy, ensurePathsSpy]) spy.mockRestore()
393
+ })
394
+ })
219
395
  })
@@ -1,4 +1,3 @@
1
- import { randomUUID } from 'node:crypto'
2
1
  import { mkdir, writeFile } from 'node:fs/promises'
3
2
  import { Writable } from 'node:stream'
4
3
 
@@ -10,7 +9,7 @@ import { isInteractive } from '@/shared/utils/interactive'
10
9
  import { formatOutput } from '@/shared/utils/output'
11
10
  import { info, warn, error as stderrError, debug } from '@/shared/utils/stderr'
12
11
 
13
- import { InstagramClient, generateAndroidDeviceId, generateDeviceString } from '../client'
12
+ import { InstagramClient, generateDevice, parseOneClickLoginLink } from '../client'
14
13
  import { InstagramCredentialManager } from '../credential-manager'
15
14
  import { InstagramTokenExtractor } from '../token-extractor'
16
15
  import { createAccountId } from '../types'
@@ -26,6 +25,13 @@ async function promptText(message: string): Promise<string | undefined> {
26
25
  }
27
26
  }
28
27
 
28
+ // Never trim passwords: they may contain leading/trailing spaces. Trimming corrupts them and
29
+ // Instagram rejects the login as bad_password. Only strip the trailing CR some terminals add.
30
+ export function sanitizePassword(raw: string): string | undefined {
31
+ const password = raw.replace(/\r$/, '')
32
+ return password.length > 0 ? password : undefined
33
+ }
34
+
29
35
  async function promptHidden(message: string): Promise<string | undefined> {
30
36
  const { createInterface } = await import('node:readline/promises')
31
37
  const hiddenOutput = new (class extends Writable {
@@ -41,7 +47,7 @@ async function promptHidden(message: string): Promise<string | undefined> {
41
47
  process.stdout.write(`${message}: `)
42
48
  const answer = await rl.question('')
43
49
  process.stdout.write('\n')
44
- return answer.trim() || undefined
50
+ return sanitizePassword(answer)
45
51
  } finally {
46
52
  hiddenOutput.muted = false
47
53
  rl.close()
@@ -187,12 +193,167 @@ async function loginAction(options: LoginOptions): Promise<void> {
187
193
  return
188
194
  }
189
195
 
196
+ if (result.oneClickEmailAvailable) {
197
+ if (interactive) {
198
+ await runEmailLogin(manager, username, options, client)
199
+ } else {
200
+ const { contactPoint } = await client.sendOneClickLoginEmail(username)
201
+ console.log(
202
+ formatOutput(
203
+ {
204
+ one_click_email_available: true,
205
+ email_sent: true,
206
+ contact_point: contactPoint,
207
+ message:
208
+ 'Password login was rejected, but this account can log in by email. A login email was sent. Open the "Login as <username>" link and run "agent-instagram auth login-email --username <username> --link <url>" to finish.',
209
+ },
210
+ options.pretty,
211
+ ),
212
+ )
213
+ }
214
+ return
215
+ }
216
+
190
217
  await saveAccountAndPrint(manager, accountId, username, result.userId, options.pretty)
191
218
  } catch (error) {
192
219
  handleError(error as Error)
193
220
  }
194
221
  }
195
222
 
223
+ interface LoginEmailOptions {
224
+ username?: string
225
+ link?: string
226
+ uid?: string
227
+ token?: string
228
+ pretty?: boolean
229
+ debug?: boolean
230
+ }
231
+
232
+ async function loginEmailAction(options: LoginEmailOptions): Promise<void> {
233
+ try {
234
+ const interactive = isInteractive()
235
+ const manager = new InstagramCredentialManager()
236
+
237
+ // Presence (not truthiness) signals redeem intent: an explicit empty --link "" must still
238
+ // fail closed here rather than falling through to the send-email branch and re-emailing.
239
+ const redeemAttempted = options.link !== undefined || options.uid !== undefined || options.token !== undefined
240
+ if (redeemAttempted) {
241
+ if (!options.username) {
242
+ console.log(
243
+ formatOutput({ error: '--username is required when redeeming a login email link.' }, options.pretty),
244
+ )
245
+ process.exit(1)
246
+ }
247
+ const redeem = resolveOneClickCredentials(options)
248
+ if (!redeem) {
249
+ console.log(
250
+ formatOutput(
251
+ { error: 'Invalid login link. Provide a valid --link <url>, or both --uid and --token.' },
252
+ options.pretty,
253
+ ),
254
+ )
255
+ process.exit(1)
256
+ }
257
+ await redeemLoginEmail(manager, redeem.uid, redeem.token, options.username, options)
258
+ return
259
+ }
260
+
261
+ let username = options.username
262
+ if (!username) {
263
+ if (!interactive) {
264
+ console.log(
265
+ formatOutput(
266
+ {
267
+ error:
268
+ 'Username required. Use --username <username> to send the login email, then --link <url> (or --uid and --token) to complete.',
269
+ },
270
+ options.pretty,
271
+ ),
272
+ )
273
+ process.exit(1)
274
+ }
275
+ username = await promptText('Instagram username')
276
+ if (!username) {
277
+ stderrError('Username is required.')
278
+ process.exit(1)
279
+ }
280
+ }
281
+
282
+ const client = new InstagramClient(manager)
283
+ if (options.debug) client.setDebugLog((msg) => debug(`[debug] ${msg}`))
284
+
285
+ if (!interactive) {
286
+ const { contactPoint } = await client.sendOneClickLoginEmail(username)
287
+ console.log(
288
+ formatOutput(
289
+ {
290
+ email_sent: true,
291
+ contact_point: contactPoint,
292
+ message:
293
+ 'Login email sent. Open the "Login as <username>" link and run "agent-instagram auth login-email --username <username> --link <url>" to finish.',
294
+ },
295
+ options.pretty,
296
+ ),
297
+ )
298
+ return
299
+ }
300
+
301
+ await runEmailLogin(manager, username, options, client)
302
+ } catch (error) {
303
+ handleError(error as Error)
304
+ }
305
+ }
306
+
307
+ async function runEmailLogin(
308
+ manager: InstagramCredentialManager,
309
+ username: string,
310
+ options: LoginEmailOptions,
311
+ client: InstagramClient,
312
+ ): Promise<void> {
313
+ const { contactPoint } = await client.sendOneClickLoginEmail(username)
314
+
315
+ info(contactPoint ? `\n Login email sent to: ${contactPoint}` : '\n Login email sent.')
316
+ info(' Open the email, copy the "Login as ..." link, and paste it here.')
317
+ const link = await promptText('Login link')
318
+ if (!link) {
319
+ stderrError('Login link is required.')
320
+ process.exit(1)
321
+ }
322
+
323
+ const parsed = parseOneClickLoginLink(link)
324
+ if (!parsed) {
325
+ stderrError('Could not read uid and token from the pasted link.')
326
+ process.exit(1)
327
+ }
328
+
329
+ await redeemLoginEmail(manager, parsed.uid, parsed.token, username, options, client)
330
+ }
331
+
332
+ function resolveOneClickCredentials(options: LoginEmailOptions): { uid: string; token: string } | null {
333
+ if (options.link) return parseOneClickLoginLink(options.link)
334
+ if (options.uid && options.token) return { uid: options.uid, token: options.token }
335
+ return null
336
+ }
337
+
338
+ async function redeemLoginEmail(
339
+ manager: InstagramCredentialManager,
340
+ uid: string,
341
+ token: string,
342
+ username: string,
343
+ options: LoginEmailOptions,
344
+ existingClient?: InstagramClient,
345
+ ): Promise<void> {
346
+ const client = existingClient ?? new InstagramClient(manager)
347
+ if (!existingClient && options.debug) client.setDebugLog((msg) => debug(`[debug] ${msg}`))
348
+
349
+ const accountId = createAccountId(username)
350
+ const paths = await manager.ensureAccountPaths(accountId)
351
+ client.setSessionPath(paths.session_path)
352
+
353
+ const result = await client.oneClickLogin(uid, token)
354
+ await saveAccountAndPrint(manager, accountId, username, result.userId, options.pretty)
355
+ }
356
+
196
357
  async function handleInteractiveChallenge(
197
358
  client: InstagramClient,
198
359
  manager: InstagramCredentialManager,
@@ -414,6 +575,12 @@ async function extractAction(options: { pretty?: boolean; debug?: boolean; brows
414
575
 
415
576
  const manager = new InstagramCredentialManager()
416
577
 
578
+ let device = await manager.loadDevice()
579
+ if (!device) {
580
+ device = generateDevice()
581
+ await manager.saveDevice(device)
582
+ }
583
+
417
584
  for (const extracted of results) {
418
585
  const session = {
419
586
  cookies: [
@@ -426,14 +593,7 @@ async function extractAction(options: { pretty?: boolean; debug?: boolean; brows
426
593
  ]
427
594
  .filter(Boolean)
428
595
  .join('; '),
429
- device: {
430
- phone_id: randomUUID(),
431
- uuid: randomUUID(),
432
- android_device_id: generateAndroidDeviceId(),
433
- advertising_id: randomUUID(),
434
- client_session_id: randomUUID(),
435
- device_string: generateDeviceString(),
436
- },
596
+ device,
437
597
  user_id: extracted.ds_user_id,
438
598
  mid: extracted.mid,
439
599
  }
@@ -530,6 +690,17 @@ export const authCommand = new Command('auth')
530
690
  .option('--debug', 'Show raw API responses')
531
691
  .action(loginAction),
532
692
  )
693
+ .addCommand(
694
+ new Command('login-email')
695
+ .description('Log in via a one-click login email link (no password needed)')
696
+ .option('--username <username>', 'Instagram username (sends the login email)')
697
+ .option('--link <url>', 'The "Login as <username>" URL from the email')
698
+ .option('--uid <uid>', 'uid from the email link (alternative to --link)')
699
+ .option('--token <token>', 'token from the email link (alternative to --link)')
700
+ .option('--pretty', 'Pretty print JSON output')
701
+ .option('--debug', 'Show raw API responses')
702
+ .action(loginEmailAction),
703
+ )
533
704
  .addCommand(
534
705
  new Command('extract')
535
706
  .description('Extract Instagram cookies from browser (Chrome, Edge, Arc, Brave)')
@@ -274,4 +274,34 @@ describe('InstagramCredentialManager', () => {
274
274
  expect(paths.session_path).toBe(join(testConfigDir, 'instagram', 'my-account', 'session.json'))
275
275
  })
276
276
  })
277
+
278
+ describe('device persistence', () => {
279
+ const device = {
280
+ phone_id: 'phone-1',
281
+ uuid: 'uuid-1',
282
+ android_device_id: 'android-abcdef0123456789',
283
+ advertising_id: 'adid-1',
284
+ client_session_id: 'csid-1',
285
+ device_string: '34/14; 480dpi; 1344x2992; Google/google; Pixel 8 Pro; husky; husky; en_US; 719358530',
286
+ }
287
+
288
+ it('returns null when no device is stored', async () => {
289
+ const manager = setup()
290
+ expect(await manager.loadDevice()).toBeNull()
291
+ })
292
+
293
+ it('persists and reloads the same device', async () => {
294
+ const manager = setup()
295
+ await manager.saveDevice(device)
296
+ expect(await manager.loadDevice()).toEqual(device)
297
+ })
298
+
299
+ it('shares one device across accounts (machine-level, not per-account)', async () => {
300
+ const manager = setup()
301
+ await manager.saveDevice(device)
302
+ await manager.setAccount(makeAccount({ account_id: 'a' }))
303
+ await manager.setAccount(makeAccount({ account_id: 'b' }))
304
+ expect(await manager.loadDevice()).toEqual(device)
305
+ })
306
+ })
277
307
  })
@@ -3,17 +3,39 @@ import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'
3
3
  import { join } from 'node:path'
4
4
 
5
5
  import { getConfigDir } from '../../shared/utils/config-dir'
6
- import { createAccountId, type InstagramAccount, type InstagramAccountPaths, type InstagramConfig } from './types'
6
+ import {
7
+ createAccountId,
8
+ type InstagramAccount,
9
+ type InstagramAccountPaths,
10
+ type InstagramConfig,
11
+ type InstagramDevice,
12
+ } from './types'
7
13
 
8
14
  export class InstagramCredentialManager {
9
15
  private configDir: string
10
16
  private credentialsPath: string
11
17
  private instagramRootDir: string
18
+ private devicePath: string
12
19
 
13
20
  constructor(configDir?: string) {
14
21
  this.configDir = configDir ?? getConfigDir()
15
22
  this.credentialsPath = join(this.configDir, 'instagram-credentials.json')
16
23
  this.instagramRootDir = join(this.configDir, 'instagram')
24
+ this.devicePath = join(this.instagramRootDir, 'device.json')
25
+ }
26
+
27
+ async loadDevice(): Promise<InstagramDevice | null> {
28
+ if (!existsSync(this.devicePath)) return null
29
+ try {
30
+ return JSON.parse(await readFile(this.devicePath, 'utf-8')) as InstagramDevice
31
+ } catch {
32
+ return null
33
+ }
34
+ }
35
+
36
+ async saveDevice(device: InstagramDevice): Promise<void> {
37
+ await mkdir(this.instagramRootDir, { recursive: true })
38
+ await writeFile(this.devicePath, JSON.stringify(device, null, 2), { mode: 0o600 })
17
39
  }
18
40
 
19
41
  async loadConfig(): Promise<InstagramConfig> {
@@ -0,0 +1,135 @@
1
+ import { afterEach, describe, expect, it, mock } from 'bun:test'
2
+ import { EventEmitter } from 'node:events'
3
+
4
+ import { InstagramHybridListener } from '@/platforms/instagram/hybrid-listener'
5
+ import type { InstagramRealtimeListener } from '@/platforms/instagram/realtime-listener'
6
+
7
+ function makeRealtimeFailingClient() {
8
+ return {
9
+ getUserId: () => '123',
10
+ getSessionState: () => {
11
+ throw new Error('realtime bootstrap failed')
12
+ },
13
+ fetchIrisBootstrap: async () => {
14
+ throw new Error('realtime bootstrap failed')
15
+ },
16
+ listChats: mock(async () => []),
17
+ } as never
18
+ }
19
+
20
+ class FakeRealtimeListener {
21
+ private emitter = new EventEmitter()
22
+ stopped = false
23
+
24
+ on(event: string, handler: (...args: unknown[]) => void) {
25
+ this.emitter.on(event, handler)
26
+ return this
27
+ }
28
+ off() {
29
+ return this
30
+ }
31
+ once() {
32
+ return this
33
+ }
34
+ async start() {
35
+ this.emitter.emit('connected', { userId: '123' })
36
+ }
37
+ stop() {
38
+ this.stopped = true
39
+ this.emitter.emit('disconnected')
40
+ }
41
+ fail() {
42
+ this.emitter.emit('error', new Error('realtime connection lost'))
43
+ }
44
+ }
45
+
46
+ class TestableHybridListener extends InstagramHybridListener {
47
+ lastFake: FakeRealtimeListener | null = null
48
+ protected override createRealtimeListener(): InstagramRealtimeListener {
49
+ const fake = new FakeRealtimeListener()
50
+ this.lastFake = fake
51
+ return fake as unknown as InstagramRealtimeListener
52
+ }
53
+ }
54
+
55
+ describe('InstagramHybridListener', () => {
56
+ let listener: InstagramHybridListener
57
+
58
+ afterEach(() => {
59
+ listener?.stop()
60
+ })
61
+
62
+ it('falls back to polling and emits a single error when realtime startup fails', async () => {
63
+ const client = makeRealtimeFailingClient()
64
+ listener = new InstagramHybridListener(client, {
65
+ pollInterval: 60_000,
66
+ realtimeRetryBaseMs: 60_000,
67
+ realtimeRetryMaxMs: 60_000,
68
+ })
69
+
70
+ const errors: Error[] = []
71
+ listener.on('error', (err) => errors.push(err))
72
+
73
+ const connected = new Promise<{ userId: string; transport: string }>((resolve) => {
74
+ listener.on('connected', resolve)
75
+ })
76
+
77
+ await listener.start()
78
+ const info = await connected
79
+
80
+ // given a realtime failure, when startup fails, then it falls back to polling
81
+ expect(info.transport).toBe('polling')
82
+ // and the failure is reported exactly once (no recursive re-entry)
83
+ expect(errors).toHaveLength(1)
84
+ })
85
+
86
+ it('falls back to polling exactly once when a connected realtime listener fails', async () => {
87
+ const client = {
88
+ getUserId: () => '123',
89
+ listChats: mock(async () => []),
90
+ } as never
91
+ const testable = new TestableHybridListener(client, {
92
+ pollInterval: 60_000,
93
+ realtimeRetryBaseMs: 60_000,
94
+ realtimeRetryMaxMs: 60_000,
95
+ })
96
+ listener = testable
97
+
98
+ const errors: Error[] = []
99
+ const transports: string[] = []
100
+ testable.on('error', (err) => errors.push(err))
101
+ testable.on('connected', ({ transport }) => transports.push(transport))
102
+
103
+ await testable.start()
104
+ // given an established realtime connection
105
+ expect(transports).toContain('realtime')
106
+
107
+ const pollingConnected = new Promise<void>((resolve) => {
108
+ testable.on('connected', ({ transport }) => {
109
+ if (transport === 'polling') resolve()
110
+ })
111
+ })
112
+
113
+ // when the connected realtime listener fails
114
+ testable.lastFake!.fail()
115
+ await pollingConnected
116
+
117
+ // then the failure is reported once and it falls back to polling without recursion
118
+ expect(errors).toHaveLength(1)
119
+ expect(transports.filter((t) => t === 'polling')).toHaveLength(1)
120
+ })
121
+
122
+ it('uses polling directly when realtime is disabled', async () => {
123
+ const client = makeRealtimeFailingClient()
124
+ listener = new InstagramHybridListener(client, { pollInterval: 60_000, disableRealtime: true })
125
+
126
+ const connected = new Promise<{ transport: string }>((resolve) => {
127
+ listener.on('connected', resolve)
128
+ })
129
+
130
+ await listener.start()
131
+ const info = await connected
132
+
133
+ expect(info.transport).toBe('polling')
134
+ })
135
+ })