agent-messenger 2.21.0 → 2.23.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 (134) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +21 -0
  3. package/dist/package.json +1 -1
  4. package/dist/src/platforms/webex/client.d.ts +25 -0
  5. package/dist/src/platforms/webex/client.d.ts.map +1 -1
  6. package/dist/src/platforms/webex/client.js +115 -5
  7. package/dist/src/platforms/webex/client.js.map +1 -1
  8. package/dist/src/platforms/webex/commands/auth.d.ts +9 -1
  9. package/dist/src/platforms/webex/commands/auth.d.ts.map +1 -1
  10. package/dist/src/platforms/webex/commands/auth.js +141 -25
  11. package/dist/src/platforms/webex/commands/auth.js.map +1 -1
  12. package/dist/src/platforms/webex/credential-manager.d.ts.map +1 -1
  13. package/dist/src/platforms/webex/credential-manager.js +8 -4
  14. package/dist/src/platforms/webex/credential-manager.js.map +1 -1
  15. package/dist/src/platforms/webex/id-normalizer.d.ts +19 -0
  16. package/dist/src/platforms/webex/id-normalizer.d.ts.map +1 -0
  17. package/dist/src/platforms/webex/id-normalizer.js +60 -0
  18. package/dist/src/platforms/webex/id-normalizer.js.map +1 -0
  19. package/dist/src/platforms/webex/index.d.ts +4 -0
  20. package/dist/src/platforms/webex/index.d.ts.map +1 -1
  21. package/dist/src/platforms/webex/index.js +2 -0
  22. package/dist/src/platforms/webex/index.js.map +1 -1
  23. package/dist/src/platforms/webex/listener.d.ts +61 -0
  24. package/dist/src/platforms/webex/listener.d.ts.map +1 -0
  25. package/dist/src/platforms/webex/listener.js +222 -0
  26. package/dist/src/platforms/webex/listener.js.map +1 -0
  27. package/dist/src/platforms/webex/password-login.d.ts +18 -0
  28. package/dist/src/platforms/webex/password-login.d.ts.map +1 -0
  29. package/dist/src/platforms/webex/password-login.js +259 -0
  30. package/dist/src/platforms/webex/password-login.js.map +1 -0
  31. package/dist/src/platforms/webex/types.d.ts +2 -1
  32. package/dist/src/platforms/webex/types.d.ts.map +1 -1
  33. package/dist/src/platforms/webex/types.js +1 -1
  34. package/dist/src/platforms/webex/types.js.map +1 -1
  35. package/dist/src/platforms/webex/wdm-discovery.d.ts.map +1 -0
  36. package/dist/src/platforms/{webexbot → webex}/wdm-discovery.js +3 -3
  37. package/dist/src/platforms/webex/wdm-discovery.js.map +1 -0
  38. package/dist/src/platforms/webexbot/cli.d.ts.map +1 -1
  39. package/dist/src/platforms/webexbot/cli.js +4 -1
  40. package/dist/src/platforms/webexbot/cli.js.map +1 -1
  41. package/dist/src/platforms/webexbot/client.d.ts +24 -0
  42. package/dist/src/platforms/webexbot/client.d.ts.map +1 -1
  43. package/dist/src/platforms/webexbot/client.js +81 -5
  44. package/dist/src/platforms/webexbot/client.js.map +1 -1
  45. package/dist/src/platforms/webexbot/commands/file.d.ts +22 -0
  46. package/dist/src/platforms/webexbot/commands/file.d.ts.map +1 -0
  47. package/dist/src/platforms/webexbot/commands/file.js +64 -0
  48. package/dist/src/platforms/webexbot/commands/file.js.map +1 -0
  49. package/dist/src/platforms/webexbot/commands/index.d.ts +3 -0
  50. package/dist/src/platforms/webexbot/commands/index.d.ts.map +1 -1
  51. package/dist/src/platforms/webexbot/commands/index.js +3 -0
  52. package/dist/src/platforms/webexbot/commands/index.js.map +1 -1
  53. package/dist/src/platforms/webexbot/commands/message.d.ts +7 -0
  54. package/dist/src/platforms/webexbot/commands/message.d.ts.map +1 -1
  55. package/dist/src/platforms/webexbot/commands/message.js +52 -1
  56. package/dist/src/platforms/webexbot/commands/message.js.map +1 -1
  57. package/dist/src/platforms/webexbot/commands/snapshot.d.ts +24 -0
  58. package/dist/src/platforms/webexbot/commands/snapshot.d.ts.map +1 -0
  59. package/dist/src/platforms/webexbot/commands/snapshot.js +37 -0
  60. package/dist/src/platforms/webexbot/commands/snapshot.js.map +1 -0
  61. package/dist/src/platforms/webexbot/commands/user.d.ts +30 -0
  62. package/dist/src/platforms/webexbot/commands/user.d.ts.map +1 -0
  63. package/dist/src/platforms/webexbot/commands/user.js +66 -0
  64. package/dist/src/platforms/webexbot/commands/user.js.map +1 -0
  65. package/dist/src/platforms/webexbot/index.d.ts +2 -0
  66. package/dist/src/platforms/webexbot/index.d.ts.map +1 -1
  67. package/dist/src/platforms/webexbot/index.js +1 -0
  68. package/dist/src/platforms/webexbot/index.js.map +1 -1
  69. package/dist/src/platforms/webexbot/listener.d.ts +3 -41
  70. package/dist/src/platforms/webexbot/listener.d.ts.map +1 -1
  71. package/dist/src/platforms/webexbot/listener.js +13 -208
  72. package/dist/src/platforms/webexbot/listener.js.map +1 -1
  73. package/dist/src/platforms/webexbot/types.d.ts +1 -18
  74. package/dist/src/platforms/webexbot/types.d.ts.map +1 -1
  75. package/dist/src/platforms/webexbot/types.js.map +1 -1
  76. package/docs/content/docs/cli/webex.mdx +38 -12
  77. package/docs/content/docs/cli/webexbot.mdx +2 -0
  78. package/docs/content/docs/sdk/webexbot.mdx +18 -0
  79. package/package.json +1 -1
  80. package/skills/agent-channeltalk/SKILL.md +1 -1
  81. package/skills/agent-channeltalkbot/SKILL.md +1 -1
  82. package/skills/agent-discord/SKILL.md +1 -1
  83. package/skills/agent-discordbot/SKILL.md +1 -1
  84. package/skills/agent-instagram/SKILL.md +1 -1
  85. package/skills/agent-kakaotalk/SKILL.md +1 -1
  86. package/skills/agent-line/SKILL.md +1 -1
  87. package/skills/agent-slack/SKILL.md +1 -1
  88. package/skills/agent-slackbot/SKILL.md +1 -1
  89. package/skills/agent-teams/SKILL.md +1 -1
  90. package/skills/agent-telegram/SKILL.md +1 -1
  91. package/skills/agent-telegrambot/SKILL.md +1 -1
  92. package/skills/agent-webex/SKILL.md +76 -22
  93. package/skills/agent-webex/references/authentication.md +55 -14
  94. package/skills/agent-webex/references/common-patterns.md +5 -2
  95. package/skills/agent-webexbot/SKILL.md +60 -5
  96. package/skills/agent-webexbot/references/common-patterns.md +118 -0
  97. package/skills/agent-wechatbot/SKILL.md +1 -1
  98. package/skills/agent-whatsapp/SKILL.md +1 -1
  99. package/skills/agent-whatsappbot/SKILL.md +1 -1
  100. package/src/platforms/webex/cli.test.ts +31 -1
  101. package/src/platforms/webex/client.test.ts +67 -0
  102. package/src/platforms/webex/client.ts +136 -7
  103. package/src/platforms/webex/commands/auth.test.ts +189 -28
  104. package/src/platforms/webex/commands/auth.ts +194 -35
  105. package/src/platforms/webex/credential-manager.test.ts +40 -0
  106. package/src/platforms/webex/credential-manager.ts +7 -4
  107. package/src/platforms/webex/id-normalizer.test.ts +207 -0
  108. package/src/platforms/webex/id-normalizer.ts +76 -0
  109. package/src/platforms/webex/index.test.ts +6 -0
  110. package/src/platforms/webex/index.ts +4 -0
  111. package/src/platforms/webex/listener.test.ts +243 -0
  112. package/src/platforms/webex/listener.ts +285 -0
  113. package/src/platforms/webex/password-login.test.ts +193 -0
  114. package/src/platforms/webex/password-login.ts +332 -0
  115. package/src/platforms/webex/types.test.ts +16 -0
  116. package/src/platforms/webex/types.ts +2 -2
  117. package/src/platforms/{webexbot → webex}/wdm-discovery.ts +3 -3
  118. package/src/platforms/webexbot/cli.ts +6 -0
  119. package/src/platforms/webexbot/client.test.ts +322 -0
  120. package/src/platforms/webexbot/client.ts +104 -7
  121. package/src/platforms/webexbot/commands/file.ts +104 -0
  122. package/src/platforms/webexbot/commands/index.ts +3 -0
  123. package/src/platforms/webexbot/commands/message.ts +68 -2
  124. package/src/platforms/webexbot/commands/snapshot.ts +60 -0
  125. package/src/platforms/webexbot/commands/user.test.ts +77 -0
  126. package/src/platforms/webexbot/commands/user.ts +98 -0
  127. package/src/platforms/webexbot/index.ts +2 -0
  128. package/src/platforms/webexbot/listener.test.ts +37 -224
  129. package/src/platforms/webexbot/listener.ts +18 -250
  130. package/src/platforms/webexbot/types.ts +2 -23
  131. package/dist/src/platforms/webexbot/wdm-discovery.d.ts.map +0 -1
  132. package/dist/src/platforms/webexbot/wdm-discovery.js.map +0 -1
  133. /package/dist/src/platforms/{webexbot → webex}/wdm-discovery.d.ts +0 -0
  134. /package/src/platforms/{webexbot → webex}/wdm-discovery.test.ts +0 -0
