agent-messenger 2.0.0 → 2.2.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 (272) hide show
  1. package/.claude-plugin/marketplace.json +14 -1
  2. package/.claude-plugin/plugin.json +4 -2
  3. package/.env.template +35 -17
  4. package/README.md +37 -33
  5. package/bun.lock +6 -6
  6. package/dist/package.json +11 -3
  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/channeltalk/commands/auth.d.ts.map +1 -1
  11. package/dist/src/platforms/channeltalk/commands/auth.js +35 -28
  12. package/dist/src/platforms/channeltalk/commands/auth.js.map +1 -1
  13. package/dist/src/platforms/channeltalk/ensure-auth.js +6 -6
  14. package/dist/src/platforms/channeltalk/ensure-auth.js.map +1 -1
  15. package/dist/src/platforms/channeltalk/token-extractor.d.ts +23 -1
  16. package/dist/src/platforms/channeltalk/token-extractor.d.ts.map +1 -1
  17. package/dist/src/platforms/channeltalk/token-extractor.js +299 -29
  18. package/dist/src/platforms/channeltalk/token-extractor.js.map +1 -1
  19. package/dist/src/platforms/discord/commands/auth.d.ts.map +1 -1
  20. package/dist/src/platforms/discord/commands/auth.js +57 -49
  21. package/dist/src/platforms/discord/commands/auth.js.map +1 -1
  22. package/dist/src/platforms/discord/ensure-auth.js +3 -3
  23. package/dist/src/platforms/discord/ensure-auth.js.map +1 -1
  24. package/dist/src/platforms/discord/token-extractor.d.ts +6 -1
  25. package/dist/src/platforms/discord/token-extractor.d.ts.map +1 -1
  26. package/dist/src/platforms/discord/token-extractor.js +167 -14
  27. package/dist/src/platforms/discord/token-extractor.js.map +1 -1
  28. package/dist/src/platforms/instagram/client.d.ts +2 -0
  29. package/dist/src/platforms/instagram/client.d.ts.map +1 -1
  30. package/dist/src/platforms/instagram/client.js +2 -2
  31. package/dist/src/platforms/instagram/client.js.map +1 -1
  32. package/dist/src/platforms/instagram/commands/auth.d.ts.map +1 -1
  33. package/dist/src/platforms/instagram/commands/auth.js +107 -14
  34. package/dist/src/platforms/instagram/commands/auth.js.map +1 -1
  35. package/dist/src/platforms/instagram/ensure-auth.d.ts.map +1 -1
  36. package/dist/src/platforms/instagram/ensure-auth.js +57 -11
  37. package/dist/src/platforms/instagram/ensure-auth.js.map +1 -1
  38. package/dist/src/platforms/instagram/index.d.ts +1 -0
  39. package/dist/src/platforms/instagram/index.d.ts.map +1 -1
  40. package/dist/src/platforms/instagram/index.js +1 -0
  41. package/dist/src/platforms/instagram/index.js.map +1 -1
  42. package/dist/src/platforms/instagram/token-extractor.d.ts +44 -0
  43. package/dist/src/platforms/instagram/token-extractor.d.ts.map +1 -0
  44. package/dist/src/platforms/instagram/token-extractor.js +407 -0
  45. package/dist/src/platforms/instagram/token-extractor.js.map +1 -0
  46. package/dist/src/platforms/kakaotalk/client.d.ts.map +1 -1
  47. package/dist/src/platforms/kakaotalk/client.js +2 -1
  48. package/dist/src/platforms/kakaotalk/client.js.map +1 -1
  49. package/dist/src/platforms/kakaotalk/commands/auth.d.ts.map +1 -1
  50. package/dist/src/platforms/kakaotalk/commands/auth.js +14 -13
  51. package/dist/src/platforms/kakaotalk/commands/auth.js.map +1 -1
  52. package/dist/src/platforms/kakaotalk/protocol/connection.d.ts.map +1 -1
  53. package/dist/src/platforms/kakaotalk/protocol/connection.js +2 -1
  54. package/dist/src/platforms/kakaotalk/protocol/connection.js.map +1 -1
  55. package/dist/src/platforms/line/commands/auth.d.ts.map +1 -1
  56. package/dist/src/platforms/line/commands/auth.js +6 -5
  57. package/dist/src/platforms/line/commands/auth.js.map +1 -1
  58. package/dist/src/platforms/slack/commands/auth.d.ts.map +1 -1
  59. package/dist/src/platforms/slack/commands/auth.js +11 -10
  60. package/dist/src/platforms/slack/commands/auth.js.map +1 -1
  61. package/dist/src/platforms/slack/token-extractor.d.ts +9 -0
  62. package/dist/src/platforms/slack/token-extractor.d.ts.map +1 -1
  63. package/dist/src/platforms/slack/token-extractor.js +300 -23
  64. package/dist/src/platforms/slack/token-extractor.js.map +1 -1
  65. package/dist/src/platforms/teams/commands/auth.d.ts.map +1 -1
  66. package/dist/src/platforms/teams/commands/auth.js +9 -8
  67. package/dist/src/platforms/teams/commands/auth.js.map +1 -1
  68. package/dist/src/platforms/teams/ensure-auth.d.ts.map +1 -1
  69. package/dist/src/platforms/teams/ensure-auth.js +2 -1
  70. package/dist/src/platforms/teams/ensure-auth.js.map +1 -1
  71. package/dist/src/platforms/teams/token-extractor.d.ts +5 -0
  72. package/dist/src/platforms/teams/token-extractor.d.ts.map +1 -1
  73. package/dist/src/platforms/teams/token-extractor.js +161 -29
  74. package/dist/src/platforms/teams/token-extractor.js.map +1 -1
  75. package/dist/src/platforms/telegram/client.d.ts.map +1 -1
  76. package/dist/src/platforms/telegram/client.js +25 -7
  77. package/dist/src/platforms/telegram/client.js.map +1 -1
  78. package/dist/src/platforms/telegram/commands/auth.d.ts.map +1 -1
  79. package/dist/src/platforms/telegram/commands/auth.js +6 -5
  80. package/dist/src/platforms/telegram/commands/auth.js.map +1 -1
  81. package/dist/src/platforms/webex/app-config.d.ts +7 -0
  82. package/dist/src/platforms/webex/app-config.d.ts.map +1 -0
  83. package/dist/src/platforms/webex/app-config.js +20 -0
  84. package/dist/src/platforms/webex/app-config.js.map +1 -0
  85. package/dist/src/platforms/webex/cli.d.ts +5 -0
  86. package/dist/src/platforms/webex/cli.d.ts.map +1 -0
  87. package/dist/src/platforms/webex/cli.js +32 -0
  88. package/dist/src/platforms/webex/cli.js.map +1 -0
  89. package/dist/src/platforms/webex/client.d.ts +55 -0
  90. package/dist/src/platforms/webex/client.d.ts.map +1 -0
  91. package/dist/src/platforms/webex/client.js +299 -0
  92. package/dist/src/platforms/webex/client.js.map +1 -0
  93. package/dist/src/platforms/webex/commands/auth.d.ts +19 -0
  94. package/dist/src/platforms/webex/commands/auth.d.ts.map +1 -0
  95. package/dist/src/platforms/webex/commands/auth.js +166 -0
  96. package/dist/src/platforms/webex/commands/auth.js.map +1 -0
  97. package/dist/src/platforms/webex/commands/index.d.ts +6 -0
  98. package/dist/src/platforms/webex/commands/index.d.ts.map +1 -0
  99. package/dist/src/platforms/webex/commands/index.js +6 -0
  100. package/dist/src/platforms/webex/commands/index.js.map +1 -0
  101. package/dist/src/platforms/webex/commands/member.d.ts +7 -0
  102. package/dist/src/platforms/webex/commands/member.d.ts.map +1 -0
  103. package/dist/src/platforms/webex/commands/member.js +34 -0
  104. package/dist/src/platforms/webex/commands/member.js.map +1 -0
  105. package/dist/src/platforms/webex/commands/message.d.ts +26 -0
  106. package/dist/src/platforms/webex/commands/message.d.ts.map +1 -0
  107. package/dist/src/platforms/webex/commands/message.js +153 -0
  108. package/dist/src/platforms/webex/commands/message.js.map +1 -0
  109. package/dist/src/platforms/webex/commands/snapshot.d.ts +9 -0
  110. package/dist/src/platforms/webex/commands/snapshot.d.ts.map +1 -0
  111. package/dist/src/platforms/webex/commands/snapshot.js +72 -0
  112. package/dist/src/platforms/webex/commands/snapshot.js.map +1 -0
  113. package/dist/src/platforms/webex/commands/space.d.ts +11 -0
  114. package/dist/src/platforms/webex/commands/space.d.ts.map +1 -0
  115. package/dist/src/platforms/webex/commands/space.js +59 -0
  116. package/dist/src/platforms/webex/commands/space.js.map +1 -0
  117. package/dist/src/platforms/webex/credential-manager.d.ts +23 -0
  118. package/dist/src/platforms/webex/credential-manager.d.ts.map +1 -0
  119. package/dist/src/platforms/webex/credential-manager.js +148 -0
  120. package/dist/src/platforms/webex/credential-manager.js.map +1 -0
  121. package/dist/src/platforms/webex/ensure-auth.d.ts +2 -0
  122. package/dist/src/platforms/webex/ensure-auth.d.ts.map +1 -0
  123. package/dist/src/platforms/webex/ensure-auth.js +36 -0
  124. package/dist/src/platforms/webex/ensure-auth.js.map +1 -0
  125. package/dist/src/platforms/webex/index.d.ts +8 -0
  126. package/dist/src/platforms/webex/index.d.ts.map +1 -0
  127. package/dist/src/platforms/webex/index.js +6 -0
  128. package/dist/src/platforms/webex/index.js.map +1 -0
  129. package/dist/src/platforms/webex/token-extractor.d.ts +28 -0
  130. package/dist/src/platforms/webex/token-extractor.d.ts.map +1 -0
  131. package/dist/src/platforms/webex/token-extractor.js +344 -0
  132. package/dist/src/platforms/webex/token-extractor.js.map +1 -0
  133. package/dist/src/platforms/webex/types.d.ts +127 -0
  134. package/dist/src/platforms/webex/types.d.ts.map +1 -0
  135. package/dist/src/platforms/webex/types.js +64 -0
  136. package/dist/src/platforms/webex/types.js.map +1 -0
  137. package/dist/src/platforms/whatsapp/client.d.ts.map +1 -1
  138. package/dist/src/platforms/whatsapp/client.js +6 -2
  139. package/dist/src/platforms/whatsapp/client.js.map +1 -1
  140. package/dist/src/shared/utils/derived-key-cache.d.ts +1 -1
  141. package/dist/src/shared/utils/derived-key-cache.d.ts.map +1 -1
  142. package/dist/src/shared/utils/error-handler.d.ts +1 -1
  143. package/dist/src/shared/utils/error-handler.d.ts.map +1 -1
  144. package/dist/src/shared/utils/error-handler.js +3 -2
  145. package/dist/src/shared/utils/error-handler.js.map +1 -1
  146. package/dist/src/shared/utils/stderr.d.ts +5 -0
  147. package/dist/src/shared/utils/stderr.d.ts.map +1 -0
  148. package/dist/src/shared/utils/stderr.js +18 -0
  149. package/dist/src/shared/utils/stderr.js.map +1 -0
  150. package/dist/src/tui/adapters/webex-adapter.d.ts +14 -0
  151. package/dist/src/tui/adapters/webex-adapter.d.ts.map +1 -0
  152. package/dist/src/tui/adapters/webex-adapter.js +79 -0
  153. package/dist/src/tui/adapters/webex-adapter.js.map +1 -0
  154. package/dist/src/tui/app.d.ts.map +1 -1
  155. package/dist/src/tui/app.js +2 -0
  156. package/dist/src/tui/app.js.map +1 -1
  157. package/docs/content/docs/cli/channeltalk.mdx +7 -7
  158. package/docs/content/docs/cli/discord.mdx +3 -3
  159. package/docs/content/docs/cli/instagram.mdx +28 -6
  160. package/docs/content/docs/cli/meta.json +1 -0
  161. package/docs/content/docs/cli/slack.mdx +2 -2
  162. package/docs/content/docs/cli/teams.mdx +6 -4
  163. package/docs/content/docs/cli/webex.mdx +310 -0
  164. package/docs/content/docs/sdk/meta.json +1 -1
  165. package/docs/content/docs/sdk/webex.mdx +260 -0
  166. package/docs/content/docs/tui.mdx +4 -3
  167. package/docs/src/app/page.tsx +2 -2
  168. package/e2e/README.md +132 -8
  169. package/e2e/channeltalk.e2e.test.ts +2 -7
  170. package/e2e/channeltalkbot.e2e.test.ts +2 -6
  171. package/e2e/config.ts +172 -10
  172. package/e2e/helpers.ts +7 -0
  173. package/e2e/instagram.e2e.test.ts +97 -0
  174. package/e2e/kakaotalk.e2e.test.ts +74 -0
  175. package/e2e/line.e2e.test.ts +92 -0
  176. package/e2e/teams.e2e.test.ts +46 -1
  177. package/e2e/telegram.e2e.test.ts +84 -0
  178. package/e2e/webex.e2e.test.ts +190 -0
  179. package/e2e/whatsapp.e2e.test.ts +90 -0
  180. package/e2e/whatsappbot.e2e.test.ts +78 -0
  181. package/package.json +11 -3
  182. package/skills/agent-channeltalk/SKILL.md +9 -9
  183. package/skills/agent-channeltalk/references/authentication.md +21 -18
  184. package/skills/agent-channeltalkbot/SKILL.md +1 -1
  185. package/skills/agent-discord/SKILL.md +5 -5
  186. package/skills/agent-discord/references/authentication.md +8 -8
  187. package/skills/agent-discordbot/SKILL.md +1 -1
  188. package/skills/agent-instagram/SKILL.md +51 -9
  189. package/skills/agent-instagram/references/authentication.md +35 -3
  190. package/skills/agent-kakaotalk/SKILL.md +1 -1
  191. package/skills/agent-line/SKILL.md +1 -1
  192. package/skills/agent-slack/SKILL.md +5 -5
  193. package/skills/agent-slack/references/authentication.md +8 -8
  194. package/skills/agent-slackbot/SKILL.md +1 -1
  195. package/skills/agent-teams/SKILL.md +6 -6
  196. package/skills/agent-teams/references/authentication.md +8 -8
  197. package/skills/agent-telegram/SKILL.md +1 -1
  198. package/skills/agent-webex/SKILL.md +406 -0
  199. package/skills/agent-webex/references/authentication.md +371 -0
  200. package/skills/agent-webex/references/common-patterns.md +726 -0
  201. package/skills/agent-webex/templates/monitor-space.sh +165 -0
  202. package/skills/agent-webex/templates/post-message.sh +170 -0
  203. package/skills/agent-whatsapp/SKILL.md +1 -1
  204. package/skills/agent-whatsappbot/SKILL.md +1 -1
  205. package/src/cli.ts +4 -0
  206. package/src/platforms/channeltalk/commands/auth.test.ts +5 -5
  207. package/src/platforms/channeltalk/commands/auth.ts +38 -32
  208. package/src/platforms/channeltalk/ensure-auth.test.ts +6 -6
  209. package/src/platforms/channeltalk/ensure-auth.ts +6 -6
  210. package/src/platforms/channeltalk/token-extractor.test.ts +182 -15
  211. package/src/platforms/channeltalk/token-extractor.ts +344 -30
  212. package/src/platforms/discord/commands/auth.test.ts +3 -3
  213. package/src/platforms/discord/commands/auth.ts +58 -54
  214. package/src/platforms/discord/ensure-auth.test.ts +3 -3
  215. package/src/platforms/discord/ensure-auth.ts +3 -3
  216. package/src/platforms/discord/token-extractor.test.ts +199 -27
  217. package/src/platforms/discord/token-extractor.ts +190 -17
  218. package/src/platforms/instagram/client.ts +2 -2
  219. package/src/platforms/instagram/commands/auth.ts +133 -14
  220. package/src/platforms/instagram/ensure-auth.ts +63 -12
  221. package/src/platforms/instagram/index.ts +1 -0
  222. package/src/platforms/instagram/token-extractor.test.ts +424 -0
  223. package/src/platforms/instagram/token-extractor.ts +478 -0
  224. package/src/platforms/kakaotalk/client.ts +3 -1
  225. package/src/platforms/kakaotalk/commands/auth.ts +14 -13
  226. package/src/platforms/kakaotalk/protocol/connection.ts +3 -1
  227. package/src/platforms/line/commands/auth.ts +7 -6
  228. package/src/platforms/slack/cli.test.ts +6 -5
  229. package/src/platforms/slack/commands/auth.test.ts +11 -7
  230. package/src/platforms/slack/commands/auth.ts +11 -10
  231. package/src/platforms/slack/token-extractor.test.ts +98 -1
  232. package/src/platforms/slack/token-extractor.ts +338 -26
  233. package/src/platforms/teams/commands/auth.ts +9 -8
  234. package/src/platforms/teams/ensure-auth.ts +3 -1
  235. package/src/platforms/teams/token-extractor.test.ts +136 -17
  236. package/src/platforms/teams/token-extractor.ts +182 -31
  237. package/src/platforms/telegram/client.test.ts +134 -0
  238. package/src/platforms/telegram/client.ts +27 -6
  239. package/src/platforms/telegram/commands/auth.ts +6 -5
  240. package/src/platforms/webex/app-config.test.ts +98 -0
  241. package/src/platforms/webex/app-config.ts +31 -0
  242. package/src/platforms/webex/cli.test.ts +58 -0
  243. package/src/platforms/webex/cli.ts +39 -0
  244. package/src/platforms/webex/client.test.ts +743 -0
  245. package/src/platforms/webex/client.ts +405 -0
  246. package/src/platforms/webex/commands/auth.test.ts +222 -0
  247. package/src/platforms/webex/commands/auth.ts +243 -0
  248. package/src/platforms/webex/commands/index.ts +5 -0
  249. package/src/platforms/webex/commands/member.test.ts +112 -0
  250. package/src/platforms/webex/commands/member.ts +45 -0
  251. package/src/platforms/webex/commands/message.test.ts +235 -0
  252. package/src/platforms/webex/commands/message.ts +204 -0
  253. package/src/platforms/webex/commands/snapshot.test.ts +105 -0
  254. package/src/platforms/webex/commands/snapshot.ts +91 -0
  255. package/src/platforms/webex/commands/space.test.ts +216 -0
  256. package/src/platforms/webex/commands/space.ts +74 -0
  257. package/src/platforms/webex/credential-manager.test.ts +314 -0
  258. package/src/platforms/webex/credential-manager.ts +197 -0
  259. package/src/platforms/webex/ensure-auth.test.ts +89 -0
  260. package/src/platforms/webex/ensure-auth.ts +38 -0
  261. package/src/platforms/webex/index.test.ts +25 -0
  262. package/src/platforms/webex/index.ts +19 -0
  263. package/src/platforms/webex/token-extractor.test.ts +327 -0
  264. package/src/platforms/webex/token-extractor.ts +393 -0
  265. package/src/platforms/webex/types.test.ts +307 -0
  266. package/src/platforms/webex/types.ts +129 -0
  267. package/src/platforms/whatsapp/client.ts +11 -7
  268. package/src/shared/utils/derived-key-cache.ts +1 -1
  269. package/src/shared/utils/error-handler.ts +4 -2
  270. package/src/shared/utils/stderr.ts +22 -0
  271. package/src/tui/adapters/webex-adapter.ts +103 -0
  272. package/src/tui/app.ts +2 -0
