agent-messenger 2.10.2 → 2.11.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 (330) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/.env.template +4 -1
  3. package/README.md +77 -27
  4. package/bun.lock +26 -0
  5. package/dist/package.json +14 -1
  6. package/dist/src/platforms/channeltalk/commands/auth.d.ts +2 -1
  7. package/dist/src/platforms/channeltalk/commands/auth.d.ts.map +1 -1
  8. package/dist/src/platforms/channeltalk/commands/auth.js +5 -3
  9. package/dist/src/platforms/channeltalk/commands/auth.js.map +1 -1
  10. package/dist/src/platforms/channeltalk/token-extractor.d.ts +2 -1
  11. package/dist/src/platforms/channeltalk/token-extractor.d.ts.map +1 -1
  12. package/dist/src/platforms/channeltalk/token-extractor.js +22 -6
  13. package/dist/src/platforms/channeltalk/token-extractor.js.map +1 -1
  14. package/dist/src/platforms/channeltalkbot/cli.d.ts.map +1 -1
  15. package/dist/src/platforms/channeltalkbot/cli.js +11 -1
  16. package/dist/src/platforms/channeltalkbot/cli.js.map +1 -1
  17. package/dist/src/platforms/channeltalkbot/commands/auth.d.ts.map +1 -1
  18. package/dist/src/platforms/channeltalkbot/commands/auth.js +1 -5
  19. package/dist/src/platforms/channeltalkbot/commands/auth.js.map +1 -1
  20. package/dist/src/platforms/channeltalkbot/commands/bot.d.ts.map +1 -1
  21. package/dist/src/platforms/channeltalkbot/commands/bot.js +1 -6
  22. package/dist/src/platforms/channeltalkbot/commands/bot.js.map +1 -1
  23. package/dist/src/platforms/channeltalkbot/commands/chat.d.ts.map +1 -1
  24. package/dist/src/platforms/channeltalkbot/commands/chat.js +1 -6
  25. package/dist/src/platforms/channeltalkbot/commands/chat.js.map +1 -1
  26. package/dist/src/platforms/channeltalkbot/commands/group.d.ts.map +1 -1
  27. package/dist/src/platforms/channeltalkbot/commands/group.js +1 -6
  28. package/dist/src/platforms/channeltalkbot/commands/group.js.map +1 -1
  29. package/dist/src/platforms/channeltalkbot/commands/manager.d.ts.map +1 -1
  30. package/dist/src/platforms/channeltalkbot/commands/manager.js +1 -6
  31. package/dist/src/platforms/channeltalkbot/commands/manager.js.map +1 -1
  32. package/dist/src/platforms/channeltalkbot/commands/message.d.ts.map +1 -1
  33. package/dist/src/platforms/channeltalkbot/commands/message.js +1 -6
  34. package/dist/src/platforms/channeltalkbot/commands/message.js.map +1 -1
  35. package/dist/src/platforms/channeltalkbot/commands/whoami.d.ts.map +1 -1
  36. package/dist/src/platforms/channeltalkbot/commands/whoami.js +1 -6
  37. package/dist/src/platforms/channeltalkbot/commands/whoami.js.map +1 -1
  38. package/dist/src/platforms/channeltalkbot/credential-manager.d.ts +5 -0
  39. package/dist/src/platforms/channeltalkbot/credential-manager.d.ts.map +1 -1
  40. package/dist/src/platforms/channeltalkbot/credential-manager.js +34 -4
  41. package/dist/src/platforms/channeltalkbot/credential-manager.js.map +1 -1
  42. package/dist/src/platforms/discord/commands/auth.d.ts +1 -0
  43. package/dist/src/platforms/discord/commands/auth.d.ts.map +1 -1
  44. package/dist/src/platforms/discord/commands/auth.js +3 -1
  45. package/dist/src/platforms/discord/commands/auth.js.map +1 -1
  46. package/dist/src/platforms/discord/listener.d.ts +2 -0
  47. package/dist/src/platforms/discord/listener.d.ts.map +1 -1
  48. package/dist/src/platforms/discord/listener.js +51 -21
  49. package/dist/src/platforms/discord/listener.js.map +1 -1
  50. package/dist/src/platforms/discord/token-extractor.d.ts +2 -1
  51. package/dist/src/platforms/discord/token-extractor.d.ts.map +1 -1
  52. package/dist/src/platforms/discord/token-extractor.js +21 -6
  53. package/dist/src/platforms/discord/token-extractor.js.map +1 -1
  54. package/dist/src/platforms/discordbot/cli.d.ts.map +1 -1
  55. package/dist/src/platforms/discordbot/cli.js +12 -1
  56. package/dist/src/platforms/discordbot/cli.js.map +1 -1
  57. package/dist/src/platforms/discordbot/client.d.ts +3 -0
  58. package/dist/src/platforms/discordbot/client.d.ts.map +1 -1
  59. package/dist/src/platforms/discordbot/client.js +3 -0
  60. package/dist/src/platforms/discordbot/client.js.map +1 -1
  61. package/dist/src/platforms/discordbot/commands/auth.d.ts.map +1 -1
  62. package/dist/src/platforms/discordbot/commands/auth.js +1 -5
  63. package/dist/src/platforms/discordbot/commands/auth.js.map +1 -1
  64. package/dist/src/platforms/discordbot/commands/message.d.ts.map +1 -1
  65. package/dist/src/platforms/discordbot/commands/message.js +1 -6
  66. package/dist/src/platforms/discordbot/commands/message.js.map +1 -1
  67. package/dist/src/platforms/discordbot/commands/server.d.ts.map +1 -1
  68. package/dist/src/platforms/discordbot/commands/server.js +1 -4
  69. package/dist/src/platforms/discordbot/commands/server.js.map +1 -1
  70. package/dist/src/platforms/discordbot/commands/whoami.d.ts.map +1 -1
  71. package/dist/src/platforms/discordbot/commands/whoami.js +1 -6
  72. package/dist/src/platforms/discordbot/commands/whoami.js.map +1 -1
  73. package/dist/src/platforms/discordbot/index.d.ts +3 -1
  74. package/dist/src/platforms/discordbot/index.d.ts.map +1 -1
  75. package/dist/src/platforms/discordbot/index.js +2 -1
  76. package/dist/src/platforms/discordbot/index.js.map +1 -1
  77. package/dist/src/platforms/discordbot/listener.d.ts +43 -0
  78. package/dist/src/platforms/discordbot/listener.d.ts.map +1 -0
  79. package/dist/src/platforms/discordbot/listener.js +292 -0
  80. package/dist/src/platforms/discordbot/listener.js.map +1 -0
  81. package/dist/src/platforms/discordbot/types.d.ts +161 -0
  82. package/dist/src/platforms/discordbot/types.d.ts.map +1 -1
  83. package/dist/src/platforms/discordbot/types.js +34 -0
  84. package/dist/src/platforms/discordbot/types.js.map +1 -1
  85. package/dist/src/platforms/instagram/commands/auth.d.ts.map +1 -1
  86. package/dist/src/platforms/instagram/commands/auth.js +3 -1
  87. package/dist/src/platforms/instagram/commands/auth.js.map +1 -1
  88. package/dist/src/platforms/instagram/token-extractor.d.ts +2 -1
  89. package/dist/src/platforms/instagram/token-extractor.d.ts.map +1 -1
  90. package/dist/src/platforms/instagram/token-extractor.js +11 -2
  91. package/dist/src/platforms/instagram/token-extractor.js.map +1 -1
  92. package/dist/src/platforms/slack/commands/auth.d.ts.map +1 -1
  93. package/dist/src/platforms/slack/commands/auth.js +4 -2
  94. package/dist/src/platforms/slack/commands/auth.js.map +1 -1
  95. package/dist/src/platforms/slack/token-extractor.d.ts +4 -1
  96. package/dist/src/platforms/slack/token-extractor.d.ts.map +1 -1
  97. package/dist/src/platforms/slack/token-extractor.js +64 -15
  98. package/dist/src/platforms/slack/token-extractor.js.map +1 -1
  99. package/dist/src/platforms/slackbot/cli.d.ts.map +1 -1
  100. package/dist/src/platforms/slackbot/cli.js +15 -3
  101. package/dist/src/platforms/slackbot/cli.js.map +1 -1
  102. package/dist/src/platforms/slackbot/client.d.ts +22 -1
  103. package/dist/src/platforms/slackbot/client.d.ts.map +1 -1
  104. package/dist/src/platforms/slackbot/client.js +104 -1
  105. package/dist/src/platforms/slackbot/client.js.map +1 -1
  106. package/dist/src/platforms/slackbot/commands/auth.d.ts.map +1 -1
  107. package/dist/src/platforms/slackbot/commands/auth.js +1 -5
  108. package/dist/src/platforms/slackbot/commands/auth.js.map +1 -1
  109. package/dist/src/platforms/slackbot/commands/file.d.ts +3 -0
  110. package/dist/src/platforms/slackbot/commands/file.d.ts.map +1 -0
  111. package/dist/src/platforms/slackbot/commands/file.js +164 -0
  112. package/dist/src/platforms/slackbot/commands/file.js.map +1 -0
  113. package/dist/src/platforms/slackbot/commands/index.d.ts +1 -0
  114. package/dist/src/platforms/slackbot/commands/index.d.ts.map +1 -1
  115. package/dist/src/platforms/slackbot/commands/index.js +1 -0
  116. package/dist/src/platforms/slackbot/commands/index.js.map +1 -1
  117. package/dist/src/platforms/slackbot/commands/message.d.ts.map +1 -1
  118. package/dist/src/platforms/slackbot/commands/message.js +19 -0
  119. package/dist/src/platforms/slackbot/commands/message.js.map +1 -1
  120. package/dist/src/platforms/slackbot/commands/whoami.d.ts.map +1 -1
  121. package/dist/src/platforms/slackbot/commands/whoami.js +1 -6
  122. package/dist/src/platforms/slackbot/commands/whoami.js.map +1 -1
  123. package/dist/src/platforms/slackbot/credential-manager.d.ts +1 -0
  124. package/dist/src/platforms/slackbot/credential-manager.d.ts.map +1 -1
  125. package/dist/src/platforms/slackbot/credential-manager.js +30 -2
  126. package/dist/src/platforms/slackbot/credential-manager.js.map +1 -1
  127. package/dist/src/platforms/slackbot/index.d.ts +4 -1
  128. package/dist/src/platforms/slackbot/index.d.ts.map +1 -1
  129. package/dist/src/platforms/slackbot/index.js +1 -0
  130. package/dist/src/platforms/slackbot/index.js.map +1 -1
  131. package/dist/src/platforms/slackbot/listener.d.ts +44 -0
  132. package/dist/src/platforms/slackbot/listener.d.ts.map +1 -0
  133. package/dist/src/platforms/slackbot/listener.js +313 -0
  134. package/dist/src/platforms/slackbot/listener.js.map +1 -0
  135. package/dist/src/platforms/slackbot/types.d.ts +196 -1
  136. package/dist/src/platforms/slackbot/types.d.ts.map +1 -1
  137. package/dist/src/platforms/slackbot/types.js +4 -1
  138. package/dist/src/platforms/slackbot/types.js.map +1 -1
  139. package/dist/src/platforms/teams/commands/auth.d.ts +1 -0
  140. package/dist/src/platforms/teams/commands/auth.d.ts.map +1 -1
  141. package/dist/src/platforms/teams/commands/auth.js +37 -6
  142. package/dist/src/platforms/teams/commands/auth.js.map +1 -1
  143. package/dist/src/platforms/teams/ensure-auth.js +31 -9
  144. package/dist/src/platforms/teams/ensure-auth.js.map +1 -1
  145. package/dist/src/platforms/teams/token-extractor.d.ts +4 -1
  146. package/dist/src/platforms/teams/token-extractor.d.ts.map +1 -1
  147. package/dist/src/platforms/teams/token-extractor.js +71 -29
  148. package/dist/src/platforms/teams/token-extractor.js.map +1 -1
  149. package/dist/src/platforms/webex/commands/auth.d.ts +1 -0
  150. package/dist/src/platforms/webex/commands/auth.d.ts.map +1 -1
  151. package/dist/src/platforms/webex/commands/auth.js +3 -1
  152. package/dist/src/platforms/webex/commands/auth.js.map +1 -1
  153. package/dist/src/platforms/webex/token-extractor.d.ts +3 -1
  154. package/dist/src/platforms/webex/token-extractor.d.ts.map +1 -1
  155. package/dist/src/platforms/webex/token-extractor.js +16 -2
  156. package/dist/src/platforms/webex/token-extractor.js.map +1 -1
  157. package/dist/src/platforms/wechatbot/cli.d.ts.map +1 -1
  158. package/dist/src/platforms/wechatbot/cli.js +11 -1
  159. package/dist/src/platforms/wechatbot/cli.js.map +1 -1
  160. package/dist/src/platforms/wechatbot/commands/auth.d.ts.map +1 -1
  161. package/dist/src/platforms/wechatbot/commands/auth.js +1 -5
  162. package/dist/src/platforms/wechatbot/commands/auth.js.map +1 -1
  163. package/dist/src/platforms/wechatbot/commands/message.d.ts.map +1 -1
  164. package/dist/src/platforms/wechatbot/commands/message.js +1 -6
  165. package/dist/src/platforms/wechatbot/commands/message.js.map +1 -1
  166. package/dist/src/platforms/wechatbot/commands/template.d.ts.map +1 -1
  167. package/dist/src/platforms/wechatbot/commands/template.js +1 -6
  168. package/dist/src/platforms/wechatbot/commands/template.js.map +1 -1
  169. package/dist/src/platforms/wechatbot/commands/user.d.ts.map +1 -1
  170. package/dist/src/platforms/wechatbot/commands/user.js +1 -6
  171. package/dist/src/platforms/wechatbot/commands/user.js.map +1 -1
  172. package/dist/src/platforms/wechatbot/commands/whoami.d.ts.map +1 -1
  173. package/dist/src/platforms/wechatbot/commands/whoami.js +1 -6
  174. package/dist/src/platforms/wechatbot/commands/whoami.js.map +1 -1
  175. package/dist/src/platforms/whatsappbot/cli.d.ts.map +1 -1
  176. package/dist/src/platforms/whatsappbot/cli.js +11 -1
  177. package/dist/src/platforms/whatsappbot/cli.js.map +1 -1
  178. package/dist/src/platforms/whatsappbot/commands/auth.d.ts.map +1 -1
  179. package/dist/src/platforms/whatsappbot/commands/auth.js +1 -5
  180. package/dist/src/platforms/whatsappbot/commands/auth.js.map +1 -1
  181. package/dist/src/platforms/whatsappbot/commands/message.d.ts.map +1 -1
  182. package/dist/src/platforms/whatsappbot/commands/message.js +1 -6
  183. package/dist/src/platforms/whatsappbot/commands/message.js.map +1 -1
  184. package/dist/src/platforms/whatsappbot/commands/template.d.ts.map +1 -1
  185. package/dist/src/platforms/whatsappbot/commands/template.js +1 -6
  186. package/dist/src/platforms/whatsappbot/commands/template.js.map +1 -1
  187. package/dist/src/platforms/whatsappbot/commands/whoami.d.ts.map +1 -1
  188. package/dist/src/platforms/whatsappbot/commands/whoami.js +1 -6
  189. package/dist/src/platforms/whatsappbot/commands/whoami.js.map +1 -1
  190. package/dist/src/shared/chromium/browsers.d.ts +8 -0
  191. package/dist/src/shared/chromium/browsers.d.ts.map +1 -1
  192. package/dist/src/shared/chromium/browsers.js +58 -3
  193. package/dist/src/shared/chromium/browsers.js.map +1 -1
  194. package/dist/src/shared/chromium/cli-options.d.ts +5 -0
  195. package/dist/src/shared/chromium/cli-options.d.ts.map +1 -0
  196. package/dist/src/shared/chromium/cli-options.js +8 -0
  197. package/dist/src/shared/chromium/cli-options.js.map +1 -0
  198. package/dist/src/shared/chromium/index.d.ts +3 -1
  199. package/dist/src/shared/chromium/index.d.ts.map +1 -1
  200. package/dist/src/shared/chromium/index.js +2 -1
  201. package/dist/src/shared/chromium/index.js.map +1 -1
  202. package/dist/src/shared/utils/cli-output.d.ts +7 -0
  203. package/dist/src/shared/utils/cli-output.d.ts.map +1 -0
  204. package/dist/src/shared/utils/cli-output.js +7 -0
  205. package/dist/src/shared/utils/cli-output.js.map +1 -0
  206. package/dist/src/tui/app.d.ts.map +1 -1
  207. package/dist/src/tui/app.js +73 -20
  208. package/dist/src/tui/app.js.map +1 -1
  209. package/docs/content/docs/cli/channeltalk.mdx +4 -0
  210. package/docs/content/docs/cli/discord.mdx +5 -0
  211. package/docs/content/docs/cli/instagram.mdx +3 -0
  212. package/docs/content/docs/cli/slack.mdx +5 -0
  213. package/docs/content/docs/cli/slackbot.mdx +60 -22
  214. package/docs/content/docs/cli/teams.mdx +5 -0
  215. package/docs/content/docs/cli/webex.mdx +3 -0
  216. package/docs/content/docs/sdk/channeltalkbot.mdx +38 -1
  217. package/docs/content/docs/sdk/discordbot.mdx +501 -0
  218. package/docs/content/docs/sdk/meta.json +2 -0
  219. package/docs/content/docs/sdk/slackbot.mdx +576 -0
  220. package/e2e/README.md +1 -1
  221. package/e2e/config.ts +9 -4
  222. package/examples/discordbot-listen.ts +65 -0
  223. package/examples/slackbot-listen.ts +65 -0
  224. package/package.json +14 -1
  225. package/skills/agent-channeltalk/SKILL.md +5 -1
  226. package/skills/agent-channeltalk/references/authentication.md +5 -1
  227. package/skills/agent-channeltalkbot/SKILL.md +17 -3
  228. package/skills/agent-channeltalkbot/references/authentication.md +7 -5
  229. package/skills/agent-discord/SKILL.md +5 -1
  230. package/skills/agent-discord/references/authentication.md +7 -1
  231. package/skills/agent-discordbot/SKILL.md +13 -2
  232. package/skills/agent-discordbot/references/common-patterns.md +1 -1
  233. package/skills/agent-instagram/SKILL.md +7 -1
  234. package/skills/agent-instagram/references/authentication.md +6 -0
  235. package/skills/agent-kakaotalk/SKILL.md +1 -1
  236. package/skills/agent-line/SKILL.md +1 -1
  237. package/skills/agent-slack/SKILL.md +5 -1
  238. package/skills/agent-slack/references/authentication.md +7 -1
  239. package/skills/agent-slackbot/SKILL.md +56 -4
  240. package/skills/agent-slackbot/references/authentication.md +4 -0
  241. package/skills/agent-teams/SKILL.md +5 -1
  242. package/skills/agent-teams/references/authentication.md +7 -1
  243. package/skills/agent-telegram/SKILL.md +1 -1
  244. package/skills/agent-webex/SKILL.md +7 -1
  245. package/skills/agent-webex/references/authentication.md +6 -0
  246. package/skills/agent-wechatbot/SKILL.md +16 -1
  247. package/skills/agent-wechatbot/references/authentication.md +219 -0
  248. package/skills/agent-wechatbot/references/common-patterns.md +358 -0
  249. package/skills/agent-wechatbot/templates/account-summary.sh +122 -0
  250. package/skills/agent-wechatbot/templates/post-message.sh +122 -0
  251. package/skills/agent-wechatbot/templates/send-template.sh +152 -0
  252. package/skills/agent-whatsapp/SKILL.md +1 -1
  253. package/skills/agent-whatsappbot/SKILL.md +30 -1
  254. package/src/platforms/channeltalk/commands/auth.test.ts +15 -3
  255. package/src/platforms/channeltalk/commands/auth.ts +15 -5
  256. package/src/platforms/channeltalk/token-extractor.ts +24 -5
  257. package/src/platforms/channeltalkbot/cli.ts +9 -0
  258. package/src/platforms/channeltalkbot/commands/auth.ts +1 -5
  259. package/src/platforms/channeltalkbot/commands/bot.ts +1 -6
  260. package/src/platforms/channeltalkbot/commands/chat.ts +1 -6
  261. package/src/platforms/channeltalkbot/commands/group.ts +1 -6
  262. package/src/platforms/channeltalkbot/commands/manager.ts +1 -6
  263. package/src/platforms/channeltalkbot/commands/message.ts +1 -6
  264. package/src/platforms/channeltalkbot/commands/whoami.test.ts +2 -0
  265. package/src/platforms/channeltalkbot/commands/whoami.ts +1 -6
  266. package/src/platforms/channeltalkbot/credential-manager.test.ts +96 -2
  267. package/src/platforms/channeltalkbot/credential-manager.ts +37 -4
  268. package/src/platforms/discord/commands/auth.ts +13 -2
  269. package/src/platforms/discord/listener.test.ts +59 -1
  270. package/src/platforms/discord/listener.ts +43 -19
  271. package/src/platforms/discord/token-extractor.ts +30 -6
  272. package/src/platforms/discordbot/cli.ts +10 -0
  273. package/src/platforms/discordbot/client.ts +4 -0
  274. package/src/platforms/discordbot/commands/auth.ts +1 -5
  275. package/src/platforms/discordbot/commands/message.ts +1 -6
  276. package/src/platforms/discordbot/commands/server.ts +1 -5
  277. package/src/platforms/discordbot/commands/whoami.ts +1 -6
  278. package/src/platforms/discordbot/index.test.ts +82 -0
  279. package/src/platforms/discordbot/index.ts +27 -9
  280. package/src/platforms/discordbot/listener.test.ts +1002 -0
  281. package/src/platforms/discordbot/listener.ts +321 -0
  282. package/src/platforms/discordbot/types.ts +163 -0
  283. package/src/platforms/instagram/commands/auth.ts +9 -1
  284. package/src/platforms/instagram/token-extractor.ts +13 -1
  285. package/src/platforms/slack/commands/auth.ts +11 -2
  286. package/src/platforms/slack/token-extractor.test.ts +96 -0
  287. package/src/platforms/slack/token-extractor.ts +76 -13
  288. package/src/platforms/slackbot/cli.ts +13 -1
  289. package/src/platforms/slackbot/client.test.ts +274 -0
  290. package/src/platforms/slackbot/client.ts +130 -2
  291. package/src/platforms/slackbot/commands/auth.ts +1 -5
  292. package/src/platforms/slackbot/commands/file.test.ts +201 -0
  293. package/src/platforms/slackbot/commands/file.ts +212 -0
  294. package/src/platforms/slackbot/commands/index.ts +1 -0
  295. package/src/platforms/slackbot/commands/message.ts +22 -0
  296. package/src/platforms/slackbot/commands/whoami.ts +1 -6
  297. package/src/platforms/slackbot/credential-manager.test.ts +62 -2
  298. package/src/platforms/slackbot/credential-manager.ts +32 -2
  299. package/src/platforms/slackbot/index.test.ts +59 -0
  300. package/src/platforms/slackbot/index.ts +31 -7
  301. package/src/platforms/slackbot/listener.test.ts +1012 -0
  302. package/src/platforms/slackbot/listener.ts +362 -0
  303. package/src/platforms/slackbot/types.ts +224 -1
  304. package/src/platforms/teams/commands/auth.test.ts +1 -1
  305. package/src/platforms/teams/commands/auth.ts +66 -7
  306. package/src/platforms/teams/ensure-auth.test.ts +56 -5
  307. package/src/platforms/teams/ensure-auth.ts +39 -11
  308. package/src/platforms/teams/token-extractor.test.ts +146 -24
  309. package/src/platforms/teams/token-extractor.ts +87 -29
  310. package/src/platforms/webex/commands/auth.ts +13 -2
  311. package/src/platforms/webex/token-extractor.ts +25 -3
  312. package/src/platforms/wechatbot/cli.ts +9 -0
  313. package/src/platforms/wechatbot/commands/auth.ts +1 -5
  314. package/src/platforms/wechatbot/commands/message.ts +1 -6
  315. package/src/platforms/wechatbot/commands/template.ts +1 -6
  316. package/src/platforms/wechatbot/commands/user.ts +1 -6
  317. package/src/platforms/wechatbot/commands/whoami.ts +1 -6
  318. package/src/platforms/whatsappbot/cli.ts +9 -0
  319. package/src/platforms/whatsappbot/commands/auth.ts +1 -5
  320. package/src/platforms/whatsappbot/commands/message.ts +1 -6
  321. package/src/platforms/whatsappbot/commands/template.ts +1 -6
  322. package/src/platforms/whatsappbot/commands/whoami.ts +1 -6
  323. package/src/shared/chromium/browsers.test.ts +80 -0
  324. package/src/shared/chromium/browsers.ts +72 -3
  325. package/src/shared/chromium/cli-options.test.ts +22 -0
  326. package/src/shared/chromium/cli-options.ts +12 -0
  327. package/src/shared/chromium/index.ts +3 -0
  328. package/src/shared/utils/cli-output.test.ts +57 -0
  329. package/src/shared/utils/cli-output.ts +8 -0
  330. package/src/tui/app.ts +129 -20