@@ -0,0 +1,322 @@
1
+ import { afterEach, beforeEach, describe, expect, it, spyOn } from 'bun:test'
2
+
3
+ import { toRestId } from '../webex/id-normalizer'
4
+ import { WebexBotClient } from './client'
5
+
6
+ describe('WebexBotClient', () => {
7
+ const originalFetch = globalThis.fetch
8
+ let fetchCalls: Array<{ url: string; options?: RequestInit }> = []
9
+ let fetchResponses: Response[] = []
10
+ let fetchIndex = 0
11
+
12
+ beforeEach(() => {
13
+ fetchCalls = []
14
+ fetchResponses = []
15
+ fetchIndex = 0
16
+ ;(globalThis as { fetch: unknown }).fetch = async (
17
+ url: string | URL | Request,
18
+ options?: RequestInit,
19
+ ): Promise<Response> => {
20
+ fetchCalls.push({ url: url.toString(), options })
21
+ const response = fetchResponses[fetchIndex]
22
+ fetchIndex++
23
+ if (!response) {
24
+ throw new Error('No mock response configured')
25
+ }
26
+ return response
27
+ }
28
+ })
29
+
30
+ afterEach(() => {
31
+ globalThis.fetch = originalFetch
32
+ })
33
+
34
+ const mockResponse = (body: unknown, status = 200) => {
35
+ fetchResponses.push(
36
+ new Response(JSON.stringify(body), {
37
+ status,
38
+ headers: { 'Content-Type': 'application/json' },
39
+ }),
40
+ )
41
+ }
42
+
43
+ describe('listMessages', () => {
44
+ it('limits group-space history to messages that mention the bot', async () => {
45
+ mockResponse({ id: 'group-room', title: 'Team', type: 'group' })
46
+ mockResponse({ items: [{ id: 'msg-1', roomId: 'group-room', roomType: 'group' }] })
47
+
48
+ const client = await new WebexBotClient().login({ token: 'bot-token' })
49
+ const messages = await client.listMessages('group-room', { max: 5 })
50
+
51
+ expect(messages).toHaveLength(1)
52
+ expect(fetchCalls[0].url).toBe('https://webexapis.com/v1/rooms/group-room')
53
+
54
+ const messagesUrl = new URL(fetchCalls[1].url)
55
+ expect(messagesUrl.origin + messagesUrl.pathname).toBe('https://webexapis.com/v1/messages')
56
+ expect(messagesUrl.searchParams.get('roomId')).toBe('group-room')
57
+ expect(messagesUrl.searchParams.get('max')).toBe('5')
58
+ expect(messagesUrl.searchParams.get('mentionedPeople')).toBe('me')
59
+ })
60
+
61
+ it('does not add mentionedPeople for direct spaces', async () => {
62
+ mockResponse({ id: 'direct-room', title: 'DM', type: 'direct' })
63
+ mockResponse({ items: [{ id: 'msg-1', roomId: 'direct-room', roomType: 'direct' }] })
64
+
65
+ const client = await new WebexBotClient().login({ token: 'bot-token' })
66
+ await client.listMessages('direct-room', { max: 5 })
67
+
68
+ const messagesUrl = new URL(fetchCalls[1].url)
69
+ expect(messagesUrl.searchParams.get('roomId')).toBe('direct-room')
70
+ expect(messagesUrl.searchParams.get('mentionedPeople')).toBeNull()
71
+ })
72
+ })
73
+
74
+ describe('sendMessage threading', () => {
75
+ it('includes parentId in the request body when threading', async () => {
76
+ mockResponse({ id: 'msg-1', roomId: 'room-1', roomType: 'group' })
77
+
78
+ const client = await new WebexBotClient().login({ token: 'bot-token' })
79
+ await client.sendMessage('room-1', 'reply text', { parentId: 'parent-1' })
80
+
81
+ const body = JSON.parse(fetchCalls[0].options?.body as string)
82
+ expect(body.roomId).toBe('room-1')
83
+ expect(body.text).toBe('reply text')
84
+ expect(body.parentId).toBe('parent-1')
85
+ })
86
+ })
87
+
88
+ describe('listReplies', () => {
89
+ it('queries messages filtered by parentId', async () => {
90
+ mockResponse({ items: [{ id: 'reply-1', roomId: 'room-1', roomType: 'group', parentId: 'parent-1' }] })
91
+
92
+ const client = await new WebexBotClient().login({ token: 'bot-token' })
93
+ const replies = await client.listReplies('room-1', 'parent-1', { max: 10 })
94
+
95
+ expect(replies).toHaveLength(1)
96
+ const url = new URL(fetchCalls[0].url)
97
+ expect(url.searchParams.get('roomId')).toBe('room-1')
98
+ expect(url.searchParams.get('parentId')).toBe('parent-1')
99
+ expect(url.searchParams.get('max')).toBe('10')
100
+ })
101
+ })
102
+
103
+ describe('getPerson', () => {
104
+ it('fetches a person by id', async () => {
105
+ mockResponse({ id: 'person-1', emails: ['a@b.com'], displayName: 'Alice', type: 'person' })
106
+
107
+ const client = await new WebexBotClient().login({ token: 'bot-token' })
108
+ const person = await client.getPerson('person-1')
109
+
110
+ expect(person.displayName).toBe('Alice')
111
+ expect(fetchCalls[0].url).toBe('https://webexapis.com/v1/people/person-1')
112
+ })
113
+ })
114
+
115
+ describe('uploadFile', () => {
116
+ it('posts multipart form data to messages', async () => {
117
+ mockResponse({
118
+ id: 'msg-1',
119
+ roomId: 'room-1',
120
+ roomType: 'group',
121
+ files: ['https://webexapis.com/v1/contents/c1'],
122
+ })
123
+
124
+ const client = await new WebexBotClient().login({ token: 'bot-token' })
125
+ const message = await client.uploadFile(
126
+ 'room-1',
127
+ { content: new Blob(['hello']), filename: 'note.txt' },
128
+ { text: 'see attached' },
129
+ )
130
+
131
+ expect(message.files).toEqual(['https://webexapis.com/v1/contents/c1'])
132
+ expect(fetchCalls[0].url).toBe('https://webexapis.com/v1/messages')
133
+ expect(fetchCalls[0].options?.method).toBe('POST')
134
+ const body = fetchCalls[0].options?.body as FormData
135
+ expect(body).toBeInstanceOf(FormData)
136
+ expect(body.get('roomId')).toBe('room-1')
137
+ expect(body.get('text')).toBe('see attached')
138
+ })
139
+ })
140
+
141
+ describe('downloadContent', () => {
142
+ it('returns binary data with filename parsed from Content-Disposition', async () => {
143
+ fetchResponses.push(
144
+ new Response('binary-bytes', {
145
+ status: 200,
146
+ headers: {
147
+ 'Content-Disposition': 'attachment; filename="report.pdf"',
148
+ 'Content-Type': 'application/pdf',
149
+ },
150
+ }),
151
+ )
152
+
153
+ const client = await new WebexBotClient().login({ token: 'bot-token' })
154
+ const result = await client.downloadContent('https://webexapis.com/v1/contents/c1')
155
+
156
+ expect(result.filename).toBe('report.pdf')
157
+ expect(result.contentType).toBe('application/pdf')
158
+ expect(fetchCalls[0].url).toBe('https://webexapis.com/v1/contents/c1')
159
+ })
160
+
161
+ it('builds the contents URL from a bare content id', async () => {
162
+ fetchResponses.push(new Response('data', { status: 200, headers: {} }))
163
+
164
+ const client = await new WebexBotClient().login({ token: 'bot-token' })
165
+ const result = await client.downloadContent('abc123')
166
+
167
+ expect(fetchCalls[0].url).toBe('https://webexapis.com/v1/contents/abc123')
168
+ expect(result.filename).toBe('abc123')
169
+ })
170
+
171
+ it('refuses to download from a non-Webex host', async () => {
172
+ const client = await new WebexBotClient().login({ token: 'bot-token' })
173
+
174
+ await expect(client.downloadContent('https://attacker.example/file')).rejects.toThrow(/untrusted/i)
175
+ expect(fetchCalls).toHaveLength(0)
176
+ })
177
+
178
+ it('refuses to download over plain http from the Webex host', async () => {
179
+ const client = await new WebexBotClient().login({ token: 'bot-token' })
180
+
181
+ await expect(client.downloadContent('http://webexapis.com/v1/contents/c1')).rejects.toThrow(/untrusted/i)
182
+ expect(fetchCalls).toHaveLength(0)
183
+ })
184
+
185
+ it('sanitizes a path-traversal filename from Content-Disposition', async () => {
186
+ fetchResponses.push(
187
+ new Response('data', {
188
+ status: 200,
189
+ headers: { 'Content-Disposition': 'attachment; filename="../../etc/passwd"' },
190
+ }),
191
+ )
192
+
193
+ const client = await new WebexBotClient().login({ token: 'bot-token' })
194
+ const result = await client.downloadContent('https://webexapis.com/v1/contents/c1')
195
+
196
+ expect(result.filename).toBe('passwd')
197
+ })
198
+ })
199
+
200
+ describe('room cluster resolution', () => {
201
+ const roomUuid = '12345678-1234-1234-1234-1234567890ab'
202
+ const usRoomId = toRestId(roomUuid, 'ROOM')
203
+ const clusteredRoomId = Buffer.from(`ciscospark://urn:TEAM:us-west-2_r/ROOM/${roomUuid}`).toString('base64url')
204
+
205
+ const isRoomsList = (url: string) => new URL(url).pathname === '/v1/rooms'
206
+
207
+ const clusteredId = (uuid: string) =>
208
+ Buffer.from(`ciscospark://urn:TEAM:us-west-2_r/ROOM/${uuid}`).toString('base64url')
209
+
210
+ const mockRoomsPage = (items: unknown[], nextCursor?: string) => {
211
+ const headers: Record<string, string> = { 'Content-Type': 'application/json' }
212
+ if (nextCursor) headers.Link = `<https://webexapis.com/v1/rooms?max=1000&before=${nextCursor}>; rel="next"`
213
+ fetchResponses.push(new Response(JSON.stringify({ items }), { status: 200, headers }))
214
+ }
215
+
216
+ it('rewrites a us-cluster roomId to the real urn:TEAM id before sending', async () => {
217
+ // given a non-default-cluster room only reachable via its clustered id
218
+ mockResponse({ items: [{ id: clusteredRoomId, title: 'Team', type: 'group' }] })
219
+ mockResponse({ id: 'msg-1', roomId: clusteredRoomId, roomType: 'group' })
220
+
221
+ // when sending to the us-flattened id the listener emitted
222
+ const client = await new WebexBotClient().login({ token: 'bot-token' })
223
+ await client.sendMessage(usRoomId, 'hello')
224
+
225
+ // then the lookup runs and the send targets the corrected clustered id
226
+ expect(isRoomsList(fetchCalls[0].url)).toBe(true)
227
+ const body = JSON.parse(fetchCalls[1].options?.body as string)
228
+ expect(body.roomId).toBe(clusteredRoomId)
229
+ })
230
+
231
+ it('routes listMemberships through the corrected clustered id', async () => {
232
+ mockResponse({ items: [{ id: clusteredRoomId, type: 'group' }] })
233
+ mockResponse({ items: [{ id: 'm1', roomId: clusteredRoomId, personId: 'p1' }] })
234
+
235
+ const client = await new WebexBotClient().login({ token: 'bot-token' })
236
+ await client.listMemberships(usRoomId)
237
+
238
+ const membershipsUrl = new URL(fetchCalls[1].url)
239
+ expect(membershipsUrl.pathname).toBe('/v1/memberships')
240
+ expect(membershipsUrl.searchParams.get('roomId')).toBe(clusteredRoomId)
241
+ })
242
+
243
+ it('routes listMessages and its space lookup through the corrected clustered id', async () => {
244
+ mockResponse({ items: [{ id: clusteredRoomId, type: 'group' }] })
245
+ mockResponse({ id: clusteredRoomId, title: 'Team', type: 'group' })
246
+ mockResponse({ items: [{ id: 'msg-1', roomId: clusteredRoomId, roomType: 'group' }] })
247
+
248
+ const client = await new WebexBotClient().login({ token: 'bot-token' })
249
+ await client.listMessages(usRoomId, { max: 5 })
250
+
251
+ expect(fetchCalls[1].url).toBe(`https://webexapis.com/v1/rooms/${clusteredRoomId}`)
252
+ const messagesUrl = new URL(fetchCalls[2].url)
253
+ expect(messagesUrl.searchParams.get('roomId')).toBe(clusteredRoomId)
254
+ })
255
+
256
+ it('passes an already-clustered roomId through without a lookup', async () => {
257
+ mockResponse({ id: 'msg-1', roomId: clusteredRoomId, roomType: 'group' })
258
+
259
+ const client = await new WebexBotClient().login({ token: 'bot-token' })
260
+ await client.sendMessage(clusteredRoomId, 'hi')
261
+
262
+ expect(fetchCalls).toHaveLength(1)
263
+ expect(new URL(fetchCalls[0].url).pathname).toBe('/v1/messages')
264
+ expect(JSON.parse(fetchCalls[0].options?.body as string).roomId).toBe(clusteredRoomId)
265
+ })
266
+
267
+ it('caches the resolution so repeated calls trigger one room lookup', async () => {
268
+ mockResponse({ items: [{ id: clusteredRoomId, type: 'group' }] })
269
+ mockResponse({ id: 'msg-1', roomId: clusteredRoomId })
270
+ mockResponse({ id: 'msg-2', roomId: clusteredRoomId })
271
+
272
+ const client = await new WebexBotClient().login({ token: 'bot-token' })
273
+ await client.sendMessage(usRoomId, 'a')
274
+ await client.sendMessage(usRoomId, 'b')
275
+
276
+ expect(fetchCalls.filter((c) => isRoomsList(c.url))).toHaveLength(1)
277
+ })
278
+
279
+ it('follows Link pages until the room is found on a later page', async () => {
280
+ // given the matching room is only on the second page
281
+ mockRoomsPage([{ id: clusteredId('00000000-0000-0000-0000-000000000000'), type: 'group' }], 'cursor1')
282
+ mockRoomsPage([{ id: clusteredRoomId, type: 'group' }])
283
+ mockResponse({ id: 'msg-1', roomId: clusteredRoomId, roomType: 'group' })
284
+
285
+ // when sending to a room not present on the first page
286
+ const client = await new WebexBotClient().login({ token: 'bot-token' })
287
+ await client.sendMessage(usRoomId, 'hi')
288
+
289
+ // then the second page is fetched via the Link cursor and the send targets the match
290
+ expect(isRoomsList(fetchCalls[0].url)).toBe(true)
291
+ expect(isRoomsList(fetchCalls[1].url)).toBe(true)
292
+ expect(fetchCalls[1].url).toContain('before=cursor1')
293
+ expect(JSON.parse(fetchCalls[2].options?.body as string).roomId).toBe(clusteredRoomId)
294
+ })
295
+
296
+ it('stops paging once the room is found and does not fetch later pages', async () => {
297
+ // given a first page that already contains the match but still advertises a next page
298
+ mockRoomsPage([{ id: clusteredRoomId, type: 'group' }], 'cursor1')
299
+ mockResponse({ id: 'msg-1', roomId: clusteredRoomId, roomType: 'group' })
300
+
301
+ const client = await new WebexBotClient().login({ token: 'bot-token' })
302
+ await client.sendMessage(usRoomId, 'hi')
303
+
304
+ // then only the first page is fetched (a second page fetch would hit the unmocked response and throw)
305
+ expect(fetchCalls.filter((c) => isRoomsList(c.url))).toHaveLength(1)
306
+ expect(JSON.parse(fetchCalls[1].options?.body as string).roomId).toBe(clusteredRoomId)
307
+ })
308
+
309
+ it('fails open to the un-clustered id and warns when no room matches', async () => {
310
+ const warnSpy = spyOn(console, 'warn').mockImplementation(() => {})
311
+ mockResponse({ items: [] })
312
+ mockResponse({ id: 'msg-1', roomId: usRoomId })
313
+
314
+ const client = await new WebexBotClient().login({ token: 'bot-token' })
315
+ await expect(client.sendMessage(usRoomId, 'x')).resolves.toBeDefined()
316
+
317
+ expect(JSON.parse(fetchCalls[1].options?.body as string).roomId).toBe(usRoomId)
318
+ expect(warnSpy).toHaveBeenCalled()
319
+ warnSpy.mockRestore()
320
+ })
321
+ })
322
+ })
@@ -2,10 +2,33 @@ import { WebexClient } from '../webex/client'
2
2
  import type { WebexMembership, WebexMessage, WebexPerson, WebexSpace } from '../webex/types'