@@ -0,0 +1,314 @@
1
+ import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test'
2
+ import { mkdtemp, rm, writeFile } from 'node:fs/promises'
3
+ import { tmpdir } from 'node:os'
4
+ import { join } from 'node:path'
5
+
6
+ import { WebexCredentialManager } from './credential-manager'
7
+
8
+ describe('WebexCredentialManager', () => {
9
+ let tempDir: string
10
+ let credManager: WebexCredentialManager
11
+
12
+ beforeEach(async () => {
13
+ tempDir = await mkdtemp(join(tmpdir(), 'webex-cred-test-'))
14
+ credManager = new WebexCredentialManager(tempDir)
15
+ })
16
+
17
+ afterEach(async () => {
18
+ await rm(tempDir, { recursive: true, force: true })
19
+ mock.restore()
20
+ })
21
+
22
+ test('loadConfig returns null when no file exists', async () => {
23
+ expect(await credManager.loadConfig()).toBeNull()
24
+ })
25
+
26
+ test('saveConfig + loadConfig round trip with OAuth tokens', async () => {
27
+ const config = {
28
+ accessToken: 'test-access-token',
29
+ refreshToken: 'test-refresh-token',
30
+ expiresAt: Date.now() + 3600000,
31
+ }
32
+ await credManager.saveConfig(config)
33
+ const loaded = await credManager.loadConfig()
34
+ expect(loaded).toEqual(config)
35
+ })
36
+
37
+ test('getToken returns accessToken when not expired', async () => {
38
+ await credManager.saveConfig({
39
+ accessToken: 'valid-token',
40
+ refreshToken: 'refresh',
41
+ expiresAt: Date.now() + 3600000, // 1 hour from now
42
+ })
43
+ const token = await credManager.getToken()
44
+ expect(token).toBe('valid-token')
45
+ })
46
+
47
+ test('getToken returns null when expired and no refresh available', async () => {
48
+ await credManager.saveConfig({
49
+ accessToken: 'expired-token',
50
+ refreshToken: 'bad-refresh',
51
+ expiresAt: Date.now() - 1000, // Already expired
52
+ })
53
+ const token = await credManager.getToken()
54
+ expect(token).toBeNull()
55
+ })
56
+
57
+ test('getToken auto-refreshes expired token', async () => {
58
+ const originalFetch = globalThis.fetch
59
+ globalThis.fetch = mock(() =>
60
+ Promise.resolve(
61
+ new Response(
62
+ JSON.stringify({
63
+ access_token: 'new-access-token',
64
+ refresh_token: 'new-refresh-token',
65
+ expires_in: 3600,
66
+ }),
67
+ { status: 200 },
68
+ ),
69
+ ),
70
+ ) as typeof fetch
71
+
72
+ await credManager.saveConfig({
73
+ accessToken: 'expired-token',
74
+ refreshToken: 'valid-refresh',
75
+ expiresAt: Date.now() - 1000,
76
+ clientId: 'test-client-id',
77
+ clientSecret: 'test-client-secret',
78
+ })
79
+
80
+ const token = await credManager.getToken()
81
+ expect(token).toBe('new-access-token')
82
+
83
+ // Verify updated config was saved
84
+ const config = await credManager.loadConfig()
85
+ expect(config?.accessToken).toBe('new-access-token')
86
+ expect(config?.refreshToken).toBe('new-refresh-token')
87
+
88
+ globalThis.fetch = originalFetch
89
+ })
90
+
91
+ test('requestDeviceCode calls device authorize endpoint', async () => {
92
+ const originalFetch = globalThis.fetch
93
+ globalThis.fetch = mock(() =>
94
+ Promise.resolve(
95
+ new Response(
96
+ JSON.stringify({
97
+ device_code: 'device-123',
98
+ user_code: '123456',
99
+ verification_uri: 'https://login-k.webex.com/verify',
100
+ verification_uri_complete: 'https://login-k.webex.com/verify?userCode=abc',
101
+ expires_in: 300,
102
+ interval: 2,
103
+ }),
104
+ { status: 200 },
105
+ ),
106
+ ),
107
+ ) as typeof fetch
108
+
109
+ const result = await credManager.requestDeviceCode('test-client-id')
110
+ expect(result.deviceCode).toBe('device-123')
111
+ expect(result.userCode).toBe('123456')
112
+ expect(result.verificationUri).toBe('https://login-k.webex.com/verify')
113
+ expect(result.interval).toBe(2)
114
+
115
+ globalThis.fetch = originalFetch
116
+ })
117
+
118
+ test('requestDeviceCode throws on failure', async () => {
119
+ const originalFetch = globalThis.fetch
120
+ globalThis.fetch = mock(() =>
121
+ Promise.resolve(new Response('{"error":"invalid_client"}', { status: 400 })),
122
+ ) as typeof fetch
123
+
124
+ await expect(credManager.requestDeviceCode('test-client-id')).rejects.toThrow('Device authorization failed')
125
+
126
+ globalThis.fetch = originalFetch
127
+ })
128
+
129
+ test('pollDeviceToken polls until authorized', async () => {
130
+ const originalFetch = globalThis.fetch
131
+ let callCount = 0
132
+ globalThis.fetch = mock(() => {
133
+ callCount++
134
+ if (callCount <= 2) {
135
+ return Promise.resolve(new Response('', { status: 428 }))
136
+ }
137
+ return Promise.resolve(
138
+ new Response(
139
+ JSON.stringify({
140
+ access_token: 'device-access-token',
141
+ refresh_token: 'device-refresh-token',
142
+ expires_in: 3600,
143
+ }),
144
+ { status: 200 },
145
+ ),
146
+ )
147
+ }) as typeof fetch
148
+
149
+ const config = await credManager.pollDeviceToken('device-123', 0.01, 30, 'test-client-id', 'test-client-secret')
150
+ expect(config.accessToken).toBe('device-access-token')
151
+ expect(config.refreshToken).toBe('device-refresh-token')
152
+
153
+ globalThis.fetch = originalFetch
154
+ })
155
+
156
+ test('clearCredentials removes the file', async () => {
157
+ await credManager.saveConfig({
158
+ accessToken: 'token',
159
+ refreshToken: 'refresh',
160
+ expiresAt: Date.now() + 3600000,
161
+ })
162
+ await credManager.clearCredentials()
163
+ expect(await credManager.loadConfig()).toBeNull()
164
+ })
165
+
166
+ test('clearCredentials does nothing when no file', async () => {
167
+ await credManager.clearCredentials() // Should not throw
168
+ })
169
+
170
+ test('credentials file has 0o600 permissions', async () => {
171
+ await credManager.saveConfig({
172
+ accessToken: 'token',
173
+ refreshToken: 'refresh',
174
+ expiresAt: Date.now() + 3600000,
175
+ })
176
+ const { stat } = await import('node:fs/promises')
177
+ const credPath = join(tempDir, 'webex-credentials.json')
178
+ const stats = await stat(credPath)
179
+ const mode = stats.mode & 0o777
180
+ expect(mode).toBe(0o600)
181
+ })
182
+
183
+ test('pollDeviceToken with undefined clientSecret uses empty Basic auth', async () => {
184
+ const originalFetch = globalThis.fetch
185
+ let capturedAuth: string | null = null
186
+ globalThis.fetch = mock((url: string, init?: RequestInit) => {
187
+ capturedAuth = (init?.headers as Record<string, string>)?.Authorization ?? null
188
+ return Promise.resolve(
189
+ new Response(
190
+ JSON.stringify({
191
+ access_token: 'token',
192
+ refresh_token: 'refresh',
193
+ expires_in: 3600,
194
+ }),
195
+ { status: 200 },
196
+ ),
197
+ )
198
+ }) as typeof fetch
199
+
200
+ await credManager.pollDeviceToken('device-123', 0.01, 30, 'test-client-id')
201
+ expect(capturedAuth).toBe(`Basic ${btoa('test-client-id:')}`)
202
+
203
+ globalThis.fetch = originalFetch
204
+ })
205
+
206
+ test('pollDeviceToken does not auto-save config', async () => {
207
+ const originalFetch = globalThis.fetch
208
+ globalThis.fetch = mock(() =>
209
+ Promise.resolve(
210
+ new Response(
211
+ JSON.stringify({
212
+ access_token: 'token',
213
+ refresh_token: 'refresh',
214
+ expires_in: 3600,
215
+ }),
216
+ { status: 200 },
217
+ ),
218
+ ),
219
+ ) as typeof fetch
220
+
221
+ await credManager.pollDeviceToken('device-123', 0.01, 30, 'test-client-id', 'test-client-secret')
222
+
223
+ const loaded = await credManager.loadConfig()
224
+ expect(loaded).toBeNull()
225
+
226
+ globalThis.fetch = originalFetch
227
+ })
228
+
229
+ test('getToken returns null when expired and no client credentials available', async () => {
230
+ await credManager.saveConfig({
231
+ accessToken: 'expired-token',
232
+ refreshToken: 'valid-refresh',
233
+ expiresAt: Date.now() - 1000,
234
+ })
235
+
236
+ const token = await credManager.getToken()
237
+ expect(token).toBeNull()
238
+ })
239
+
240
+ test('getToken returns manual token without attempting refresh', async () => {
241
+ await credManager.saveConfig({
242
+ accessToken: 'my-bot-token',
243
+ refreshToken: '',
244
+ expiresAt: 0,
245
+ tokenType: 'manual',
246
+ })
247
+
248
+ const token = await credManager.getToken()
249
+ expect(token).toBe('my-bot-token')
250
+ })
251
+
252
+ test('getToken uses stored clientId/clientSecret for refresh', async () => {
253
+ const originalFetch = globalThis.fetch
254
+ globalThis.fetch = mock(() =>
255
+ Promise.resolve(
256
+ new Response(
257
+ JSON.stringify({
258
+ access_token: 'refreshed-token',
259
+ refresh_token: 'new-refresh',
260
+ expires_in: 3600,
261
+ }),
262
+ { status: 200 },
263
+ ),
264
+ ),
265
+ ) as typeof fetch
266
+
267
+ await credManager.saveConfig({
268
+ accessToken: 'expired-token',
269
+ refreshToken: 'valid-refresh',
270
+ expiresAt: Date.now() - 1000,
271
+ clientId: 'stored-client-id',
272
+ clientSecret: 'stored-client-secret',
273
+ })
274
+
275
+ const token = await credManager.getToken()
276
+ expect(token).toBe('refreshed-token')
277
+
278
+ globalThis.fetch = originalFetch
279
+ })
280
+
281
+ test('saveConfig persists clientId and clientSecret', async () => {
282
+ await credManager.saveConfig({
283
+ accessToken: 'token',
284
+ refreshToken: 'refresh',
285
+ expiresAt: Date.now() + 3600000,
286
+ clientId: 'my-client-id',
287
+ clientSecret: 'my-client-secret',
288
+ })
289
+
290
+ const loaded = await credManager.loadConfig()
291
+ expect(loaded?.clientId).toBe('my-client-id')
292
+ expect(loaded?.clientSecret).toBe('my-client-secret')
293
+ })
294
+
295
+ test('loadConfig backward compat — old config without clientId/clientSecret', async () => {
296
+ // Write raw JSON without clientId/clientSecret fields
297
+ const credPath = join(tempDir, 'webex-credentials.json')
298
+ await writeFile(
299
+ credPath,
300
+ JSON.stringify({
301
+ accessToken: 'old-token',
302
+ refreshToken: 'old-refresh',
303
+ expiresAt: Date.now() + 3600000,
304
+ }),
305
+ 'utf-8',
306
+ )
307
+
308
+ const loaded = await credManager.loadConfig()
309
+ expect(loaded).not.toBeNull()
310
+ expect(loaded?.accessToken).toBe('old-token')
311
+ expect(loaded?.clientId).toBeUndefined()
312
+ expect(loaded?.clientSecret).toBeUndefined()
313
+ })
314
+ })
@@ -0,0 +1,197 @@
1
+ import { existsSync } from 'node:fs'
2
+ import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises'
3
+ import { homedir } from 'node:os'
4
+ import { join } from 'node:path'
5
+
6
+ import { getWebexAppCredentials } from './app-config'
7
+ import type { WebexConfig } from './types'
8
+ import { WebexConfigSchema } from './types'
9
+
10
+ const OAUTH_DEVICE_AUTHORIZE_URL = 'https://webexapis.com/v1/device/authorize'
11
+ const OAUTH_DEVICE_TOKEN_URL = 'https://webexapis.com/v1/device/token'
12
+ const OAUTH_TOKEN_URL = 'https://webexapis.com/v1/access_token'
13
+ const OAUTH_SCOPES = 'spark:all'
14
+
15
+ export { OAUTH_SCOPES }
16
+
17
+ export class WebexCredentialManager {
18
+ private configDir: string
19
+ private credentialsPath: string
20
+
21
+ constructor(configDir?: string) {
22
+ this.configDir = configDir ?? join(homedir(), '.config', 'agent-messenger')
23
+ this.credentialsPath = join(this.configDir, 'webex-credentials.json')
24
+ }
25
+
26
+ async loadConfig(): Promise<WebexConfig | null> {
27
+ if (!existsSync(this.credentialsPath)) return null
28
+ const content = await readFile(this.credentialsPath, 'utf-8')
29
+ const result = WebexConfigSchema.safeParse(JSON.parse(content))
30
+ if (!result.success) return null
31
+ return result.data
32
+ }
33
+
34
+ async saveConfig(config: WebexConfig): Promise<void> {
35
+ await mkdir(this.configDir, { recursive: true })
36
+ const tmpPath = `${this.credentialsPath}.tmp`
37
+ await writeFile(tmpPath, JSON.stringify(config, null, 2), {
38
+ encoding: 'utf-8',
39
+ mode: 0o600,
40
+ })
41
+ await rename(tmpPath, this.credentialsPath)
42
+ }
43
+
44
+ async getToken(clientId?: string, clientSecret?: string): Promise<string | null> {
45
+ const config = await this.loadConfig()
46
+ if (!config) return null
47
+
48
+ if (config.tokenType === 'manual' || config.tokenType === 'extracted') {
49
+ return config.accessToken
50
+ }
51
+
52
+ if (config.expiresAt < Date.now() + 5 * 60 * 1000) {
53
+ const builtinCreds = getWebexAppCredentials()
54
+ const resolvedClientId = clientId ?? config.clientId ?? builtinCreds.clientId
55
+ const resolvedClientSecret = clientSecret ?? config.clientSecret ?? builtinCreds.clientSecret
56
+ const refreshed = await this.refreshToken(config.refreshToken, resolvedClientId, resolvedClientSecret)
57
+ if (refreshed) return refreshed.accessToken
58
+ return null
59
+ }
60
+
61
+ return config.accessToken
62
+ }
63
+
64
+ async refreshToken(refreshToken: string, clientId: string, clientSecret: string): Promise<WebexConfig | null> {
65
+ try {
66
+ const response = await fetch(OAUTH_TOKEN_URL, {
67
+ method: 'POST',
68
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
69
+ body: new URLSearchParams({
70
+ grant_type: 'refresh_token',
71
+ client_id: clientId,
72
+ client_secret: clientSecret,
73
+ refresh_token: refreshToken,
74
+ }),
75
+ })
76
+
77
+ if (!response.ok) return null
78
+
79
+ const data = (await response.json()) as {
80
+ access_token: string
81
+ refresh_token: string
82
+ expires_in: number
83
+ }
84
+
85
+ const config: WebexConfig = {
86
+ accessToken: data.access_token,
87
+ refreshToken: data.refresh_token,
88
+ expiresAt: Date.now() + data.expires_in * 1000,
89
+ }
90
+
91
+ await this.saveConfig(config)
92
+ return config
93
+ } catch {
94
+ return null
95
+ }
96
+ }
97
+
98
+ async requestDeviceCode(clientId: string, scopes?: string): Promise<{
99
+ deviceCode: string
100
+ userCode: string
101
+ verificationUri: string
102
+ verificationUriComplete: string
103
+ expiresIn: number
104
+ interval: number
105
+ }> {
106
+ const response = await fetch(OAUTH_DEVICE_AUTHORIZE_URL, {
107
+ method: 'POST',
108
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
109
+ body: new URLSearchParams({
110
+ client_id: clientId,
111
+ scope: scopes ?? OAUTH_SCOPES,
112
+ }),
113
+ })
114
+
115
+ if (!response.ok) {
116
+ const errorBody = await response.text()
117
+ throw new Error(`Device authorization failed: ${response.status} ${errorBody}`)
118
+ }
119
+
120
+ const data = (await response.json()) as {
121
+ device_code: string
122
+ user_code: string
123
+ verification_uri: string
124
+ verification_uri_complete: string
125
+ expires_in: number
126
+ interval: number
127
+ }
128
+
129
+ return {
130
+ deviceCode: data.device_code,
131
+ userCode: data.user_code,
132
+ verificationUri: data.verification_uri,
133
+ verificationUriComplete: data.verification_uri_complete,
134
+ expiresIn: data.expires_in,
135
+ interval: data.interval,
136
+ }
137
+ }
138
+
139
+ async pollDeviceToken(deviceCode: string, interval: number, expiresIn: number, clientId: string, clientSecret?: string): Promise<WebexConfig> {
140
+ const basicAuth = btoa(`${clientId}:${clientSecret ?? ''}`)
141
+ const deadline = Date.now() + expiresIn * 1000
142
+
143
+ while (Date.now() < deadline) {
144
+ await new Promise((resolve) => setTimeout(resolve, interval * 1000))
145
+
146
+ const response = await fetch(OAUTH_DEVICE_TOKEN_URL, {
147
+ method: 'POST',
148
+ headers: {
149
+ Authorization: `Basic ${basicAuth}`,
150
+ 'Content-Type': 'application/x-www-form-urlencoded',
151
+ },
152
+ body: new URLSearchParams({
153
+ grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
154
+ device_code: deviceCode,
155
+ client_id: clientId,
156
+ }),
157
+ })
158
+
159
+ if (response.ok) {
160
+ const data = (await response.json()) as {
161
+ access_token: string
162
+ refresh_token: string
163
+ expires_in: number
164
+ }
165
+
166
+ const config: WebexConfig = {
167
+ accessToken: data.access_token,
168
+ refreshToken: data.refresh_token,
169
+ expiresAt: Date.now() + data.expires_in * 1000,
170
+ }
171
+
172
+ return config
173
+ }
174
+
175
+ if (response.status === 428) continue
176
+
177
+ const errorBody = (await response.json().catch(() => null)) as {
178
+ errors?: Array<{ description: string }>
179
+ } | null
180
+ const errorDesc = errorBody?.errors?.[0]?.description ?? ''
181
+
182
+ if (errorDesc.includes('authorization_pending') || errorDesc.includes('slow_down')) {
183
+ continue
184
+ }
185
+
186
+ throw new Error(`Device token exchange failed: ${response.status} ${errorDesc}`)
187
+ }
188
+
189
+ throw new Error('Device authorization timed out')
190
+ }
191
+
192
+ async clearCredentials(): Promise<void> {
193
+ if (existsSync(this.credentialsPath)) {
194
+ await rm(this.credentialsPath)
195
+ }
196
+ }
197
+ }
@@ -0,0 +1,89 @@
1
+ import { afterEach, beforeEach, describe, expect, spyOn, test } from 'bun:test'
2
+
3
+ import { WebexClient } from './client'
4
+ import { WebexCredentialManager } from './credential-manager'
5
+ import { ensureWebexAuth } from './ensure-auth'
6
+ import { WebexTokenExtractor } from './token-extractor'
7
+
8
+ let loadConfigSpy: ReturnType<typeof spyOn>
9
+ let getTokenSpy: ReturnType<typeof spyOn>
10
+ let loginSpy: ReturnType<typeof spyOn>
11
+ let testAuthSpy: ReturnType<typeof spyOn>
12
+ let extractSpy: ReturnType<typeof spyOn>
13
+
14
+ beforeEach(() => {
15
+ loadConfigSpy = spyOn(WebexCredentialManager.prototype, 'loadConfig').mockResolvedValue(null)
16
+ getTokenSpy = spyOn(WebexCredentialManager.prototype, 'getToken').mockResolvedValue(null)
17
+ loginSpy = spyOn(WebexClient.prototype, 'login').mockResolvedValue({} as WebexClient)
18
+ testAuthSpy = spyOn(WebexClient.prototype, 'testAuth').mockResolvedValue({
19
+ id: 'user-123',
20
+ displayName: 'Test User',
21
+ emails: ['test@example.com'],
22
+ type: 'person',
23
+ })
24
+ extractSpy = spyOn(WebexTokenExtractor.prototype, 'extract').mockResolvedValue(null)
25
+ })
26
+
27
+ afterEach(() => {
28
+ loadConfigSpy?.mockRestore()
29
+ getTokenSpy?.mockRestore()
30
+ loginSpy?.mockRestore()
31
+ testAuthSpy?.mockRestore()
32
+ extractSpy?.mockRestore()
33
+ })
34
+
35
+ describe('ensureWebexAuth', () => {
36
+ test('does nothing when no config stored', async () => {
37
+ // given
38
+ loadConfigSpy.mockResolvedValue(null)
39
+
40
+ // when
41
+ await ensureWebexAuth()
42
+
43
+ // then
44
+ expect(getTokenSpy).not.toHaveBeenCalled()
45
+ expect(testAuthSpy).not.toHaveBeenCalled()
46
+ })
47
+
48
+ test('validates token when stored', async () => {
49
+ // given
50
+ loadConfigSpy.mockResolvedValue({
51
+ accessToken: 'test-webex-token',
52
+ refreshToken: 'refresh',
53
+ expiresAt: Date.now() + 3600000,
54
+ clientId: 'stored-id',
55
+ clientSecret: 'stored-secret',
56
+ })
57
+ getTokenSpy.mockResolvedValue('test-webex-token')
58
+
59
+ // when
60
+ await ensureWebexAuth()
61
+
62
+ // then
63
+ expect(getTokenSpy).toHaveBeenCalledWith('stored-id', 'stored-secret')
64
+ expect(loginSpy).toHaveBeenCalledWith({ token: 'test-webex-token' })
65
+ expect(testAuthSpy).toHaveBeenCalled()
66
+ })
67
+
68
+ test('does not throw when token validation fails', async () => {
69
+ // given
70
+ loadConfigSpy.mockResolvedValue({
71
+ accessToken: 'invalid-token',
72
+ refreshToken: 'refresh',
73
+ expiresAt: Date.now() + 3600000,
74
+ })
75
+ getTokenSpy.mockResolvedValue('invalid-token')
76
+ testAuthSpy.mockRejectedValue(new Error('401 Unauthorized'))
77
+
78
+ // when / then
79
+ await expect(ensureWebexAuth()).resolves.toBeUndefined()
80
+ })
81
+
82
+ test('does not throw when credential manager fails', async () => {
83
+ // given
84
+ getTokenSpy.mockRejectedValue(new Error('Disk read error'))
85
+
86
+ // when / then
87
+ await expect(ensureWebexAuth()).resolves.toBeUndefined()
88
+ })
89
+ })
@@ -0,0 +1,38 @@
1
+ import { WebexClient } from './client'
2
+ import { WebexCredentialManager } from './credential-manager'
3
+ import { WebexTokenExtractor } from './token-extractor'
4
+
5
+ export async function ensureWebexAuth(): Promise<void> {
6
+ try {
7
+ const credManager = new WebexCredentialManager()
8
+ const config = await credManager.loadConfig()
9
+
10
+ if (config) {
11
+ const token = await credManager.getToken(config.clientId, config.clientSecret)
12
+ if (token) {
13
+ const client = new WebexClient()
14
+ await client.login({ token })
15
+ await client.testAuth()
16
+ return
17
+ }
18
+ }
19
+
20
+ const extractor = new WebexTokenExtractor()
21
+ const extracted = await extractor.extract()
22
+ if (!extracted) return
23
+
24
+ const client = new WebexClient()
25
+ await client.login({ token: extracted.accessToken })
26
+ await client.testAuth()
27
+
28
+ await credManager.saveConfig({
29
+ accessToken: extracted.accessToken,
30
+ refreshToken: extracted.refreshToken ?? '',
31
+ expiresAt: extracted.expiresAt ?? 0,
32
+ tokenType: 'extracted',
33
+ deviceUrl: extracted.deviceUrl,
34
+ })
35
+ } catch {
36
+ // Intentionally silent — best-effort preflight that should not block commands
37
+ }
38
+ }
@@ -0,0 +1,25 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+
3
+ import * as webex from './index'
4
+
5
+ describe('webex barrel exports', () => {
6
+ test('exports WebexClient', () => {
7
+ expect(webex.WebexClient).toBeDefined()
8
+ })
9
+
10
+ test('exports WebexCredentialManager', () => {
11
+ expect(webex.WebexCredentialManager).toBeDefined()
12
+ })
13
+
14
+ test('exports WebexError', () => {
15
+ expect(webex.WebexError).toBeDefined()
16
+ })
17
+
18
+ test('exports Zod schemas', () => {
19
+ expect(webex.WebexSpaceSchema).toBeDefined()
20
+ expect(webex.WebexMessageSchema).toBeDefined()
21
+ expect(webex.WebexPersonSchema).toBeDefined()
22
+ expect(webex.WebexMembershipSchema).toBeDefined()
23
+ expect(webex.WebexConfigSchema).toBeDefined()
24
+ })
25
+ })
@@ -0,0 +1,19 @@
1
+ export { WebexClient } from './client'
2
+ export { WebexCredentialManager } from './credential-manager'
3
+ export { WebexTokenExtractor } from './token-extractor'
4
+ export type { ExtractedWebexToken } from './token-extractor'
5
+ export { WebexError } from './types'
6
+ export type {
7
+ WebexConfig,
8
+ WebexMembership,
9
+ WebexMessage,
10
+ WebexPerson,
11
+ WebexSpace,
12
+ } from './types'
13
+ export {
14
+ WebexConfigSchema,
15
+ WebexMembershipSchema,
16
+ WebexMessageSchema,
17
+ WebexPersonSchema,
18
+ WebexSpaceSchema,
19
+ } from './types'