agent-messenger 2.0.0 → 2.1.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.
- package/.claude-plugin/marketplace.json +14 -1
- package/.claude-plugin/plugin.json +4 -2
- package/README.md +33 -29
- package/dist/package.json +10 -2
- package/dist/src/cli.d.ts.map +1 -1
- package/dist/src/cli.js +3 -0
- package/dist/src/cli.js.map +1 -1
- package/dist/src/platforms/webex/app-config.d.ts +7 -0
- package/dist/src/platforms/webex/app-config.d.ts.map +1 -0
- package/dist/src/platforms/webex/app-config.js +20 -0
- package/dist/src/platforms/webex/app-config.js.map +1 -0
- package/dist/src/platforms/webex/cli.d.ts +5 -0
- package/dist/src/platforms/webex/cli.d.ts.map +1 -0
- package/dist/src/platforms/webex/cli.js +32 -0
- package/dist/src/platforms/webex/cli.js.map +1 -0
- package/dist/src/platforms/webex/client.d.ts +45 -0
- package/dist/src/platforms/webex/client.d.ts.map +1 -0
- package/dist/src/platforms/webex/client.js +175 -0
- package/dist/src/platforms/webex/client.js.map +1 -0
- package/dist/src/platforms/webex/commands/auth.d.ts +15 -0
- package/dist/src/platforms/webex/commands/auth.d.ts.map +1 -0
- package/dist/src/platforms/webex/commands/auth.js +124 -0
- package/dist/src/platforms/webex/commands/auth.js.map +1 -0
- package/dist/src/platforms/webex/commands/index.d.ts +6 -0
- package/dist/src/platforms/webex/commands/index.d.ts.map +1 -0
- package/dist/src/platforms/webex/commands/index.js +6 -0
- package/dist/src/platforms/webex/commands/index.js.map +1 -0
- package/dist/src/platforms/webex/commands/member.d.ts +7 -0
- package/dist/src/platforms/webex/commands/member.d.ts.map +1 -0
- package/dist/src/platforms/webex/commands/member.js +34 -0
- package/dist/src/platforms/webex/commands/member.js.map +1 -0
- package/dist/src/platforms/webex/commands/message.d.ts +26 -0
- package/dist/src/platforms/webex/commands/message.d.ts.map +1 -0
- package/dist/src/platforms/webex/commands/message.js +153 -0
- package/dist/src/platforms/webex/commands/message.js.map +1 -0
- package/dist/src/platforms/webex/commands/snapshot.d.ts +9 -0
- package/dist/src/platforms/webex/commands/snapshot.d.ts.map +1 -0
- package/dist/src/platforms/webex/commands/snapshot.js +72 -0
- package/dist/src/platforms/webex/commands/snapshot.js.map +1 -0
- package/dist/src/platforms/webex/commands/space.d.ts +11 -0
- package/dist/src/platforms/webex/commands/space.d.ts.map +1 -0
- package/dist/src/platforms/webex/commands/space.js +59 -0
- package/dist/src/platforms/webex/commands/space.js.map +1 -0
- package/dist/src/platforms/webex/credential-manager.d.ts +23 -0
- package/dist/src/platforms/webex/credential-manager.d.ts.map +1 -0
- package/dist/src/platforms/webex/credential-manager.js +148 -0
- package/dist/src/platforms/webex/credential-manager.js.map +1 -0
- package/dist/src/platforms/webex/ensure-auth.d.ts +2 -0
- package/dist/src/platforms/webex/ensure-auth.d.ts.map +1 -0
- package/dist/src/platforms/webex/ensure-auth.js +20 -0
- package/dist/src/platforms/webex/ensure-auth.js.map +1 -0
- package/dist/src/platforms/webex/index.d.ts +6 -0
- package/dist/src/platforms/webex/index.d.ts.map +1 -0
- package/dist/src/platforms/webex/index.js +5 -0
- package/dist/src/platforms/webex/index.js.map +1 -0
- package/dist/src/platforms/webex/types.d.ts +124 -0
- package/dist/src/platforms/webex/types.d.ts.map +1 -0
- package/dist/src/platforms/webex/types.js +63 -0
- package/dist/src/platforms/webex/types.js.map +1 -0
- package/dist/src/tui/adapters/webex-adapter.d.ts +14 -0
- package/dist/src/tui/adapters/webex-adapter.d.ts.map +1 -0
- package/dist/src/tui/adapters/webex-adapter.js +79 -0
- package/dist/src/tui/adapters/webex-adapter.js.map +1 -0
- package/dist/src/tui/app.d.ts.map +1 -1
- package/dist/src/tui/app.js +2 -0
- package/dist/src/tui/app.js.map +1 -1
- package/docs/content/docs/cli/meta.json +1 -0
- package/docs/content/docs/cli/webex.mdx +291 -0
- package/docs/content/docs/sdk/meta.json +1 -1
- package/docs/content/docs/sdk/webex.mdx +260 -0
- package/docs/content/docs/tui.mdx +4 -3
- package/docs/src/app/page.tsx +2 -2
- package/package.json +10 -2
- package/skills/agent-channeltalk/SKILL.md +1 -1
- package/skills/agent-channeltalkbot/SKILL.md +1 -1
- package/skills/agent-discord/SKILL.md +1 -1
- package/skills/agent-discordbot/SKILL.md +1 -1
- package/skills/agent-instagram/SKILL.md +1 -1
- package/skills/agent-kakaotalk/SKILL.md +1 -1
- package/skills/agent-line/SKILL.md +1 -1
- package/skills/agent-slack/SKILL.md +1 -1
- package/skills/agent-slackbot/SKILL.md +1 -1
- package/skills/agent-teams/SKILL.md +1 -1
- package/skills/agent-telegram/SKILL.md +1 -1
- package/skills/agent-webex/SKILL.md +386 -0
- package/skills/agent-webex/references/authentication.md +318 -0
- package/skills/agent-webex/references/common-patterns.md +723 -0
- package/skills/agent-webex/templates/monitor-space.sh +165 -0
- package/skills/agent-webex/templates/post-message.sh +170 -0
- package/skills/agent-whatsapp/SKILL.md +1 -1
- package/skills/agent-whatsappbot/SKILL.md +1 -1
- package/src/cli.ts +4 -0
- package/src/platforms/webex/app-config.test.ts +98 -0
- package/src/platforms/webex/app-config.ts +31 -0
- package/src/platforms/webex/cli.test.ts +58 -0
- package/src/platforms/webex/cli.ts +39 -0
- package/src/platforms/webex/client.test.ts +429 -0
- package/src/platforms/webex/client.ts +247 -0
- package/src/platforms/webex/commands/auth.test.ts +222 -0
- package/src/platforms/webex/commands/auth.ts +180 -0
- package/src/platforms/webex/commands/index.ts +5 -0
- package/src/platforms/webex/commands/member.test.ts +103 -0
- package/src/platforms/webex/commands/member.ts +45 -0
- package/src/platforms/webex/commands/message.test.ts +231 -0
- package/src/platforms/webex/commands/message.ts +204 -0
- package/src/platforms/webex/commands/snapshot.test.ts +96 -0
- package/src/platforms/webex/commands/snapshot.ts +91 -0
- package/src/platforms/webex/commands/space.test.ts +206 -0
- package/src/platforms/webex/commands/space.ts +74 -0
- package/src/platforms/webex/credential-manager.test.ts +314 -0
- package/src/platforms/webex/credential-manager.ts +197 -0
- package/src/platforms/webex/ensure-auth.test.ts +85 -0
- package/src/platforms/webex/ensure-auth.ts +19 -0
- package/src/platforms/webex/index.test.ts +25 -0
- package/src/platforms/webex/index.ts +17 -0
- package/src/platforms/webex/types.test.ts +307 -0
- package/src/platforms/webex/types.ts +127 -0
- package/src/tui/adapters/webex-adapter.ts +103 -0
- package/src/tui/app.ts +2 -0
|
@@ -0,0 +1,429 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
|
|
2
|
+
|
|
3
|
+
import { WebexClient } from './client'
|
|
4
|
+
import { WebexError } from './types'
|
|
5
|
+
|
|
6
|
+
describe('WebexClient', () => {
|
|
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, headers: Record<string, string> = {}) => {
|
|
35
|
+
const defaultHeaders: Record<string, string> = {
|
|
36
|
+
'Content-Type': 'application/json',
|
|
37
|
+
'X-RateLimit-Remaining': '10',
|
|
38
|
+
'X-RateLimit-Reset': String(Date.now() / 1000 + 60),
|
|
39
|
+
...headers,
|
|
40
|
+
}
|
|
41
|
+
fetchResponses.push(
|
|
42
|
+
new Response(body === null ? null : JSON.stringify(body), {
|
|
43
|
+
status,
|
|
44
|
+
headers: defaultHeaders,
|
|
45
|
+
}),
|
|
46
|
+
)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
describe('login', () => {
|
|
50
|
+
test('accepts valid token', async () => {
|
|
51
|
+
const client = await new WebexClient().login({ token: 'test-token' })
|
|
52
|
+
expect(client).toBeInstanceOf(WebexClient)
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
test('throws on empty token', async () => {
|
|
56
|
+
await expect(new WebexClient().login({ token: '' })).rejects.toThrow(WebexError)
|
|
57
|
+
await expect(new WebexClient().login({ token: '' })).rejects.toThrow('Token is required')
|
|
58
|
+
})
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
describe('testAuth', () => {
|
|
62
|
+
test('calls GET /people/me and returns person', async () => {
|
|
63
|
+
mockResponse({
|
|
64
|
+
id: 'user-123',
|
|
65
|
+
displayName: 'Test User',
|
|
66
|
+
emails: ['test@example.com'],
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
const client = await new WebexClient().login({ token: 'test-token' })
|
|
70
|
+
const person = await client.testAuth()
|
|
71
|
+
|
|
72
|
+
expect(person.id).toBe('user-123')
|
|
73
|
+
expect(person.displayName).toBe('Test User')
|
|
74
|
+
expect(fetchCalls.length).toBe(1)
|
|
75
|
+
expect(fetchCalls[0].url).toBe('https://webexapis.com/v1/people/me')
|
|
76
|
+
expect(fetchCalls[0].options?.headers).toMatchObject({
|
|
77
|
+
Authorization: 'Bearer test-token',
|
|
78
|
+
})
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
test('throws WebexError on API error', async () => {
|
|
82
|
+
mockResponse({ message: 'Unauthorized' }, 401)
|
|
83
|
+
|
|
84
|
+
const client = await new WebexClient().login({ token: 'bad-token' })
|
|
85
|
+
await expect(client.testAuth()).rejects.toThrow(WebexError)
|
|
86
|
+
})
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
describe('listSpaces', () => {
|
|
90
|
+
test('returns unwrapped items array', async () => {
|
|
91
|
+
mockResponse({
|
|
92
|
+
items: [
|
|
93
|
+
{ id: 'room1', title: 'Room One', type: 'group' },
|
|
94
|
+
{ id: 'room2', title: 'Room Two', type: 'direct' },
|
|
95
|
+
],
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
const client = await new WebexClient().login({ token: 'test-token' })
|
|
99
|
+
const spaces = await client.listSpaces()
|
|
100
|
+
|
|
101
|
+
expect(spaces).toHaveLength(2)
|
|
102
|
+
expect(spaces[0].id).toBe('room1')
|
|
103
|
+
expect(spaces[1].title).toBe('Room Two')
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
test('includes default max=50 query param', async () => {
|
|
107
|
+
mockResponse({ items: [] })
|
|
108
|
+
|
|
109
|
+
const client = await new WebexClient().login({ token: 'test-token' })
|
|
110
|
+
await client.listSpaces()
|
|
111
|
+
|
|
112
|
+
expect(fetchCalls[0].url).toContain('max=50')
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
test('passes type and max query params', async () => {
|
|
116
|
+
mockResponse({ items: [] })
|
|
117
|
+
|
|
118
|
+
const client = await new WebexClient().login({ token: 'test-token' })
|
|
119
|
+
await client.listSpaces({ type: 'direct', max: 10 })
|
|
120
|
+
|
|
121
|
+
expect(fetchCalls[0].url).toContain('type=direct')
|
|
122
|
+
expect(fetchCalls[0].url).toContain('max=10')
|
|
123
|
+
expect(fetchCalls[0].url).toContain('/rooms')
|
|
124
|
+
})
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
describe('getSpace', () => {
|
|
128
|
+
test('calls GET /rooms/{spaceId}', async () => {
|
|
129
|
+
mockResponse({ id: 'room1', title: 'Test Room', type: 'group' })
|
|
130
|
+
|
|
131
|
+
const client = await new WebexClient().login({ token: 'test-token' })
|
|
132
|
+
const space = await client.getSpace('room1')
|
|
133
|
+
|
|
134
|
+
expect(space.id).toBe('room1')
|
|
135
|
+
expect(fetchCalls[0].url).toBe('https://webexapis.com/v1/rooms/room1')
|
|
136
|
+
})
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
describe('sendMessage', () => {
|
|
140
|
+
test('posts text message to room', async () => {
|
|
141
|
+
mockResponse({ id: 'msg1', roomId: 'room1', text: 'Hello world' })
|
|
142
|
+
|
|
143
|
+
const client = await new WebexClient().login({ token: 'test-token' })
|
|
144
|
+
const message = await client.sendMessage('room1', 'Hello world')
|
|
145
|
+
|
|
146
|
+
expect(message.id).toBe('msg1')
|
|
147
|
+
expect(fetchCalls[0].url).toBe('https://webexapis.com/v1/messages')
|
|
148
|
+
expect(fetchCalls[0].options?.method).toBe('POST')
|
|
149
|
+
expect(fetchCalls[0].options?.body).toBe(JSON.stringify({ roomId: 'room1', text: 'Hello world' }))
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
test('sends markdown message when option set', async () => {
|
|
153
|
+
mockResponse({ id: 'msg1', roomId: 'room1', markdown: '**bold**' })
|
|
154
|
+
|
|
155
|
+
const client = await new WebexClient().login({ token: 'test-token' })
|
|
156
|
+
await client.sendMessage('room1', '**bold**', { markdown: true })
|
|
157
|
+
|
|
158
|
+
expect(fetchCalls[0].options?.body).toBe(
|
|
159
|
+
JSON.stringify({ roomId: 'room1', markdown: '**bold**' }),
|
|
160
|
+
)
|
|
161
|
+
})
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
describe('sendDirectMessage', () => {
|
|
165
|
+
test('posts message with toPersonEmail', async () => {
|
|
166
|
+
mockResponse({ id: 'msg1', toPersonEmail: 'user@example.com', text: 'Hello' })
|
|
167
|
+
|
|
168
|
+
const client = await new WebexClient().login({ token: 'test-token' })
|
|
169
|
+
await client.sendDirectMessage('user@example.com', 'Hello')
|
|
170
|
+
|
|
171
|
+
expect(fetchCalls[0].options?.body).toBe(
|
|
172
|
+
JSON.stringify({ toPersonEmail: 'user@example.com', text: 'Hello' }),
|
|
173
|
+
)
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
test('sends markdown direct message when option set', async () => {
|
|
177
|
+
mockResponse({ id: 'msg1', toPersonEmail: 'user@example.com' })
|
|
178
|
+
|
|
179
|
+
const client = await new WebexClient().login({ token: 'test-token' })
|
|
180
|
+
await client.sendDirectMessage('user@example.com', '**bold**', { markdown: true })
|
|
181
|
+
|
|
182
|
+
expect(fetchCalls[0].options?.body).toBe(
|
|
183
|
+
JSON.stringify({ toPersonEmail: 'user@example.com', markdown: '**bold**' }),
|
|
184
|
+
)
|
|
185
|
+
})
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
describe('listMessages', () => {
|
|
189
|
+
test('includes roomId query param and unwraps items', async () => {
|
|
190
|
+
mockResponse({
|
|
191
|
+
items: [
|
|
192
|
+
{ id: 'msg1', roomId: 'room1', text: 'Message 1' },
|
|
193
|
+
{ id: 'msg2', roomId: 'room1', text: 'Message 2' },
|
|
194
|
+
],
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
const client = await new WebexClient().login({ token: 'test-token' })
|
|
198
|
+
const messages = await client.listMessages('room1')
|
|
199
|
+
|
|
200
|
+
expect(messages).toHaveLength(2)
|
|
201
|
+
expect(messages[0].id).toBe('msg1')
|
|
202
|
+
expect(fetchCalls[0].url).toContain('roomId=room1')
|
|
203
|
+
expect(fetchCalls[0].url).toContain('max=50')
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
test('passes custom max', async () => {
|
|
207
|
+
mockResponse({ items: [] })
|
|
208
|
+
|
|
209
|
+
const client = await new WebexClient().login({ token: 'test-token' })
|
|
210
|
+
await client.listMessages('room1', { max: 10 })
|
|
211
|
+
|
|
212
|
+
expect(fetchCalls[0].url).toContain('max=10')
|
|
213
|
+
})
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
describe('getMessage', () => {
|
|
217
|
+
test('calls GET /messages/{messageId}', async () => {
|
|
218
|
+
mockResponse({ id: 'msg1', roomId: 'room1', text: 'Hello' })
|
|
219
|
+
|
|
220
|
+
const client = await new WebexClient().login({ token: 'test-token' })
|
|
221
|
+
const message = await client.getMessage('msg1')
|
|
222
|
+
|
|
223
|
+
expect(message.id).toBe('msg1')
|
|
224
|
+
expect(fetchCalls[0].url).toBe('https://webexapis.com/v1/messages/msg1')
|
|
225
|
+
})
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
describe('deleteMessage', () => {
|
|
229
|
+
test('calls DELETE /messages/{messageId} and handles 204', async () => {
|
|
230
|
+
mockResponse(null, 204)
|
|
231
|
+
|
|
232
|
+
const client = await new WebexClient().login({ token: 'test-token' })
|
|
233
|
+
await client.deleteMessage('msg1')
|
|
234
|
+
|
|
235
|
+
expect(fetchCalls[0].url).toBe('https://webexapis.com/v1/messages/msg1')
|
|
236
|
+
expect(fetchCalls[0].options?.method).toBe('DELETE')
|
|
237
|
+
})
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
describe('editMessage', () => {
|
|
241
|
+
test('calls PUT /messages/{messageId} with roomId and text', async () => {
|
|
242
|
+
mockResponse({ id: 'msg1', roomId: 'room1', text: 'Edited text' })
|
|
243
|
+
|
|
244
|
+
const client = await new WebexClient().login({ token: 'test-token' })
|
|
245
|
+
await client.editMessage('msg1', 'room1', 'Edited text')
|
|
246
|
+
|
|
247
|
+
expect(fetchCalls[0].url).toBe('https://webexapis.com/v1/messages/msg1')
|
|
248
|
+
expect(fetchCalls[0].options?.method).toBe('PUT')
|
|
249
|
+
expect(fetchCalls[0].options?.body).toBe(
|
|
250
|
+
JSON.stringify({ roomId: 'room1', text: 'Edited text' }),
|
|
251
|
+
)
|
|
252
|
+
})
|
|
253
|
+
|
|
254
|
+
test('sends markdown when option set', async () => {
|
|
255
|
+
mockResponse({ id: 'msg1', roomId: 'room1', markdown: '**edited**' })
|
|
256
|
+
|
|
257
|
+
const client = await new WebexClient().login({ token: 'test-token' })
|
|
258
|
+
await client.editMessage('msg1', 'room1', '**edited**', { markdown: true })
|
|
259
|
+
|
|
260
|
+
expect(fetchCalls[0].options?.body).toBe(
|
|
261
|
+
JSON.stringify({ roomId: 'room1', markdown: '**edited**' }),
|
|
262
|
+
)
|
|
263
|
+
})
|
|
264
|
+
})
|
|
265
|
+
|
|
266
|
+
describe('listPeople', () => {
|
|
267
|
+
test('returns unwrapped items', async () => {
|
|
268
|
+
mockResponse({
|
|
269
|
+
items: [
|
|
270
|
+
{ id: 'u1', displayName: 'User One', emails: ['user1@example.com'] },
|
|
271
|
+
{ id: 'u2', displayName: 'User Two', emails: ['user2@example.com'] },
|
|
272
|
+
],
|
|
273
|
+
})
|
|
274
|
+
|
|
275
|
+
const client = await new WebexClient().login({ token: 'test-token' })
|
|
276
|
+
const people = await client.listPeople()
|
|
277
|
+
|
|
278
|
+
expect(people).toHaveLength(2)
|
|
279
|
+
expect(people[0].displayName).toBe('User One')
|
|
280
|
+
})
|
|
281
|
+
|
|
282
|
+
test('passes email, displayName, max query params', async () => {
|
|
283
|
+
mockResponse({ items: [] })
|
|
284
|
+
|
|
285
|
+
const client = await new WebexClient().login({ token: 'test-token' })
|
|
286
|
+
await client.listPeople({ email: 'user@example.com', displayName: 'Test', max: 5 })
|
|
287
|
+
|
|
288
|
+
expect(fetchCalls[0].url).toContain('email=user%40example.com')
|
|
289
|
+
expect(fetchCalls[0].url).toContain('displayName=Test')
|
|
290
|
+
expect(fetchCalls[0].url).toContain('max=5')
|
|
291
|
+
})
|
|
292
|
+
})
|
|
293
|
+
|
|
294
|
+
describe('listMemberships', () => {
|
|
295
|
+
test('includes roomId and returns unwrapped items', async () => {
|
|
296
|
+
mockResponse({
|
|
297
|
+
items: [
|
|
298
|
+
{ id: 'm1', roomId: 'room1', personEmail: 'user1@example.com', isModerator: false },
|
|
299
|
+
{ id: 'm2', roomId: 'room1', personEmail: 'user2@example.com', isModerator: true },
|
|
300
|
+
],
|
|
301
|
+
})
|
|
302
|
+
|
|
303
|
+
const client = await new WebexClient().login({ token: 'test-token' })
|
|
304
|
+
const memberships = await client.listMemberships('room1')
|
|
305
|
+
|
|
306
|
+
expect(memberships).toHaveLength(2)
|
|
307
|
+
expect(memberships[0].id).toBe('m1')
|
|
308
|
+
expect(fetchCalls[0].url).toContain('roomId=room1')
|
|
309
|
+
})
|
|
310
|
+
|
|
311
|
+
test('passes max query param', async () => {
|
|
312
|
+
mockResponse({ items: [] })
|
|
313
|
+
|
|
314
|
+
const client = await new WebexClient().login({ token: 'test-token' })
|
|
315
|
+
await client.listMemberships('room1', { max: 20 })
|
|
316
|
+
|
|
317
|
+
expect(fetchCalls[0].url).toContain('max=20')
|
|
318
|
+
})
|
|
319
|
+
})
|
|
320
|
+
|
|
321
|
+
describe('rate limiting', () => {
|
|
322
|
+
test('retries on 429 with Retry-After header', async () => {
|
|
323
|
+
mockResponse({ message: 'Rate limited' }, 429, { 'Retry-After': '0.1' })
|
|
324
|
+
mockResponse({
|
|
325
|
+
id: 'user-123',
|
|
326
|
+
displayName: 'Test User',
|
|
327
|
+
emails: ['test@example.com'],
|
|
328
|
+
})
|
|
329
|
+
|
|
330
|
+
const client = await new WebexClient().login({ token: 'test-token' })
|
|
331
|
+
const person = await client.testAuth()
|
|
332
|
+
|
|
333
|
+
expect(person.id).toBe('user-123')
|
|
334
|
+
expect(fetchCalls.length).toBe(2)
|
|
335
|
+
})
|
|
336
|
+
|
|
337
|
+
test('throws after max retries exceeded on 429', async () => {
|
|
338
|
+
for (let i = 0; i <= MAX_RETRIES; i++) {
|
|
339
|
+
mockResponse({ message: 'Rate limited' }, 429, { 'Retry-After': '0.01' })
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
const client = await new WebexClient().login({ token: 'test-token' })
|
|
343
|
+
await expect(client.testAuth()).rejects.toThrow(WebexError)
|
|
344
|
+
expect(fetchCalls.length).toBeLessThanOrEqual(4)
|
|
345
|
+
})
|
|
346
|
+
})
|
|
347
|
+
|
|
348
|
+
describe('server errors', () => {
|
|
349
|
+
test('retries on 500 with exponential backoff', async () => {
|
|
350
|
+
mockResponse({ message: 'Internal Server Error' }, 500)
|
|
351
|
+
mockResponse({
|
|
352
|
+
id: 'user-123',
|
|
353
|
+
displayName: 'Test User',
|
|
354
|
+
emails: ['test@example.com'],
|
|
355
|
+
})
|
|
356
|
+
|
|
357
|
+
const client = await new WebexClient().login({ token: 'test-token' })
|
|
358
|
+
const person = await client.testAuth()
|
|
359
|
+
|
|
360
|
+
expect(person.id).toBe('user-123')
|
|
361
|
+
expect(fetchCalls.length).toBe(2)
|
|
362
|
+
})
|
|
363
|
+
|
|
364
|
+
test('does not retry on 4xx errors except 429', async () => {
|
|
365
|
+
mockResponse({ message: 'Not Found' }, 404)
|
|
366
|
+
|
|
367
|
+
const client = await new WebexClient().login({ token: 'test-token' })
|
|
368
|
+
await expect(client.testAuth()).rejects.toThrow(WebexError)
|
|
369
|
+
expect(fetchCalls.length).toBe(1)
|
|
370
|
+
})
|
|
371
|
+
|
|
372
|
+
test('backoff increases with multiple retries', async () => {
|
|
373
|
+
mockResponse({ message: 'Error' }, 500)
|
|
374
|
+
mockResponse({ message: 'Error' }, 500)
|
|
375
|
+
mockResponse({
|
|
376
|
+
id: 'user-123',
|
|
377
|
+
displayName: 'Test User',
|
|
378
|
+
emails: ['test@example.com'],
|
|
379
|
+
})
|
|
380
|
+
|
|
381
|
+
const client = await new WebexClient().login({ token: 'test-token' })
|
|
382
|
+
const startTime = Date.now()
|
|
383
|
+
await client.testAuth()
|
|
384
|
+
const elapsed = Date.now() - startTime
|
|
385
|
+
|
|
386
|
+
expect(elapsed).toBeGreaterThanOrEqual(150)
|
|
387
|
+
expect(fetchCalls.length).toBe(3)
|
|
388
|
+
})
|
|
389
|
+
})
|
|
390
|
+
|
|
391
|
+
describe('error handling', () => {
|
|
392
|
+
test('throws WebexError with parsed message from response body', async () => {
|
|
393
|
+
mockResponse({ message: 'The requested resource could not be found.', trackingId: 'abc' }, 404)
|
|
394
|
+
|
|
395
|
+
const client = await new WebexClient().login({ token: 'test-token' })
|
|
396
|
+
let error: WebexError | null = null
|
|
397
|
+
try {
|
|
398
|
+
await client.testAuth()
|
|
399
|
+
} catch (err) {
|
|
400
|
+
error = err as WebexError
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
expect(error).toBeInstanceOf(WebexError)
|
|
404
|
+
expect(error?.message).toBe('The requested resource could not be found.')
|
|
405
|
+
})
|
|
406
|
+
|
|
407
|
+
test('falls back to HTTP status message when no body', async () => {
|
|
408
|
+
fetchResponses.push(
|
|
409
|
+
new Response(null, {
|
|
410
|
+
status: 403,
|
|
411
|
+
headers: { 'Content-Type': 'application/json' },
|
|
412
|
+
}),
|
|
413
|
+
)
|
|
414
|
+
|
|
415
|
+
const client = await new WebexClient().login({ token: 'test-token' })
|
|
416
|
+
let error: WebexError | null = null
|
|
417
|
+
try {
|
|
418
|
+
await client.testAuth()
|
|
419
|
+
} catch (err) {
|
|
420
|
+
error = err as WebexError
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
expect(error).toBeInstanceOf(WebexError)
|
|
424
|
+
expect(error?.message).toBe('HTTP 403')
|
|
425
|
+
})
|
|
426
|
+
})
|
|
427
|
+
})
|
|
428
|
+
|
|
429
|
+
const MAX_RETRIES = 3
|
|
@@ -0,0 +1,247 @@
|
|
|
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 buckets: Map<string, RateLimitBucket> = new Map()
|
|
17
|
+
private globalRateLimitUntil: number = 0
|
|
18
|
+
|
|
19
|
+
async login(credentials?: { token: string }): Promise<this> {
|
|
20
|
+
if (credentials) {
|
|
21
|
+
if (!credentials.token) {
|
|
22
|
+
throw new WebexError('Token is required', 'missing_token')
|
|
23
|
+
}
|
|
24
|
+
this.token = credentials.token
|
|
25
|
+
return this
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const { ensureWebexAuth } = await import('./ensure-auth')
|
|
29
|
+
await ensureWebexAuth()
|
|
30
|
+
const credManager = new WebexCredentialManager()
|
|
31
|
+
const config = await credManager.loadConfig()
|
|
32
|
+
const token = await credManager.getToken(config?.clientId, config?.clientSecret)
|
|
33
|
+
if (!token) {
|
|
34
|
+
throw new WebexError(
|
|
35
|
+
'No Webex credentials found. Run "auth login" to authenticate.',
|
|
36
|
+
'no_credentials',
|
|
37
|
+
)
|
|
38
|
+
}
|
|
39
|
+
return this.login({ token })
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
private ensureAuth(): string {
|
|
43
|
+
if (this.token === null) {
|
|
44
|
+
throw new WebexError('Not authenticated. Call .login() first.', 'not_authenticated')
|
|
45
|
+
}
|
|
46
|
+
return this.token
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
private getBucketKey(method: string, path: string): string {
|
|
50
|
+
const normalized = path.replace(
|
|
51
|
+
/\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}(?=\/|$)/gi,
|
|
52
|
+
'/{id}',
|
|
53
|
+
)
|
|
54
|
+
return `${method}:${normalized}`
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
private async waitForRateLimit(bucketKey: string): Promise<void> {
|
|
58
|
+
const now = Date.now()
|
|
59
|
+
|
|
60
|
+
if (this.globalRateLimitUntil > now) {
|
|
61
|
+
await this.sleep(this.globalRateLimitUntil - now)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const bucket = this.buckets.get(bucketKey)
|
|
65
|
+
if (bucket && bucket.remaining === 0 && bucket.resetAt * 1000 > now) {
|
|
66
|
+
await this.sleep(bucket.resetAt * 1000 - now)
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
private updateBucket(bucketKey: string, response: Response): void {
|
|
71
|
+
const remaining = response.headers.get('X-RateLimit-Remaining')
|
|
72
|
+
const reset = response.headers.get('X-RateLimit-Reset')
|
|
73
|
+
|
|
74
|
+
if (remaining !== null && reset !== null) {
|
|
75
|
+
this.buckets.set(bucketKey, {
|
|
76
|
+
remaining: parseInt(remaining, 10),
|
|
77
|
+
resetAt: parseFloat(reset),
|
|
78
|
+
})
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
private async handleRateLimitResponse(response: Response): Promise<number> {
|
|
83
|
+
const retryAfter = response.headers.get('Retry-After')
|
|
84
|
+
const waitMs = parseFloat(retryAfter || '1') * 1000
|
|
85
|
+
|
|
86
|
+
this.globalRateLimitUntil = Date.now() + waitMs
|
|
87
|
+
await this.sleep(waitMs)
|
|
88
|
+
return waitMs
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
private sleep(ms: number): Promise<void> {
|
|
92
|
+
return new Promise((resolve) => setTimeout(resolve, ms))
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
private async request<T>(method: string, path: string, body?: unknown): Promise<T> {
|
|
96
|
+
const url = `${BASE_URL}${path}`
|
|
97
|
+
const bucketKey = this.getBucketKey(method, path)
|
|
98
|
+
|
|
99
|
+
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
|
|
100
|
+
await this.waitForRateLimit(bucketKey)
|
|
101
|
+
|
|
102
|
+
const options: RequestInit = {
|
|
103
|
+
method,
|
|
104
|
+
headers: {
|
|
105
|
+
Authorization: `Bearer ${this.ensureAuth()}`,
|
|
106
|
+
'Content-Type': 'application/json',
|
|
107
|
+
},
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (body !== undefined) {
|
|
111
|
+
options.body = JSON.stringify(body)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const response = await fetch(url, options)
|
|
115
|
+
this.updateBucket(bucketKey, response)
|
|
116
|
+
|
|
117
|
+
if (response.status === 429) {
|
|
118
|
+
if (attempt < MAX_RETRIES) {
|
|
119
|
+
await this.handleRateLimitResponse(response)
|
|
120
|
+
continue
|
|
121
|
+
}
|
|
122
|
+
const errorBody = (await response.json().catch(() => null)) as {
|
|
123
|
+
message?: string
|
|
124
|
+
} | null
|
|
125
|
+
throw new WebexError(errorBody?.message ?? 'Rate limited', 'rate_limited')
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (response.status >= 500 && attempt < MAX_RETRIES) {
|
|
129
|
+
await this.sleep(BASE_BACKOFF_MS * 2 ** attempt)
|
|
130
|
+
continue
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (!response.ok) {
|
|
134
|
+
const errorBody = (await response.json().catch(() => null)) as {
|
|
135
|
+
message?: string
|
|
136
|
+
errors?: Array<{ description: string }>
|
|
137
|
+
trackingId?: string
|
|
138
|
+
} | null
|
|
139
|
+
const message =
|
|
140
|
+
errorBody?.message ??
|
|
141
|
+
errorBody?.errors?.[0]?.description ??
|
|
142
|
+
`HTTP ${response.status}`
|
|
143
|
+
throw new WebexError(message, `http_${response.status}`)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (response.status === 204) {
|
|
147
|
+
return undefined as T
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return response.json() as Promise<T>
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
throw new WebexError('Request failed after retries', 'max_retries')
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async testAuth(): Promise<WebexPerson> {
|
|
157
|
+
return this.request<WebexPerson>('GET', '/people/me')
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async listSpaces(options?: { type?: string; max?: number }): Promise<WebexSpace[]> {
|
|
161
|
+
const params = new URLSearchParams()
|
|
162
|
+
if (options?.type) params.set('type', options.type)
|
|
163
|
+
params.set('max', String(options?.max ?? 50))
|
|
164
|
+
const query = params.toString()
|
|
165
|
+
const data = await this.request<{ items: WebexSpace[] }>('GET', `/rooms?${query}`)
|
|
166
|
+
return data.items
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async getSpace(spaceId: string): Promise<WebexSpace> {
|
|
170
|
+
return this.request<WebexSpace>('GET', `/rooms/${spaceId}`)
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async sendMessage(
|
|
174
|
+
roomId: string,
|
|
175
|
+
text: string,
|
|
176
|
+
options?: { markdown?: boolean },
|
|
177
|
+
): Promise<WebexMessage> {
|
|
178
|
+
const body = options?.markdown ? { roomId, markdown: text } : { roomId, text }
|
|
179
|
+
return this.request<WebexMessage>('POST', '/messages', body)
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async sendDirectMessage(
|
|
183
|
+
personEmail: string,
|
|
184
|
+
text: string,
|
|
185
|
+
options?: { markdown?: boolean },
|
|
186
|
+
): Promise<WebexMessage> {
|
|
187
|
+
const body = options?.markdown
|
|
188
|
+
? { toPersonEmail: personEmail, markdown: text }
|
|
189
|
+
: { toPersonEmail: personEmail, text }
|
|
190
|
+
return this.request<WebexMessage>('POST', '/messages', body)
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
async listMessages(roomId: string, options?: { max?: number }): Promise<WebexMessage[]> {
|
|
194
|
+
const params = new URLSearchParams()
|
|
195
|
+
params.set('roomId', roomId)
|
|
196
|
+
params.set('max', String(options?.max ?? 50))
|
|
197
|
+
const data = await this.request<{ items: WebexMessage[] }>('GET', `/messages?${params}`)
|
|
198
|
+
return data.items
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
async getMessage(messageId: string): Promise<WebexMessage> {
|
|
202
|
+
return this.request<WebexMessage>('GET', `/messages/${messageId}`)
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
async deleteMessage(messageId: string): Promise<void> {
|
|
206
|
+
return this.request<void>('DELETE', `/messages/${messageId}`)
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
async editMessage(
|
|
210
|
+
messageId: string,
|
|
211
|
+
roomId: string,
|
|
212
|
+
text: string,
|
|
213
|
+
options?: { markdown?: boolean },
|
|
214
|
+
): Promise<WebexMessage> {
|
|
215
|
+
const body = options?.markdown ? { roomId, markdown: text } : { roomId, text }
|
|
216
|
+
return this.request<WebexMessage>('PUT', `/messages/${messageId}`, body)
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
async listPeople(options?: {
|
|
220
|
+
email?: string
|
|
221
|
+
displayName?: string
|
|
222
|
+
max?: number
|
|
223
|
+
}): Promise<WebexPerson[]> {
|
|
224
|
+
const params = new URLSearchParams()
|
|
225
|
+
if (options?.email) params.set('email', options.email)
|
|
226
|
+
if (options?.displayName) params.set('displayName', options.displayName)
|
|
227
|
+
if (options?.max) params.set('max', String(options.max))
|
|
228
|
+
const query = params.toString()
|
|
229
|
+
const path = query ? `/people?${query}` : '/people'
|
|
230
|
+
const data = await this.request<{ items: WebexPerson[] }>('GET', path)
|
|
231
|
+
return data.items
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
async listMemberships(
|
|
235
|
+
roomId: string,
|
|
236
|
+
options?: { max?: number },
|
|
237
|
+
): Promise<WebexMembership[]> {
|
|
238
|
+
const params = new URLSearchParams()
|
|
239
|
+
params.set('roomId', roomId)
|
|
240
|
+
if (options?.max) params.set('max', String(options.max))
|
|
241
|
+
const data = await this.request<{ items: WebexMembership[] }>(
|
|
242
|
+
'GET',
|
|
243
|
+
`/memberships?${params}`,
|
|
244
|
+
)
|
|
245
|
+
return data.items
|
|
246
|
+
}
|
|
247
|
+
}
|