3
3
  import { WebexBotError } from './types'
4
4
 
5
+ interface DecodedWebexId {
6
+ cluster: string
7
+ type: string
8
+ uuid: string
9
+ }
10
+
11
+ // Webex REST ids are base64(url) of `ciscospark://<cluster>/<TYPE>/<uuid>`; the
12
+ // cluster correction needs all three parts, not just the <uuid> `fromRestId` returns.
13
+ function decodeWebexId(restId: string): DecodedWebexId | null {
14
+ if (!restId) return null
15
+ const decoded = Buffer.from(restId, 'base64').toString('utf-8')
16
+ const match = decoded.match(/^ciscospark:\/\/([^/]+)\/([^/]+)\/(.+)$/)
17
+ if (!match) return null
18
+ return { cluster: match[1], type: match[2], uuid: match[3] }
19
+ }
20
+
5
21
  export class WebexBotClient {
6
22
  private client = new WebexClient()
7
23
  private token: string | null = null
8
24
 
25
+ // The listener flattens room ids to `ciscospark://us/ROOM/<uuid>`, but team/group
26
+ // rooms live on `ciscospark://urn:TEAM:<cluster>/ROOM/<uuid>` — a cluster the bare
27
+ // uuid cannot recover. Cache the real clustered id per uuid and dedupe concurrent
28
+ // lookups so a burst of calls triggers a single `listSpaces`.
29
+ private clusteredRoomIds = new Map<string, string>()
30
+ private roomIdLookups = new Map<string, Promise<string>>()
31
+
9
32
  async login(credentials?: { token: string }): Promise<this> {
10
33
  if (credentials) {
11
34
  if (!credentials.token) {
@@ -41,22 +64,37 @@ export class WebexBotClient {
41
64
  }
42
65
 
43
66
  async getSpace(spaceId: string): Promise<WebexSpace> {
44
- return this.client.getSpace(spaceId)
67
+ return this.client.getSpace(await this.resolveRoomId(spaceId))
45
68
  }
46
69
 
47
- async sendMessage(roomId: string, text: string, options?: { markdown?: boolean }): Promise<WebexMessage> {
48
- return this.client.sendMessage(roomId, text, options)
70
+ async sendMessage(
71
+ roomId: string,
72
+ text: string,
73
+ options?: { markdown?: boolean; parentId?: string; files?: string[] },
74
+ ): Promise<WebexMessage> {
75
+ return this.client.sendMessage(await this.resolveRoomId(roomId), text, options)
49
76
  }
50
77
 
51
78
  async sendDirectMessage(personEmail: string, text: string, options?: { markdown?: boolean }): Promise<WebexMessage> {
52
79
  return this.client.sendDirectMessage(personEmail, text, options)
53
80
  }
54
81
 
55
- async listMessages(roomId: string, options?: { max?: number }): Promise<WebexMessage[]> {
56
- return this.client.listMessages(roomId, options)
82
+ async listMessages(roomId: string, options?: { max?: number; parentId?: string }): Promise<WebexMessage[]> {
83
+ const resolvedRoomId = await this.resolveRoomId(roomId)
84
+ const space = await this.client.getSpace(resolvedRoomId)
85
+ const messageOptions = space.type === 'group' ? { ...options, mentionedPeople: 'me' } : options
86
+ return this.client.listMessages(resolvedRoomId, messageOptions)
87
+ }
88
+
89
+ async listReplies(roomId: string, parentId: string, options?: { max?: number }): Promise<WebexMessage[]> {
90
+ return this.client.listMessages(await this.resolveRoomId(roomId), { ...options, parentId })
57
91
  }
58
92
 
59
93
  async getMessage(messageId: string): Promise<WebexMessage> {
94
+ // MESSAGE ids carry their parent room's cluster, which the bare-UUID normalizer
95
+ // also flattens to `us`. Correcting that needs the room context, which a lone
96
+ // messageId does not provide; room-keyed calls (the reported failures) are
97
+ // corrected via resolveRoomId instead.
60
98
  return this.client.getMessage(messageId)
61
99
  }
62
100
 
@@ -70,18 +108,77 @@ export class WebexBotClient {
70
108
  text: string,
71
109
  options?: { markdown?: boolean },
72
110
  ): Promise<WebexMessage> {
73
- return this.client.editMessage(messageId, roomId, text, options)
111
+ return this.client.editMessage(messageId, await this.resolveRoomId(roomId), text, options)
74
112
  }
75
113
 
76
114
  async listPeople(options?: { email?: string; displayName?: string; max?: number }): Promise<WebexPerson[]> {
77
115
  return this.client.listPeople(options)
78
116
  }
79
117
 
118
+ async getPerson(personId: string): Promise<WebexPerson> {
119
+ return this.client.getPerson(personId)
120
+ }
121
+
80
122
  async listMyMemberships(options?: { max?: number }): Promise<WebexMembership[]> {
81
123
  return this.client.listMyMemberships(options)
82
124
  }
83
125
 
84
126
  async listMemberships(roomId: string, options?: { max?: number }): Promise<WebexMembership[]> {
85
- return this.client.listMemberships(roomId, options)
127
+ return this.client.listMemberships(await this.resolveRoomId(roomId), options)
128
+ }
129
+
130
+ async uploadFile(
131
+ roomId: string,
132
+ file: { content: Blob; filename: string },
133
+ options?: { text?: string; markdown?: boolean; parentId?: string },
134
+ ): Promise<WebexMessage> {
135
+ return this.client.uploadFile(await this.resolveRoomId(roomId), file, options)
136
+ }
137
+
138
+ async downloadContent(contentRef: string): Promise<{ data: ArrayBuffer; filename: string; contentType: string }> {
139
+ return this.client.downloadContent(contentRef)
140
+ }
141
+
142
+ private async resolveRoomId(roomId: string): Promise<string> {
143
+ const decoded = decodeWebexId(roomId)
144
+ // Already cluster-qualified or undecodable: nothing to correct.
145
+ if (!decoded || decoded.cluster.startsWith('urn:')) return roomId
146
+
147
+ const { uuid } = decoded
148
+ const cached = this.clusteredRoomIds.get(uuid)
149
+ if (cached) return cached
150
+
151
+ const inFlight = this.roomIdLookups.get(uuid)
152
+ if (inFlight) return inFlight
153
+
154
+ const lookup = this.lookupRoomId(uuid, roomId)
155
+ this.roomIdLookups.set(uuid, lookup)
156
+ try {
157
+ return await lookup
158
+ } finally {
159
+ this.roomIdLookups.delete(uuid)
160
+ }
161
+ }
162
+
163
+ private async lookupRoomId(uuid: string, fallback: string): Promise<string> {
164
+ try {
165
+ // Page through every room the bot belongs to (largest page size, following
166
+ // `Link` pages), stopping as soon as the trailing UUID matches.
167
+ for await (const room of this.client.iterateSpaces({ max: 1000 })) {
168
+ if (decodeWebexId(room.id)?.uuid === uuid) {
169
+ this.clusteredRoomIds.set(uuid, room.id)
170
+ return room.id
171
+ }
172
+ }
173
+ } catch {
174
+ // Network/auth failure: fail open to the un-corrected id rather than block the call.
175
+ return fallback
176
+ }
177
+
178
+ console.warn(
179
+ `[webexbot] Could not resolve clustered room id for ${uuid}; falling back to the un-clustered id. ` +
180
+ 'Room-scoped calls may fail if this room lives on a non-default Webex cluster.',
181
+ )
182
+ return fallback
86
183
  }
87
184
  }
@@ -0,0 +1,104 @@
1
+ import { readFile, writeFile } from 'node:fs/promises'
2
+ import { basename, resolve } from 'node:path'
3
+
4
+ import { Command } from 'commander'
5
+
6
+ import { cliOutput } from '@/shared/utils/cli-output'
7
+
8
+ import type { BotOption } from './shared'
9
+ import { getClient } from './shared'
10
+
11
+ interface FileResult {
12
+ id?: string
13
+ roomId?: string
14
+ files?: string[]
15
+ created?: string
16
+ downloaded?: string
17
+ filename?: string
18
+ contentType?: string
19
+ size?: number
20
+ error?: string
21
+ }
22
+
23
+ export async function uploadAction(
24
+ space: string,
25
+ path: string,
26
+ options: BotOption & { text?: string; markdown?: boolean; parent?: string },
27
+ ): Promise<FileResult> {
28
+ try {
29
+ const client = await getClient(options)
30
+ const filePath = resolve(path)
31
+ const content = await readFile(filePath)
32
+ const message = await client.uploadFile(
33
+ space,
34
+ { content: new Blob([content]), filename: basename(filePath) },
35
+ { text: options.text, markdown: options.markdown, parentId: options.parent },
36
+ )
37
+
38
+ return {
39
+ id: message.id,
40
+ roomId: message.roomId,
41
+ files: message.files,
42
+ created: message.created,
43
+ }
44
+ } catch (error) {
45
+ return { error: (error as Error).message }
46
+ }
47
+ }
48
+
49
+ export async function downloadAction(
50
+ contentRef: string,
51
+ output: string | undefined,
52
+ options: BotOption,
53
+ ): Promise<FileResult> {
54
+ try {
55
+ const client = await getClient(options)
56
+ const { data, filename, contentType } = await client.downloadContent(contentRef)
57
+ // When no explicit output is given, confine the server-provided name to cwd.
58
+ const outputPath = output ? resolve(output) : resolve(process.cwd(), basename(filename))
59
+ await writeFile(outputPath, Buffer.from(data))
60
+
61
+ return {
62
+ downloaded: outputPath,
63
+ filename,
64
+ contentType,
65
+ size: data.byteLength,
66
+ }
67
+ } catch (error) {
68
+ return { error: (error as Error).message }
69
+ }
70
+ }
71
+
72
+ export const fileCommand = new Command('file')
73
+ .description('File commands')
74
+ .addCommand(
75
+ new Command('upload')
76
+ .description('Upload a local file to a space')
77
+ .argument('<space>', 'Space/Room ID')
78
+ .argument('<path>', 'Local file path')
79
+ .option('--text <text>', 'Optional message to send with the file')
80
+ .option('--markdown', 'Treat --text as markdown')
81
+ .option('--parent <id>', 'Reply within a thread (parent message ID)')
82
+ .option('--bot <id>', 'Use specific bot')
83
+ .option('--pretty', 'Pretty print JSON output')
84
+ .action(
85
+ async (
86
+ space: string,
87
+ path: string,
88
+ opts: BotOption & { text?: string; markdown?: boolean; parent?: string },
89
+ ) => {
90
+ cliOutput(await uploadAction(space, path, opts), opts.pretty)
91
+ },
92
+ ),
93
+ )
94
+ .addCommand(
95
+ new Command('download')
96
+ .description('Download a file attachment by content URL or ID')
97
+ .argument('<content>', 'File content URL (from message.files) or content ID')
98
+ .argument('[output]', 'Output path (defaults to original filename)')
99
+ .option('--bot <id>', 'Use specific bot')
100
+ .option('--pretty', 'Pretty print JSON output')
101
+ .action(async (content: string, output: string | undefined, opts: BotOption) => {
102
+ cliOutput(await downloadAction(content, output, opts), opts.pretty)
103
+ }),
104
+ )
@@ -1,6 +1,9 @@
1
1
  export { authCommand } from './auth'
2
+ export { fileCommand } from './file'
2
3
  export { listenCommand } from './listen'
3
4
  export { memberCommand } from './member'
4
5
  export { messageCommand } from './message'
6
+ export { snapshotCommand } from './snapshot'
5
7
  export { spaceCommand } from './space'
8
+ export { userCommand } from './user'
6
9
  export { whoamiCommand } from './whoami'