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,405 @@
1
+ import type { WebexMembership, WebexMessage, WebexPerson, WebexSpace } from './types'
2
+ import { WebexError } from './types'
3
+ import { WebexCredentialManager } from './credential-manager'
4
+
5
+ const BASE_URL = 'https://webexapis.com/v1'
6
+ const MAX_RETRIES = 3
7
+ const BASE_BACKOFF_MS = 100
8
+
9
+ interface RateLimitBucket {
10
+ remaining: number
11
+ resetAt: number
12
+ }
13
+
14
+ export class WebexClient {
15
+ private token: string | null = null
16
+ private deviceUrl: string | null = null
17
+ private tokenType: string | null = null
18
+ private buckets: Map<string, RateLimitBucket> = new Map()
19
+ private globalRateLimitUntil: number = 0
20
+
21
+ async login(credentials?: { token: string }): Promise<this> {
22
+ if (credentials) {
23
+ if (!credentials.token) {
24
+ throw new WebexError('Token is required', 'missing_token')
25
+ }
26
+ this.token = credentials.token
27
+ return this
28
+ }
29
+
30
+ const { ensureWebexAuth } = await import('./ensure-auth')
31
+ await ensureWebexAuth()
32
+ const credManager = new WebexCredentialManager()
33
+ const config = await credManager.loadConfig()
34
+ const token = await credManager.getToken(config?.clientId, config?.clientSecret)
35
+ if (!token) {
36
+ throw new WebexError(
37
+ 'No Webex credentials found. Run "auth login" to authenticate.',
38
+ 'no_credentials',
39
+ )
40
+ }
41
+ this.deviceUrl = config?.deviceUrl ?? null
42
+ this.tokenType = config?.tokenType ?? null
43
+ return this.login({ token })
44
+ }
45
+
46
+ private ensureAuth(): string {
47
+ if (this.token === null) {
48
+ throw new WebexError('Not authenticated. Call .login() first.', 'not_authenticated')
49
+ }
50
+ return this.token
51
+ }
52
+
53
+ private getBucketKey(method: string, path: string): string {
54
+ const normalized = path.replace(
55
+ /\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}(?=\/|$)/gi,
56
+ '/{id}',
57
+ )
58
+ return `${method}:${normalized}`
59
+ }
60
+
61
+ private async waitForRateLimit(bucketKey: string): Promise<void> {
62
+ const now = Date.now()
63
+
64
+ if (this.globalRateLimitUntil > now) {
65
+ await this.sleep(this.globalRateLimitUntil - now)
66
+ }
67
+
68
+ const bucket = this.buckets.get(bucketKey)
69
+ if (bucket && bucket.remaining === 0 && bucket.resetAt * 1000 > now) {
70
+ await this.sleep(bucket.resetAt * 1000 - now)
71
+ }
72
+ }
73
+
74
+ private updateBucket(bucketKey: string, response: Response): void {
75
+ const remaining = response.headers.get('X-RateLimit-Remaining')
76
+ const reset = response.headers.get('X-RateLimit-Reset')
77
+
78
+ if (remaining !== null && reset !== null) {
79
+ this.buckets.set(bucketKey, {
80
+ remaining: parseInt(remaining, 10),
81
+ resetAt: parseFloat(reset),
82
+ })
83
+ }
84
+ }
85
+
86
+ private async handleRateLimitResponse(response: Response): Promise<number> {
87
+ const retryAfter = response.headers.get('Retry-After')
88
+ const waitMs = parseFloat(retryAfter || '1') * 1000
89
+
90
+ this.globalRateLimitUntil = Date.now() + waitMs
91
+ await this.sleep(waitMs)
92
+ return waitMs
93
+ }
94
+
95
+ private sleep(ms: number): Promise<void> {
96
+ return new Promise((resolve) => setTimeout(resolve, ms))
97
+ }
98
+
99
+ private async request<T>(method: string, path: string, body?: unknown): Promise<T> {
100
+ const url = `${BASE_URL}${path}`
101
+ const bucketKey = this.getBucketKey(method, path)
102
+
103
+ for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
104
+ await this.waitForRateLimit(bucketKey)
105
+
106
+ const options: RequestInit = {
107
+ method,
108
+ headers: {
109
+ Authorization: `Bearer ${this.ensureAuth()}`,
110
+ 'Content-Type': 'application/json',
111
+ },
112
+ }
113
+
114
+ if (body !== undefined) {
115
+ options.body = JSON.stringify(body)
116
+ }
117
+
118
+ const response = await fetch(url, options)
119
+ this.updateBucket(bucketKey, response)
120
+
121
+ if (response.status === 429) {
122
+ if (attempt < MAX_RETRIES) {
123
+ await this.handleRateLimitResponse(response)
124
+ continue
125
+ }
126
+ const errorBody = (await response.json().catch(() => null)) as {
127
+ message?: string
128
+ } | null
129
+ throw new WebexError(errorBody?.message ?? 'Rate limited', 'rate_limited')
130
+ }
131
+
132
+ if (response.status >= 500 && attempt < MAX_RETRIES) {
133
+ await this.sleep(BASE_BACKOFF_MS * 2 ** attempt)
134
+ continue
135
+ }
136
+
137
+ if (!response.ok) {
138
+ const errorBody = (await response.json().catch(() => null)) as {
139
+ message?: string
140
+ errors?: Array<{ description: string }>
141
+ trackingId?: string
142
+ } | null
143
+ const message =
144
+ errorBody?.message ??
145
+ errorBody?.errors?.[0]?.description ??
146
+ `HTTP ${response.status}`
147
+ throw new WebexError(message, `http_${response.status}`)
148
+ }
149
+
150
+ if (response.status === 204) {
151
+ return undefined as T
152
+ }
153
+
154
+ return response.json() as Promise<T>
155
+ }
156
+
157
+ throw new WebexError('Request failed after retries', 'max_retries')
158
+ }
159
+
160
+ async testAuth(): Promise<WebexPerson> {
161
+ return this.request<WebexPerson>('GET', '/people/me')
162
+ }
163
+
164
+ async listSpaces(options?: { type?: string; max?: number }): Promise<WebexSpace[]> {
165
+ const params = new URLSearchParams()
166
+ if (options?.type) params.set('type', options.type)
167
+ params.set('max', String(options?.max ?? 50))
168
+ const query = params.toString()
169
+ const data = await this.request<{ items: WebexSpace[] }>('GET', `/rooms?${query}`)
170
+ return data.items
171
+ }
172
+
173
+ async getSpace(spaceId: string): Promise<WebexSpace> {
174
+ return this.request<WebexSpace>('GET', `/rooms/${spaceId}`)
175
+ }
176
+
177
+ async sendMessage(
178
+ roomId: string,
179
+ text: string,
180
+ options?: { markdown?: boolean },
181
+ ): Promise<WebexMessage> {
182
+ if (this.useInternalAPI) {
183
+ return this.sendMessageInternal(roomId, text, options)
184
+ }
185
+ const body = options?.markdown ? { roomId, markdown: text } : { roomId, text }
186
+ return this.request<WebexMessage>('POST', '/messages', body)
187
+ }
188
+
189
+ private get useInternalAPI(): boolean {
190
+ return this.tokenType === 'extracted' && this.deviceUrl !== null
191
+ }
192
+
193
+ private get convBaseUrl(): string {
194
+ const match = this.deviceUrl?.match(/wdm(-[a-z0-9]+)\.wbx2\.com/)
195
+ return `https://conv${match?.[1] ?? ''}.wbx2.com/conversation/api/v1`
196
+ }
197
+
198
+ private get internalHeaders(): Record<string, string> {
199
+ return {
200
+ Authorization: `Bearer ${this.ensureAuth()}`,
201
+ 'Content-Type': 'application/json',
202
+ 'cisco-device-url': this.deviceUrl!,
203
+ }
204
+ }
205
+
206
+ private decodeConvUuid(roomId: string): string {
207
+ return Buffer.from(roomId, 'base64').toString('utf8').split('/').pop() ?? roomId
208
+ }
209
+
210
+ private async internalRequest<T>(path: string, init?: RequestInit): Promise<T> {
211
+ const response = await fetch(`${this.convBaseUrl}${path}`, {
212
+ ...init,
213
+ headers: { ...this.internalHeaders, ...init?.headers as Record<string, string> },
214
+ })
215
+
216
+ if (!response.ok) {
217
+ const errorBody = (await response.json().catch(() => null)) as { message?: string } | null
218
+ throw new WebexError(
219
+ errorBody?.message ?? `HTTP ${response.status}`,
220
+ `http_${response.status}`,
221
+ )
222
+ }
223
+
224
+ if (response.status === 204) return undefined as T
225
+ return response.json() as Promise<T>
226
+ }
227
+
228
+ private activityToMessage(a: InternalActivity, roomId: string): WebexMessage {
229
+ return {
230
+ id: a.id,
231
+ roomId,
232
+ roomType: 'group' as const,
233
+ text: a.object?.content ?? a.object?.displayName,
234
+ personId: a.actor?.entryUUID ?? a.actor?.id ?? '',
235
+ personEmail: a.actor?.emailAddress ?? '',
236
+ created: a.published,
237
+ }
238
+ }
239
+
240
+ private async sendMessageInternal(
241
+ roomId: string,
242
+ text: string,
243
+ options?: { markdown?: boolean },
244
+ ): Promise<WebexMessage> {
245
+ const convUuid = this.decodeConvUuid(roomId)
246
+ const object = options?.markdown
247
+ ? { objectType: 'comment', displayName: text, content: text, markdown: text }
248
+ : { objectType: 'comment', displayName: text, content: text }
249
+ const result = await this.internalRequest<InternalActivity>('/activities', {
250
+ method: 'POST',
251
+ body: JSON.stringify({
252
+ verb: 'post',
253
+ object,
254
+ target: { id: convUuid, objectType: 'conversation' },
255
+ clientTempId: `tmp-${Date.now()}`,
256
+ }),
257
+ })
258
+ return this.activityToMessage(result, roomId)
259
+ }
260
+
261
+ async sendDirectMessage(
262
+ personEmail: string,
263
+ text: string,
264
+ options?: { markdown?: boolean },
265
+ ): Promise<WebexMessage> {
266
+ if (this.useInternalAPI) {
267
+ const roomId = await this.findDirectRoomByEmail(personEmail)
268
+ if (!roomId) {
269
+ throw new WebexError(`No existing direct conversation with ${personEmail}`, 'not_found')
270
+ }
271
+ return this.sendMessageInternal(roomId, text, options)
272
+ }
273
+ const body = options?.markdown
274
+ ? { toPersonEmail: personEmail, markdown: text }
275
+ : { toPersonEmail: personEmail, text }
276
+ return this.request<WebexMessage>('POST', '/messages', body)
277
+ }
278
+
279
+ private async findDirectRoomByEmail(email: string): Promise<string | null> {
280
+ const rooms = await this.request<{ items: WebexSpace[] }>('GET', `/rooms?type=direct&max=100`)
281
+ for (const room of rooms.items) {
282
+ const members = await this.request<{ items: WebexMembership[] }>(
283
+ 'GET',
284
+ `/memberships?roomId=${room.id}&max=10`,
285
+ )
286
+ if (members.items.some((m) => m.personEmail === email)) {
287
+ return room.id
288
+ }
289
+ }
290
+ return null
291
+ }
292
+
293
+ async listMessages(roomId: string, options?: { max?: number }): Promise<WebexMessage[]> {
294
+ if (this.useInternalAPI) {
295
+ const convUuid = this.decodeConvUuid(roomId)
296
+ const max = options?.max ?? 50
297
+ const conv = await this.internalRequest<InternalConversation>(
298
+ `/conversations/${convUuid}?activitiesLimit=${max}&participantsLimit=0`,
299
+ )
300
+ return (conv.activities?.items ?? [])
301
+ .filter((a) => a.verb === 'post')
302
+ .map((a) => this.activityToMessage(a, roomId))
303
+ }
304
+ const params = new URLSearchParams()
305
+ params.set('roomId', roomId)
306
+ params.set('max', String(options?.max ?? 50))
307
+ const data = await this.request<{ items: WebexMessage[] }>('GET', `/messages?${params}`)
308
+ return data.items
309
+ }
310
+
311
+ async getMessage(messageId: string): Promise<WebexMessage> {
312
+ if (this.useInternalAPI) {
313
+ const activity = await this.internalRequest<InternalActivity>(`/activities/${messageId}`)
314
+ const convId = activity.target?.id ?? ''
315
+ const roomId = convId ? Buffer.from(`ciscospark://urn:TEAM:unknown/ROOM/${convId}`).toString('base64') : ''
316
+ return this.activityToMessage(activity, roomId)
317
+ }
318
+ return this.request<WebexMessage>('GET', `/messages/${messageId}`)
319
+ }
320
+
321
+ async deleteMessage(messageId: string): Promise<void> {
322
+ if (this.useInternalAPI) {
323
+ const activity = await this.internalRequest<InternalActivity>(`/activities/${messageId}`)
324
+ const convId = activity.target?.id
325
+ if (!convId) throw new WebexError('Cannot determine conversation for activity', 'internal_error')
326
+ await this.internalRequest<unknown>('/activities', {
327
+ method: 'POST',
328
+ body: JSON.stringify({
329
+ verb: 'delete',
330
+ object: { id: messageId, objectType: 'activity' },
331
+ target: { id: convId, objectType: 'conversation' },
332
+ }),
333
+ })
334
+ return
335
+ }
336
+ return this.request<void>('DELETE', `/messages/${messageId}`)
337
+ }
338
+
339
+ async editMessage(
340
+ messageId: string,
341
+ roomId: string,
342
+ text: string,
343
+ options?: { markdown?: boolean },
344
+ ): Promise<WebexMessage> {
345
+ if (this.useInternalAPI) {
346
+ const convUuid = this.decodeConvUuid(roomId)
347
+ const result = await this.internalRequest<InternalActivity>('/activities', {
348
+ method: 'POST',
349
+ body: JSON.stringify({
350
+ verb: 'post',
351
+ object: { objectType: 'comment', displayName: text, content: text },
352
+ target: { id: convUuid, objectType: 'conversation' },
353
+ parent: { id: messageId, type: 'edit' },
354
+ clientTempId: `tmp-${Date.now()}`,
355
+ }),
356
+ })
357
+ return this.activityToMessage(result, roomId)
358
+ }
359
+ const body = options?.markdown ? { roomId, markdown: text } : { roomId, text }
360
+ return this.request<WebexMessage>('PUT', `/messages/${messageId}`, body)
361
+ }
362
+
363
+ async listPeople(options?: {
364
+ email?: string
365
+ displayName?: string
366
+ max?: number
367
+ }): Promise<WebexPerson[]> {
368
+ const params = new URLSearchParams()
369
+ if (options?.email) params.set('email', options.email)
370
+ if (options?.displayName) params.set('displayName', options.displayName)
371
+ if (options?.max) params.set('max', String(options.max))
372
+ const query = params.toString()
373
+ const path = query ? `/people?${query}` : '/people'
374
+ const data = await this.request<{ items: WebexPerson[] }>('GET', path)
375
+ return data.items
376
+ }
377
+
378
+ async listMemberships(
379
+ roomId: string,
380
+ options?: { max?: number },
381
+ ): Promise<WebexMembership[]> {
382
+ const params = new URLSearchParams()
383
+ params.set('roomId', roomId)
384
+ if (options?.max) params.set('max', String(options.max))
385
+ const data = await this.request<{ items: WebexMembership[] }>(
386
+ 'GET',
387
+ `/memberships?${params}`,
388
+ )
389
+ return data.items
390
+ }
391
+ }
392
+
393
+ interface InternalActivity {
394
+ id: string
395
+ verb: string
396
+ actor?: { displayName?: string; emailAddress?: string; entryUUID?: string; id?: string }
397
+ object?: { content?: string; displayName?: string; objectType?: string }
398
+ target?: { id: string }
399
+ published: string
400
+ }
401
+
402
+ interface InternalConversation {
403
+ id: string
404
+ activities?: { items: InternalActivity[] }
405
+ }
@@ -0,0 +1,222 @@
1
+ import { afterEach, beforeEach, describe, expect, mock, spyOn, test } from 'bun:test'
2
+ import * as childProcess from 'node:child_process'
3
+
4
+ import { WebexClient } from '../client'
5
+ import { WebexCredentialManager } from '../credential-manager'
6
+ import { loginAction, logoutAction, statusAction } from './auth'
7
+
8
+ describe('auth commands', () => {
9
+ let consoleSpy: ReturnType<typeof spyOn>
10
+ let _consoleErrorSpy: ReturnType<typeof spyOn>
11
+ const mockPerson = {
12
+ id: 'person-1',
13
+ displayName: 'Test User',
14
+ emails: ['test@example.com'],
15
+ orgId: 'org-1',
16
+ type: 'person' as const,
17
+ created: '2024-01-01T00:00:00.000Z',
18
+ }
19
+
20
+ beforeEach(() => {
21
+ consoleSpy = spyOn(console, 'log').mockImplementation(() => {})
22
+ _consoleErrorSpy = spyOn(console, 'error').mockImplementation(() => {})
23
+ spyOn(childProcess, 'exec').mockImplementation((() => {}) as any)
24
+ })
25
+
26
+ afterEach(() => {
27
+ mock.restore()
28
+ })
29
+
30
+ describe('loginAction with --token', () => {
31
+ test('authenticates with provided token (bot token flow)', async () => {
32
+ spyOn(WebexClient.prototype, 'login').mockResolvedValue(new WebexClient())
33
+ spyOn(WebexClient.prototype, 'testAuth').mockResolvedValue(mockPerson)
34
+ spyOn(WebexCredentialManager.prototype, 'saveConfig').mockResolvedValue(undefined)
35
+
36
+ await loginAction({ token: 'bot-token-123', pretty: false })
37
+
38
+ expect(consoleSpy).toHaveBeenCalled()
39
+ const lastCall = consoleSpy.mock.calls[consoleSpy.mock.calls.length - 1][0] as string
40
+ const output = JSON.parse(lastCall)
41
+ expect(output.authenticated).toBe(true)
42
+ expect(output.user.displayName).toBe('Test User')
43
+ })
44
+
45
+ test('saves tokenType as manual with expiresAt 0', async () => {
46
+ spyOn(WebexClient.prototype, 'login').mockResolvedValue(new WebexClient())
47
+ spyOn(WebexClient.prototype, 'testAuth').mockResolvedValue(mockPerson)
48
+ const saveSpy = spyOn(WebexCredentialManager.prototype, 'saveConfig').mockResolvedValue(undefined)
49
+
50
+ await loginAction({ token: 'bot-token-123', pretty: false })
51
+
52
+ const savedConfig = saveSpy.mock.calls[0][0] as { tokenType: string; expiresAt: number; refreshToken: string }
53
+ expect(savedConfig.tokenType).toBe('manual')
54
+ expect(savedConfig.expiresAt).toBe(0)
55
+ expect(savedConfig.refreshToken).toBe('')
56
+ })
57
+ })
58
+
59
+ describe('loginAction with --client-id and --client-secret', () => {
60
+ test('uses provided credentials for Device Grant flow', async () => {
61
+ spyOn(WebexCredentialManager.prototype, 'requestDeviceCode').mockResolvedValue({
62
+ deviceCode: 'd',
63
+ userCode: 'u',
64
+ verificationUri: 'https://v',
65
+ verificationUriComplete: 'https://vc',
66
+ expiresIn: 300,
67
+ interval: 0.01,
68
+ })
69
+ spyOn(WebexCredentialManager.prototype, 'pollDeviceToken').mockResolvedValue({
70
+ accessToken: 'at',
71
+ refreshToken: 'rt',
72
+ expiresAt: Date.now() + 3600000,
73
+ })
74
+ spyOn(WebexCredentialManager.prototype, 'saveConfig').mockResolvedValue(undefined)
75
+ spyOn(WebexClient.prototype, 'login').mockResolvedValue(new WebexClient())
76
+ spyOn(WebexClient.prototype, 'testAuth').mockResolvedValue(mockPerson)
77
+
78
+ await loginAction({ clientId: 'my-id', clientSecret: 'my-secret', pretty: false })
79
+
80
+ expect(WebexCredentialManager.prototype.requestDeviceCode).toHaveBeenCalledWith('my-id')
81
+ expect(WebexCredentialManager.prototype.pollDeviceToken).toHaveBeenCalledWith('d', 0.01, 300, 'my-id', 'my-secret')
82
+ })
83
+
84
+ test('saves tokenType as oauth in config', async () => {
85
+ spyOn(WebexCredentialManager.prototype, 'requestDeviceCode').mockResolvedValue({
86
+ deviceCode: 'd',
87
+ userCode: 'u',
88
+ verificationUri: 'https://v',
89
+ verificationUriComplete: 'https://vc',
90
+ expiresIn: 300,
91
+ interval: 0.01,
92
+ })
93
+ spyOn(WebexCredentialManager.prototype, 'pollDeviceToken').mockResolvedValue({
94
+ accessToken: 'at',
95
+ refreshToken: 'rt',
96
+ expiresAt: Date.now() + 3600000,
97
+ })
98
+ const saveSpy = spyOn(WebexCredentialManager.prototype, 'saveConfig').mockResolvedValue(undefined)
99
+ spyOn(WebexClient.prototype, 'login').mockResolvedValue(new WebexClient())
100
+ spyOn(WebexClient.prototype, 'testAuth').mockResolvedValue(mockPerson)
101
+
102
+ await loginAction({ clientId: 'my-id', clientSecret: 'my-secret', pretty: false })
103
+
104
+ const savedConfig = saveSpy.mock.calls[0][0] as { tokenType: string }
105
+ expect(savedConfig.tokenType).toBe('oauth')
106
+ })
107
+
108
+ test('saves clientId and clientSecret in config', async () => {
109
+ spyOn(WebexCredentialManager.prototype, 'requestDeviceCode').mockResolvedValue({
110
+ deviceCode: 'd',
111
+ userCode: 'u',
112
+ verificationUri: 'https://v',
113
+ verificationUriComplete: 'https://vc',
114
+ expiresIn: 300,
115
+ interval: 0.01,
116
+ })
117
+ spyOn(WebexCredentialManager.prototype, 'pollDeviceToken').mockResolvedValue({
118
+ accessToken: 'at',
119
+ refreshToken: 'rt',
120
+ expiresAt: Date.now() + 3600000,
121
+ })
122
+ spyOn(WebexCredentialManager.prototype, 'saveConfig').mockResolvedValue(undefined)
123
+ spyOn(WebexClient.prototype, 'login').mockResolvedValue(new WebexClient())
124
+ spyOn(WebexClient.prototype, 'testAuth').mockResolvedValue(mockPerson)
125
+
126
+ await loginAction({ clientId: 'my-id', clientSecret: 'my-secret', pretty: false })
127
+
128
+ const savedConfig = (WebexCredentialManager.prototype.saveConfig as ReturnType<typeof spyOn>).mock.calls[0][0] as { clientId: string; clientSecret: string }
129
+ expect(savedConfig.clientId).toBe('my-id')
130
+ expect(savedConfig.clientSecret).toBe('my-secret')
131
+ })
132
+ })
133
+
134
+ describe('statusAction', () => {
135
+ test('shows authenticated status when token is valid', async () => {
136
+ spyOn(WebexCredentialManager.prototype, 'loadConfig').mockResolvedValue(null)
137
+ spyOn(WebexCredentialManager.prototype, 'getToken').mockResolvedValue('valid-token')
138
+ spyOn(WebexClient.prototype, 'login').mockResolvedValue(new WebexClient())
139
+ spyOn(WebexClient.prototype, 'testAuth').mockResolvedValue(mockPerson)
140
+
141
+ await statusAction({ pretty: false })
142
+
143
+ const lastCall = consoleSpy.mock.calls[consoleSpy.mock.calls.length - 1][0] as string
144
+ const output = JSON.parse(lastCall)
145
+ expect(output.authenticated).toBe(true)
146
+ expect(output.user.displayName).toBe('Test User')
147
+ })
148
+
149
+ test('shows not authenticated when no token', async () => {
150
+ spyOn(WebexCredentialManager.prototype, 'loadConfig').mockResolvedValue(null)
151
+ spyOn(WebexCredentialManager.prototype, 'getToken').mockResolvedValue(null)
152
+ const exitSpy = spyOn(process, 'exit').mockImplementation(() => undefined as never)
153
+
154
+ await statusAction({ pretty: false })
155
+
156
+ const lastCall = consoleSpy.mock.calls[consoleSpy.mock.calls.length - 1][0] as string
157
+ const output = JSON.parse(lastCall)
158
+ expect(output.error).toContain('Not authenticated')
159
+ expect(exitSpy).toHaveBeenCalledWith(1)
160
+ })
161
+
162
+ test('shows not authenticated when token validation fails', async () => {
163
+ spyOn(WebexCredentialManager.prototype, 'loadConfig').mockResolvedValue(null)
164
+ spyOn(WebexCredentialManager.prototype, 'getToken').mockResolvedValue('invalid-token')
165
+ spyOn(WebexClient.prototype, 'login').mockResolvedValue(new WebexClient())
166
+ spyOn(WebexClient.prototype, 'testAuth').mockRejectedValue(new Error('401 Unauthorized'))
167
+
168
+ await statusAction({ pretty: false })
169
+
170
+ const lastCall = consoleSpy.mock.calls[consoleSpy.mock.calls.length - 1][0] as string
171
+ const output = JSON.parse(lastCall)
172
+ expect(output.authenticated).toBe(false)
173
+ })
174
+
175
+ test('loads config for stored client credentials', async () => {
176
+ spyOn(WebexCredentialManager.prototype, 'loadConfig').mockResolvedValue({
177
+ accessToken: 'at',
178
+ refreshToken: 'rt',
179
+ expiresAt: Date.now() + 3600000,
180
+ clientId: 'stored-id',
181
+ clientSecret: 'stored-secret',
182
+ })
183
+ spyOn(WebexCredentialManager.prototype, 'getToken').mockResolvedValue('valid-token')
184
+ spyOn(WebexClient.prototype, 'login').mockResolvedValue(new WebexClient())
185
+ spyOn(WebexClient.prototype, 'testAuth').mockResolvedValue(mockPerson)
186
+
187
+ await statusAction({ pretty: false })
188
+
189
+ expect(WebexCredentialManager.prototype.getToken).toHaveBeenCalledWith('stored-id', 'stored-secret')
190
+ })
191
+ })
192
+
193
+ describe('logoutAction', () => {
194
+ test('clears credentials when authenticated', async () => {
195
+ spyOn(WebexCredentialManager.prototype, 'loadConfig').mockResolvedValue({
196
+ accessToken: 'token',
197
+ refreshToken: 'refresh',
198
+ expiresAt: Date.now() + 3600000,
199
+ })
200
+ const clearSpy = spyOn(WebexCredentialManager.prototype, 'clearCredentials').mockResolvedValue(undefined)
201
+
202
+ await logoutAction({ pretty: false })
203
+
204
+ expect(clearSpy).toHaveBeenCalled()
205
+ const lastCall = consoleSpy.mock.calls[consoleSpy.mock.calls.length - 1][0] as string
206
+ const output = JSON.parse(lastCall)
207
+ expect(output.success).toBe(true)
208
+ })
209
+
210
+ test('shows error when not authenticated', async () => {
211
+ spyOn(WebexCredentialManager.prototype, 'loadConfig').mockResolvedValue(null)
212
+ const exitSpy = spyOn(process, 'exit').mockImplementation(() => undefined as never)
213
+
214
+ await logoutAction({ pretty: false })
215
+
216
+ const lastCall = consoleSpy.mock.calls[consoleSpy.mock.calls.length - 1][0] as string
217
+ const output = JSON.parse(lastCall)
218
+ expect(output.error).toContain('Not authenticated')
219
+ expect(exitSpy).toHaveBeenCalledWith(1)
220
+ })
221
+ })
222
+ })