@@ -0,0 +1,1002 @@
1
+ import { afterEach, describe, expect, mock, it } from 'bun:test'
2
+
3
+ import { DiscordBotListener } from '@/platforms/discordbot/listener'
4
+ import type {
5
+ DiscordGatewayGenericEvent,
6
+ DiscordGatewayMessageCreateEvent,
7
+ DiscordGatewayMessageDeleteEvent,
8
+ DiscordGatewayMessageUpdateEvent,
9
+ DiscordGatewayReactionEvent,
10
+ } from '@/platforms/discordbot/types'
11
+
12
+ type WsHandler = (...args: any[]) => void
13
+
14
+ let mockWsInstance: MockWs
15
+
16
+ class MockWs {
17
+ static OPEN = 1
18
+ static CLOSED = 3
19
+ static lastUrl: string | null = null
20
+ readyState = MockWs.OPEN
21
+
22
+ private handlers = new Map<string, WsHandler[]>()
23
+ sent: string[] = []
24
+ url: string
25
+
26
+ constructor(url: string, _options?: any) {
27
+ this.url = url
28
+ MockWs.lastUrl = url
29
+ // oxlint-disable-next-line typescript-eslint/no-this-alias
30
+ mockWsInstance = this
31
+ }
32
+
33
+ on(event: string, handler: WsHandler) {
34
+ const list = this.handlers.get(event) ?? []
35
+ list.push(handler)
36
+ this.handlers.set(event, list)
37
+ }
38
+
39
+ send(data: string) {
40
+ this.sent.push(data)
41
+ }
42
+
43
+ close(code?: number) {
44
+ this.readyState = MockWs.CLOSED
45
+ setTimeout(() => this.emit('close', code ?? 1000), 0)
46
+ }
47
+
48
+ emit(event: string, ...args: any[]) {
49
+ for (const handler of this.handlers.get(event) ?? []) {
50
+ handler(...args)
51
+ }
52
+ }
53
+
54
+ simulateOpen() {
55
+ this.emit('open')
56
+ }
57
+
58
+ simulateMessage(data: Record<string, unknown>) {
59
+ this.emit('message', Buffer.from(JSON.stringify(data)))
60
+ }
61
+
62
+ simulateClose(code?: number) {
63
+ this.readyState = MockWs.CLOSED
64
+ this.emit('close', code ?? 1000)
65
+ }
66
+
67
+ simulateHello(interval = 41250) {
68
+ this.simulateMessage({ op: 10, d: { heartbeat_interval: interval } })
69
+ }
70
+
71
+ simulateReady(sessionId = 'session_123') {
72
+ this.simulateMessage({
73
+ op: 0,
74
+ t: 'READY',
75
+ s: 1,
76
+ d: {
77
+ session_id: sessionId,
78
+ resume_gateway_url: 'wss://resume.discord.gg',
79
+ user: { id: 'BOT_SELF', username: 'testbot' },
80
+ },
81
+ })
82
+ }
83
+
84
+ simulateDispatch(t: string, d: Record<string, unknown>, s: number) {
85
+ this.simulateMessage({ op: 0, t, s, d })
86
+ }
87
+
88
+ simulateHeartbeatACK() {
89
+ this.simulateMessage({ op: 11 })
90
+ }
91
+
92
+ simulateReconnect() {
93
+ this.simulateMessage({ op: 7 })
94
+ }
95
+
96
+ simulateInvalidSession(resumable: boolean) {
97
+ this.simulateMessage({ op: 9, d: resumable })
98
+ }
99
+ }
100
+
101
+ mock.module('ws', () => ({ default: MockWs, __esModule: true }))
102
+
103
+ function createMockClient(overrides: Record<string, any> = {}) {
104
+ return {
105
+ gatewayConnect: mock(() => Promise.resolve({ token: 'fake-bot-token' })),
106
+ ...overrides,
107
+ } as any
108
+ }
109
+
110
+ describe('DiscordBotListener', () => {
111
+ let listener: DiscordBotListener
112
+
113
+ afterEach(() => {
114
+ listener?.stop()
115
+ })
116
+
117
+ describe('start', () => {
118
+ it('calls gatewayConnect and opens WebSocket', async () => {
119
+ const client = createMockClient()
120
+ listener = new DiscordBotListener(client)
121
+
122
+ await listener.start()
123
+ mockWsInstance.simulateOpen()
124
+
125
+ expect(client.gatewayConnect).toHaveBeenCalledTimes(1)
126
+ })
127
+
128
+ it('is idempotent', async () => {
129
+ const client = createMockClient()
130
+ listener = new DiscordBotListener(client)
131
+
132
+ await listener.start()
133
+ await listener.start()
134
+
135
+ expect(client.gatewayConnect).toHaveBeenCalledTimes(1)
136
+ })
137
+ })
138
+
139
+ describe('connected event', () => {
140
+ it('emits connected with bot user/sessionId on READY', async () => {
141
+ const client = createMockClient()
142
+ listener = new DiscordBotListener(client)
143
+
144
+ const connected: any[] = []
145
+ listener.on('connected', (info) => connected.push(info))
146
+
147
+ await listener.start()
148
+ mockWsInstance.simulateOpen()
149
+ mockWsInstance.simulateHello()
150
+ mockWsInstance.simulateReady()
151
+
152
+ expect(connected.length).toBe(1)
153
+ expect(connected[0].user.id).toBe('BOT_SELF')
154
+ expect(connected[0].sessionId).toBe('session_123')
155
+ })
156
+ })
157
+
158
+ describe('identify', () => {
159
+ it('sends Identify with bot token after Hello', async () => {
160
+ const client = createMockClient()
161
+ listener = new DiscordBotListener(client)
162
+
163
+ await listener.start()
164
+ mockWsInstance.simulateOpen()
165
+ mockWsInstance.simulateHello()
166
+
167
+ const sentMessages = mockWsInstance.sent.map((s) => JSON.parse(s))
168
+ const identifyMsg = sentMessages.find((m) => m.op === 2)
169
+
170
+ expect(identifyMsg).toBeDefined()
171
+ expect(identifyMsg.d.token).toBe('fake-bot-token')
172
+ })
173
+
174
+ it('sends Identify with custom intents', async () => {
175
+ const client = createMockClient()
176
+ const customIntents = (1 << 9) | (1 << 15) // GuildMessages | MessageContent
177
+ listener = new DiscordBotListener(client, { intents: customIntents })
178
+
179
+ await listener.start()
180
+ mockWsInstance.simulateOpen()
181
+ mockWsInstance.simulateHello()
182
+
183
+ const sentMessages = mockWsInstance.sent.map((s) => JSON.parse(s))
184
+ const identifyMsg = sentMessages.find((m) => m.op === 2)
185
+
186
+ expect(identifyMsg).toBeDefined()
187
+ expect(identifyMsg.d.intents).toBe(customIntents)
188
+ })
189
+
190
+ it('uses sensible default intents when none specified', async () => {
191
+ const client = createMockClient()
192
+ listener = new DiscordBotListener(client)
193
+
194
+ await listener.start()
195
+ mockWsInstance.simulateOpen()
196
+ mockWsInstance.simulateHello()
197
+
198
+ const sentMessages = mockWsInstance.sent.map((s) => JSON.parse(s))
199
+ const identifyMsg = sentMessages.find((m) => m.op === 2)
200
+
201
+ // given: default intents enable Guilds + GuildMessages + reactions/typing + DMs
202
+ // then: the bitfield must include GuildMessages (1 << 9) at minimum
203
+ expect(identifyMsg.d.intents & (1 << 9)).toBeGreaterThan(0)
204
+ // and: must NOT include privileged MessageContent (1 << 15) by default
205
+ expect(identifyMsg.d.intents & (1 << 15)).toBe(0)
206
+ })
207
+ })
208
+
209
+ describe('message events', () => {
210
+ it('emits message_create events', async () => {
211
+ const client = createMockClient()
212
+ listener = new DiscordBotListener(client)
213
+
214
+ const messages: DiscordGatewayMessageCreateEvent[] = []
215
+ listener.on('message_create', (event) => messages.push(event))
216
+
217
+ await listener.start()
218
+ mockWsInstance.simulateOpen()
219
+ mockWsInstance.simulateHello()
220
+ mockWsInstance.simulateReady()
221
+ mockWsInstance.simulateDispatch(
222
+ 'MESSAGE_CREATE',
223
+ {
224
+ id: 'msg_1',
225
+ channel_id: 'C123',
226
+ author: { id: 'U456', username: 'user' },
227
+ content: 'hello world',
228
+ timestamp: '2024-01-01T00:00:00Z',
229
+ },
230
+ 2,
231
+ )
232
+
233
+ expect(messages.length).toBe(1)
234
+ expect(messages[0].content).toBe('hello world')
235
+ expect(messages[0].channel_id).toBe('C123')
236
+ })
237
+
238
+ it('emits message_update events', async () => {
239
+ const client = createMockClient()
240
+ listener = new DiscordBotListener(client)
241
+
242
+ const updates: DiscordGatewayMessageUpdateEvent[] = []
243
+ listener.on('message_update', (event) => updates.push(event))
244
+
245
+ await listener.start()
246
+ mockWsInstance.simulateOpen()
247
+ mockWsInstance.simulateHello()
248
+ mockWsInstance.simulateReady()
249
+ mockWsInstance.simulateDispatch(
250
+ 'MESSAGE_UPDATE',
251
+ { id: 'msg_1', channel_id: 'C123', content: 'edited', edited_timestamp: '2024-01-01T00:01:00Z' },
252
+ 2,
253
+ )
254
+
255
+ expect(updates.length).toBe(1)
256
+ expect(updates[0].id).toBe('msg_1')
257
+ expect(updates[0].content).toBe('edited')
258
+ })
259
+
260
+ it('emits message_delete events', async () => {
261
+ const client = createMockClient()
262
+ listener = new DiscordBotListener(client)
263
+
264
+ const deletes: DiscordGatewayMessageDeleteEvent[] = []
265
+ listener.on('message_delete', (event) => deletes.push(event))
266
+
267
+ await listener.start()
268
+ mockWsInstance.simulateOpen()
269
+ mockWsInstance.simulateHello()
270
+ mockWsInstance.simulateReady()
271
+ mockWsInstance.simulateDispatch('MESSAGE_DELETE', { id: 'msg_1', channel_id: 'C123' }, 2)
272
+
273
+ expect(deletes.length).toBe(1)
274
+ expect(deletes[0].id).toBe('msg_1')
275
+ })
276
+ })
277
+
278
+ describe('reaction events', () => {
279
+ it('emits message_reaction_add events', async () => {
280
+ const client = createMockClient()
281
+ listener = new DiscordBotListener(client)
282
+
283
+ const reactions: DiscordGatewayReactionEvent[] = []
284
+ listener.on('message_reaction_add', (event) => reactions.push(event))
285
+
286
+ await listener.start()
287
+ mockWsInstance.simulateOpen()
288
+ mockWsInstance.simulateHello()
289
+ mockWsInstance.simulateReady()
290
+ mockWsInstance.simulateDispatch(
291
+ 'MESSAGE_REACTION_ADD',
292
+ {
293
+ user_id: 'U789',
294
+ channel_id: 'C123',
295
+ message_id: 'msg_1',
296
+ emoji: { name: '👍' },
297
+ },
298
+ 2,
299
+ )
300
+
301
+ expect(reactions.length).toBe(1)
302
+ expect(reactions[0].user_id).toBe('U789')
303
+ expect(reactions[0].emoji.name).toBe('👍')
304
+ })
305
+ })
306
+
307
+ describe('interaction events', () => {
308
+ it('emits interaction_create events for slash commands', async () => {
309
+ const client = createMockClient()
310
+ listener = new DiscordBotListener(client)
311
+
312
+ const interactions: any[] = []
313
+ listener.on('interaction_create', (event) => interactions.push(event))
314
+
315
+ await listener.start()
316
+ mockWsInstance.simulateOpen()
317
+ mockWsInstance.simulateHello()
318
+ mockWsInstance.simulateReady()
319
+ mockWsInstance.simulateDispatch(
320
+ 'INTERACTION_CREATE',
321
+ {
322
+ id: 'int_1',
323
+ application_id: 'app_1',
324
+ token: 'interaction_token',
325
+ channel_id: 'C123',
326
+ data: { name: 'ping' },
327
+ },
328
+ 2,
329
+ )
330
+
331
+ expect(interactions.length).toBe(1)
332
+ expect(interactions[0].id).toBe('int_1')
333
+ expect(interactions[0].data.name).toBe('ping')
334
+ })
335
+ })
336
+
337
+ describe('discord_event catch-all', () => {
338
+ it('emits discord_event for every dispatch (not READY)', async () => {
339
+ const client = createMockClient()
340
+ listener = new DiscordBotListener(client)
341
+
342
+ const events: DiscordGatewayGenericEvent[] = []
343
+ listener.on('discord_event', (event) => events.push(event))
344
+
345
+ await listener.start()
346
+ mockWsInstance.simulateOpen()
347
+ mockWsInstance.simulateHello()
348
+ mockWsInstance.simulateReady()
349
+ mockWsInstance.simulateDispatch(
350
+ 'MESSAGE_CREATE',
351
+ { id: 'm1', channel_id: 'C1', content: 'hi', timestamp: 't', author: { id: 'U1', username: 'u' } },
352
+ 2,
353
+ )
354
+ mockWsInstance.simulateDispatch('TYPING_START', { user_id: 'U1', channel_id: 'C1', timestamp: 1 }, 3)
355
+
356
+ expect(events.length).toBe(2)
357
+ expect(events[0].type).toBe('MESSAGE_CREATE')
358
+ expect(events[1].type).toBe('TYPING_START')
359
+ })
360
+ })
361
+
362
+ describe('heartbeat', () => {
363
+ it('sends heartbeat after Hello (with jitter)', async () => {
364
+ const originalRandom = Math.random
365
+ Math.random = () => 0
366
+
367
+ try {
368
+ const client = createMockClient()
369
+ listener = new DiscordBotListener(client)
370
+
371
+ await listener.start()
372
+ mockWsInstance.simulateOpen()
373
+ mockWsInstance.simulateHello(50)
374
+
375
+ await new Promise((r) => setTimeout(r, 150))
376
+
377
+ const sentMessages = mockWsInstance.sent.map((s) => JSON.parse(s))
378
+ const heartbeats = sentMessages.filter((m) => m.op === 1)
379
+
380
+ expect(heartbeats.length).toBeGreaterThanOrEqual(1)
381
+ } finally {
382
+ Math.random = originalRandom
383
+ }
384
+ })
385
+
386
+ it('heartbeat ACK not emitted as user event', async () => {
387
+ const client = createMockClient()
388
+ listener = new DiscordBotListener(client)
389
+
390
+ const events: DiscordGatewayGenericEvent[] = []
391
+ listener.on('discord_event', (event) => events.push(event))
392
+
393
+ await listener.start()
394
+ mockWsInstance.simulateOpen()
395
+ mockWsInstance.simulateHello()
396
+ mockWsInstance.simulateReady()
397
+ mockWsInstance.simulateHeartbeatACK()
398
+
399
+ expect(events.length).toBe(0)
400
+ })
401
+
402
+ it('zombie connection triggers reconnect (no ACK received)', async () => {
403
+ const originalRandom = Math.random
404
+ Math.random = () => 0
405
+
406
+ try {
407
+ const client = createMockClient()
408
+ listener = new DiscordBotListener(client)
409
+
410
+ const disconnected: boolean[] = []
411
+ listener.on('disconnected', () => disconnected.push(true))
412
+
413
+ await listener.start()
414
+ mockWsInstance.simulateOpen()
415
+ mockWsInstance.simulateHello(50)
416
+
417
+ await new Promise((r) => setTimeout(r, 300))
418
+
419
+ expect(disconnected.length).toBeGreaterThanOrEqual(1)
420
+ } finally {
421
+ Math.random = originalRandom
422
+ }
423
+ })
424
+ })
425
+
426
+ describe('stop', () => {
427
+ it('closes WebSocket and prevents reconnection', async () => {
428
+ const client = createMockClient()
429
+ listener = new DiscordBotListener(client)
430
+
431
+ await listener.start()
432
+ mockWsInstance.simulateOpen()
433
+
434
+ listener.stop()
435
+
436
+ await new Promise((r) => setTimeout(r, 50))
437
+ expect(client.gatewayConnect).toHaveBeenCalledTimes(1)
438
+ })
439
+ })
440
+
441
+ describe('reconnection', () => {
442
+ it('reconnects on WebSocket close when running', async () => {
443
+ const client = createMockClient()
444
+ listener = new DiscordBotListener(client)
445
+
446
+ const disconnected: boolean[] = []
447
+ listener.on('disconnected', () => disconnected.push(true))
448
+
449
+ await listener.start()
450
+ mockWsInstance.simulateOpen()
451
+ mockWsInstance.simulateClose()
452
+
453
+ expect(disconnected.length).toBe(1)
454
+
455
+ await new Promise((r) => setTimeout(r, 1500))
456
+ expect(client.gatewayConnect.mock.calls.length).toBeGreaterThanOrEqual(2)
457
+ })
458
+
459
+ it('emits error and reconnects on gatewayConnect failure', async () => {
460
+ let callCount = 0
461
+ const client = createMockClient({
462
+ gatewayConnect: mock(() => {
463
+ callCount++
464
+ if (callCount === 1) return Promise.reject(new Error('network_error'))
465
+ return Promise.resolve({ token: 'fake-bot-token' })
466
+ }),
467
+ })
468
+
469
+ listener = new DiscordBotListener(client)
470
+
471
+ const errors: Error[] = []
472
+ listener.on('error', (err) => errors.push(err))
473
+
474
+ await listener.start()
475
+
476
+ await new Promise((r) => setTimeout(r, 1500))
477
+
478
+ expect(errors.length).toBe(1)
479
+ expect(errors[0].message).toBe('network_error')
480
+ expect(client.gatewayConnect.mock.calls.length).toBeGreaterThanOrEqual(2)
481
+ })
482
+ })
483
+
484
+ describe('on/off/once', () => {
485
+ it('off removes listener', async () => {
486
+ const client = createMockClient()
487
+ listener = new DiscordBotListener(client)
488
+
489
+ const messages: DiscordGatewayMessageCreateEvent[] = []
490
+ const handler = (event: DiscordGatewayMessageCreateEvent) => messages.push(event)
491
+ listener.on('message_create', handler)
492
+
493
+ await listener.start()
494
+ mockWsInstance.simulateOpen()
495
+ mockWsInstance.simulateHello()
496
+ mockWsInstance.simulateReady()
497
+ mockWsInstance.simulateDispatch(
498
+ 'MESSAGE_CREATE',
499
+ { id: 'm1', channel_id: 'C1', content: 'a', timestamp: 't', author: { id: 'U1', username: 'u' } },
500
+ 2,
501
+ )
502
+
503
+ listener.off('message_create', handler)
504
+ mockWsInstance.simulateDispatch(
505
+ 'MESSAGE_CREATE',
506
+ { id: 'm2', channel_id: 'C1', content: 'b', timestamp: 't', author: { id: 'U1', username: 'u' } },
507
+ 3,
508
+ )
509
+
510
+ expect(messages.length).toBe(1)
511
+ expect(messages[0].content).toBe('a')
512
+ })
513
+
514
+ it('once fires only once', async () => {
515
+ const client = createMockClient()
516
+ listener = new DiscordBotListener(client)
517
+
518
+ const messages: DiscordGatewayMessageCreateEvent[] = []
519
+ listener.once('message_create', (event) => messages.push(event))
520
+
521
+ await listener.start()
522
+ mockWsInstance.simulateOpen()
523
+ mockWsInstance.simulateHello()
524
+ mockWsInstance.simulateReady()
525
+ mockWsInstance.simulateDispatch(
526
+ 'MESSAGE_CREATE',
527
+ { id: 'm1', channel_id: 'C1', content: 'first', timestamp: 't', author: { id: 'U1', username: 'u' } },
528
+ 2,
529
+ )
530
+ mockWsInstance.simulateDispatch(
531
+ 'MESSAGE_CREATE',
532
+ { id: 'm2', channel_id: 'C1', content: 'second', timestamp: 't', author: { id: 'U1', username: 'u' } },
533
+ 3,
534
+ )
535
+
536
+ expect(messages.length).toBe(1)
537
+ expect(messages[0].content).toBe('first')
538
+ })
539
+ })
540
+
541
+ describe('opcode 7 Reconnect', () => {
542
+ it('triggers immediate reconnect without backoff', async () => {
543
+ const client = createMockClient()
544
+ listener = new DiscordBotListener(client)
545
+
546
+ await listener.start()
547
+ mockWsInstance.simulateOpen()
548
+ mockWsInstance.simulateHello()
549
+ mockWsInstance.simulateReady()
550
+
551
+ ;(listener as any).reconnectAttempts = 5
552
+ mockWsInstance.simulateReconnect()
553
+
554
+ expect((listener as any).reconnectAttempts).toBe(0)
555
+ })
556
+ })
557
+
558
+ describe('opcode 9 InvalidSession', () => {
559
+ it('d=false clears session state, reconnects with fresh identify', async () => {
560
+ const client = createMockClient()
561
+ listener = new DiscordBotListener(client)
562
+
563
+ await listener.start()
564
+ mockWsInstance.simulateOpen()
565
+ mockWsInstance.simulateHello()
566
+ mockWsInstance.simulateReady()
567
+
568
+ expect((listener as any).sessionId).toBe('session_123')
569
+
570
+ mockWsInstance.simulateInvalidSession(false)
571
+
572
+ expect((listener as any).sessionId).toBeNull()
573
+ expect((listener as any).sequence).toBeNull()
574
+ expect((listener as any).resumeGatewayUrl).toBeNull()
575
+ })
576
+
577
+ it('d=true allows resume on reconnect', async () => {
578
+ const client = createMockClient()
579
+ listener = new DiscordBotListener(client)
580
+
581
+ await listener.start()
582
+ mockWsInstance.simulateOpen()
583
+ mockWsInstance.simulateHello()
584
+ mockWsInstance.simulateReady()
585
+
586
+ const sessionIdBefore = (listener as any).sessionId
587
+
588
+ mockWsInstance.simulateInvalidSession(true)
589
+
590
+ expect((listener as any).sessionId).toBe(sessionIdBefore)
591
+ })
592
+ })
593
+
594
+ describe('resume', () => {
595
+ it('sends Resume instead of Identify when session exists', async () => {
596
+ const client = createMockClient()
597
+ listener = new DiscordBotListener(client)
598
+
599
+ await listener.start()
600
+ mockWsInstance.simulateOpen()
601
+ mockWsInstance.simulateHello()
602
+ mockWsInstance.simulateReady()
603
+
604
+ mockWsInstance.simulateClose()
605
+
606
+ await new Promise((r) => setTimeout(r, 1500))
607
+
608
+ mockWsInstance.simulateOpen()
609
+ mockWsInstance.simulateHello()
610
+
611
+ const sentMessages = mockWsInstance.sent.map((s) => JSON.parse(s))
612
+ const resumeMsg = sentMessages.find((m) => m.op === 6)
613
+
614
+ expect(resumeMsg).toBeDefined()
615
+ expect(resumeMsg.d.session_id).toBe('session_123')
616
+ })
617
+ })
618
+
619
+ describe('non-recoverable close codes', () => {
620
+ it('emits error and stops on code 4014 (privileged intent not approved)', async () => {
621
+ const client = createMockClient()
622
+ listener = new DiscordBotListener(client)
623
+
624
+ const errors: Error[] = []
625
+ listener.on('error', (err) => errors.push(err))
626
+
627
+ await listener.start()
628
+ mockWsInstance.simulateOpen()
629
+ mockWsInstance.simulateClose(4014)
630
+
631
+ await new Promise((r) => setTimeout(r, 50))
632
+
633
+ expect(errors.length).toBe(1)
634
+ expect(errors[0].message).toContain('4014')
635
+ expect(client.gatewayConnect).toHaveBeenCalledTimes(1)
636
+ })
637
+
638
+ it('emits error and stops on code 4004 (invalid token)', async () => {
639
+ const client = createMockClient()
640
+ listener = new DiscordBotListener(client)
641
+
642
+ const errors: Error[] = []
643
+ listener.on('error', (err) => errors.push(err))
644
+
645
+ await listener.start()
646
+ mockWsInstance.simulateOpen()
647
+ mockWsInstance.simulateClose(4004)
648
+
649
+ await new Promise((r) => setTimeout(r, 50))
650
+
651
+ expect(errors.length).toBe(1)
652
+ expect(errors[0].message).toContain('4004')
653
+ })
654
+ })
655
+
656
+ describe('session reset close codes', () => {
657
+ it('4007 clears session and reconnects with fresh identify', async () => {
658
+ const client = createMockClient()
659
+ listener = new DiscordBotListener(client)
660
+
661
+ await listener.start()
662
+ mockWsInstance.simulateOpen()
663
+ mockWsInstance.simulateHello()
664
+ mockWsInstance.simulateReady()
665
+
666
+ expect((listener as any).sessionId).toBe('session_123')
667
+
668
+ mockWsInstance.simulateClose(4007)
669
+
670
+ expect((listener as any).sessionId).toBeNull()
671
+ expect((listener as any).sequence).toBeNull()
672
+ expect((listener as any).resumeGatewayUrl).toBeNull()
673
+
674
+ await new Promise((r) => setTimeout(r, 1500))
675
+
676
+ mockWsInstance.simulateOpen()
677
+ mockWsInstance.simulateHello()
678
+
679
+ const sentMessages = mockWsInstance.sent.map((s) => JSON.parse(s))
680
+ const identifyMsg = sentMessages.find((m) => m.op === 2)
681
+ const resumeMsg = sentMessages.find((m) => m.op === 6)
682
+
683
+ expect(identifyMsg).toBeDefined()
684
+ expect(resumeMsg).toBeUndefined()
685
+ })
686
+
687
+ it('4009 clears session', async () => {
688
+ const client = createMockClient()
689
+ listener = new DiscordBotListener(client)
690
+
691
+ await listener.start()
692
+ mockWsInstance.simulateOpen()
693
+ mockWsInstance.simulateHello()
694
+ mockWsInstance.simulateReady()
695
+
696
+ mockWsInstance.simulateClose(4009)
697
+
698
+ expect((listener as any).sessionId).toBeNull()
699
+ })
700
+ })
701
+
702
+ describe('duplicate Hello', () => {
703
+ it('does not stack heartbeat timers on second Hello', async () => {
704
+ const originalRandom = Math.random
705
+ Math.random = () => 0
706
+
707
+ try {
708
+ const client = createMockClient()
709
+ listener = new DiscordBotListener(client)
710
+
711
+ await listener.start()
712
+ mockWsInstance.simulateOpen()
713
+ mockWsInstance.simulateHello(50)
714
+ mockWsInstance.simulateReady()
715
+
716
+ mockWsInstance.simulateHello(50)
717
+
718
+ await new Promise((r) => setTimeout(r, 200))
719
+
720
+ const sentMessages = mockWsInstance.sent.map((s) => JSON.parse(s))
721
+ const heartbeats = sentMessages.filter((m) => m.op === 1)
722
+
723
+ expect(heartbeats.length).toBeLessThanOrEqual(8)
724
+ } finally {
725
+ Math.random = originalRandom
726
+ }
727
+ })
728
+ })
729
+
730
+ describe('InvalidSession timer safety', () => {
731
+ it('stop cancels pending InvalidSession d=true timeout', async () => {
732
+ const originalRandom = Math.random
733
+ Math.random = () => 0
734
+
735
+ try {
736
+ const client = createMockClient()
737
+ listener = new DiscordBotListener(client)
738
+
739
+ await listener.start()
740
+ mockWsInstance.simulateOpen()
741
+ mockWsInstance.simulateHello()
742
+ mockWsInstance.simulateReady()
743
+
744
+ mockWsInstance.simulateInvalidSession(true)
745
+
746
+ listener.stop()
747
+
748
+ expect((listener as any).invalidSessionTimer).toBeNull()
749
+ } finally {
750
+ Math.random = originalRandom
751
+ }
752
+ })
753
+ })
754
+
755
+ describe('server-requested heartbeat', () => {
756
+ it('responds to opcode 1 with heartbeat', async () => {
757
+ const client = createMockClient()
758
+ listener = new DiscordBotListener(client)
759
+
760
+ await listener.start()
761
+ mockWsInstance.simulateOpen()
762
+ mockWsInstance.simulateHello()
763
+ mockWsInstance.simulateReady()
764
+
765
+ const sentBefore = mockWsInstance.sent.length
766
+ mockWsInstance.simulateMessage({ op: 1 })
767
+
768
+ const sentAfter = mockWsInstance.sent.slice(sentBefore)
769
+ const heartbeats = sentAfter.map((s) => JSON.parse(s)).filter((m) => m.op === 1)
770
+
771
+ expect(heartbeats.length).toBe(1)
772
+ })
773
+ })
774
+
775
+ describe('sequence tracking', () => {
776
+ it('tracks sequence from dispatch events', async () => {
777
+ const client = createMockClient()
778
+ listener = new DiscordBotListener(client)
779
+
780
+ await listener.start()
781
+ mockWsInstance.simulateOpen()
782
+ mockWsInstance.simulateHello()
783
+ mockWsInstance.simulateReady()
784
+
785
+ mockWsInstance.simulateDispatch(
786
+ 'MESSAGE_CREATE',
787
+ { id: 'm1', channel_id: 'C1', content: 'hi', timestamp: 't', author: { id: 'U1', username: 'u' } },
788
+ 5,
789
+ )
790
+
791
+ expect((listener as any).sequence).toBe(5)
792
+ })
793
+
794
+ it('ignores null sequence values', async () => {
795
+ const client = createMockClient()
796
+ listener = new DiscordBotListener(client)
797
+
798
+ await listener.start()
799
+ mockWsInstance.simulateOpen()
800
+ mockWsInstance.simulateHello()
801
+ mockWsInstance.simulateReady()
802
+
803
+ mockWsInstance.simulateDispatch(
804
+ 'MESSAGE_CREATE',
805
+ { id: 'm1', channel_id: 'C1', content: 'hi', timestamp: 't', author: { id: 'U1', username: 'u' } },
806
+ 5,
807
+ )
808
+
809
+ mockWsInstance.simulateMessage({
810
+ op: 0,
811
+ t: 'TYPING_START',
812
+ s: null,
813
+ d: { user_id: 'U1', channel_id: 'C1', timestamp: 1 },
814
+ })
815
+
816
+ expect((listener as any).sequence).toBe(5)
817
+ })
818
+ })
819
+
820
+ describe('start after stop', () => {
821
+ it('resets reconnect attempts on fresh start', async () => {
822
+ const client = createMockClient()
823
+ listener = new DiscordBotListener(client)
824
+
825
+ await listener.start()
826
+ ;(listener as any).reconnectAttempts = 5
827
+ listener.stop()
828
+
829
+ await listener.start()
830
+ expect((listener as any).reconnectAttempts).toBe(0)
831
+ })
832
+ })
833
+
834
+ describe('reconnect URL', () => {
835
+ it('appends ?v=10&encoding=json to resume_gateway_url on reconnect', async () => {
836
+ const client = createMockClient()
837
+ listener = new DiscordBotListener(client)
838
+
839
+ await listener.start()
840
+ mockWsInstance.simulateOpen()
841
+ mockWsInstance.simulateHello()
842
+ mockWsInstance.simulateMessage({
843
+ op: 0,
844
+ t: 'READY',
845
+ s: 1,
846
+ d: {
847
+ session_id: 'session_xyz',
848
+ resume_gateway_url: 'wss://gateway-us-east1-b.discord.gg',
849
+ user: { id: 'BOT', username: 'bot' },
850
+ },
851
+ })
852
+
853
+ mockWsInstance.simulateClose()
854
+ await new Promise((r) => setTimeout(r, 1500))
855
+
856
+ expect(MockWs.lastUrl).toBe('wss://gateway-us-east1-b.discord.gg?v=10&encoding=json')
857
+ })
858
+
859
+ it('uses default gateway URL on initial connect when no resume URL is set', async () => {
860
+ const client = createMockClient()
861
+ listener = new DiscordBotListener(client)
862
+
863
+ await listener.start()
864
+
865
+ expect(MockWs.lastUrl).toBe('wss://gateway.discord.gg/?v=10&encoding=json')
866
+ })
867
+ })
868
+
869
+ describe('reconnectAttempts deferred to READY/RESUMED', () => {
870
+ it('does not reset reconnectAttempts on socket open alone', async () => {
871
+ const client = createMockClient()
872
+ listener = new DiscordBotListener(client)
873
+
874
+ await listener.start()
875
+ ;(listener as any).reconnectAttempts = 5
876
+
877
+ mockWsInstance.simulateOpen()
878
+
879
+ // given: a socket opens but no READY received yet
880
+ // then: reconnectAttempts must NOT be reset (open alone is not a successful session)
881
+ expect((listener as any).reconnectAttempts).toBe(5)
882
+ })
883
+
884
+ it('resets reconnectAttempts on READY dispatch', async () => {
885
+ const client = createMockClient()
886
+ listener = new DiscordBotListener(client)
887
+
888
+ await listener.start()
889
+ ;(listener as any).reconnectAttempts = 5
890
+
891
+ mockWsInstance.simulateOpen()
892
+ mockWsInstance.simulateHello()
893
+ mockWsInstance.simulateReady()
894
+
895
+ expect((listener as any).reconnectAttempts).toBe(0)
896
+ })
897
+
898
+ it('resets reconnectAttempts on RESUMED dispatch', async () => {
899
+ const client = createMockClient()
900
+ listener = new DiscordBotListener(client)
901
+
902
+ await listener.start()
903
+ mockWsInstance.simulateOpen()
904
+ mockWsInstance.simulateHello()
905
+ mockWsInstance.simulateReady()
906
+
907
+ mockWsInstance.simulateClose()
908
+ await new Promise((r) => setTimeout(r, 1500))
909
+
910
+ mockWsInstance.simulateOpen()
911
+ mockWsInstance.simulateHello()
912
+ ;(listener as any).reconnectAttempts = 5
913
+
914
+ mockWsInstance.simulateMessage({ op: 0, t: 'RESUMED', s: 2, d: {} })
915
+
916
+ expect((listener as any).reconnectAttempts).toBe(0)
917
+ })
918
+ })
919
+
920
+ describe('RESUMED dispatch', () => {
921
+ it('emits connected with cached user/session on RESUMED', async () => {
922
+ const client = createMockClient()
923
+ listener = new DiscordBotListener(client)
924
+
925
+ const connectedEvents: any[] = []
926
+ listener.on('connected', (info) => connectedEvents.push(info))
927
+
928
+ await listener.start()
929
+ mockWsInstance.simulateOpen()
930
+ mockWsInstance.simulateHello()
931
+ mockWsInstance.simulateReady()
932
+
933
+ expect(connectedEvents.length).toBe(1)
934
+
935
+ mockWsInstance.simulateClose()
936
+ await new Promise((r) => setTimeout(r, 1500))
937
+
938
+ mockWsInstance.simulateOpen()
939
+ mockWsInstance.simulateHello()
940
+ mockWsInstance.simulateMessage({ op: 0, t: 'RESUMED', s: 2, d: {} })
941
+
942
+ expect(connectedEvents.length).toBe(2)
943
+ expect(connectedEvents[1].user.id).toBe('BOT_SELF')
944
+ expect(connectedEvents[1].sessionId).toBe('session_123')
945
+ })
946
+ })
947
+
948
+ describe('generation guard prevents stale-socket interference', () => {
949
+ it('stale socket close after stop+start does not clear new socket timers', async () => {
950
+ const client = createMockClient()
951
+ listener = new DiscordBotListener(client)
952
+
953
+ await listener.start()
954
+ mockWsInstance.simulateOpen()
955
+ mockWsInstance.simulateHello()
956
+ mockWsInstance.simulateReady()
957
+
958
+ const oldWs = mockWsInstance
959
+
960
+ listener.stop()
961
+ await listener.start()
962
+
963
+ // given: a fresh socket from the second start()
964
+ // when: the OLD socket fires a stale close event
965
+ oldWs.emit('close', 1000)
966
+
967
+ // then: the new socket's state should be intact (running, generation incremented)
968
+ expect((listener as any).running).toBe(true)
969
+ expect((listener as any).generation).toBeGreaterThanOrEqual(2)
970
+ })
971
+
972
+ it('InvalidSession d=true timer no-ops if generation changed before firing', async () => {
973
+ const originalRandom = Math.random
974
+ Math.random = () => 0
975
+
976
+ try {
977
+ const client = createMockClient()
978
+ listener = new DiscordBotListener(client)
979
+
980
+ await listener.start()
981
+ mockWsInstance.simulateOpen()
982
+ mockWsInstance.simulateHello()
983
+ mockWsInstance.simulateReady()
984
+
985
+ const initialGeneration = (listener as any).generation
986
+
987
+ mockWsInstance.simulateInvalidSession(true)
988
+
989
+ // when: stop()+start() before the d=true delay fires
990
+ listener.stop()
991
+ await listener.start()
992
+
993
+ await new Promise((r) => setTimeout(r, 1500))
994
+
995
+ // then: generation moved forward so the stale timer's close call is suppressed
996
+ expect((listener as any).generation).toBeGreaterThan(initialGeneration)
997
+ } finally {
998
+ Math.random = originalRandom
999
+ }
1000
+ })
1001
+ })
1002
+ })