agent-messenger 2.22.0 → 2.23.1
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/plugin.json +1 -1
- package/README.md +21 -0
- package/dist/package.json +1 -1
- package/dist/src/platforms/webex/client.d.ts +6 -0
- package/dist/src/platforms/webex/client.d.ts.map +1 -1
- package/dist/src/platforms/webex/client.js +34 -4
- package/dist/src/platforms/webex/client.js.map +1 -1
- package/dist/src/platforms/webex/commands/auth.d.ts +9 -1
- package/dist/src/platforms/webex/commands/auth.d.ts.map +1 -1
- package/dist/src/platforms/webex/commands/auth.js +141 -25
- package/dist/src/platforms/webex/commands/auth.js.map +1 -1
- package/dist/src/platforms/webex/credential-manager.d.ts.map +1 -1
- package/dist/src/platforms/webex/credential-manager.js +8 -4
- package/dist/src/platforms/webex/credential-manager.js.map +1 -1
- package/dist/src/platforms/webex/id-normalizer.d.ts +19 -0
- package/dist/src/platforms/webex/id-normalizer.d.ts.map +1 -0
- package/dist/src/platforms/webex/id-normalizer.js +60 -0
- package/dist/src/platforms/webex/id-normalizer.js.map +1 -0
- package/dist/src/platforms/webex/index.d.ts +6 -0
- package/dist/src/platforms/webex/index.d.ts.map +1 -1
- package/dist/src/platforms/webex/index.js +3 -0
- package/dist/src/platforms/webex/index.js.map +1 -1
- package/dist/src/platforms/webex/listener.d.ts +61 -0
- package/dist/src/platforms/webex/listener.d.ts.map +1 -0
- package/dist/src/platforms/webex/listener.js +222 -0
- package/dist/src/platforms/webex/listener.js.map +1 -0
- package/dist/src/platforms/webex/password-login.d.ts +18 -0
- package/dist/src/platforms/webex/password-login.d.ts.map +1 -0
- package/dist/src/platforms/webex/password-login.js +259 -0
- package/dist/src/platforms/webex/password-login.js.map +1 -0
- package/dist/src/platforms/webex/types.d.ts +2 -1
- package/dist/src/platforms/webex/types.d.ts.map +1 -1
- package/dist/src/platforms/webex/types.js +1 -1
- package/dist/src/platforms/webex/types.js.map +1 -1
- package/dist/src/platforms/webex/wdm-discovery.d.ts.map +1 -0
- package/dist/src/platforms/{webexbot → webex}/wdm-discovery.js +3 -3
- package/dist/src/platforms/webex/wdm-discovery.js.map +1 -0
- package/dist/src/platforms/webexbot/client.d.ts +4 -0
- package/dist/src/platforms/webexbot/client.d.ts.map +1 -1
- package/dist/src/platforms/webexbot/client.js +70 -8
- package/dist/src/platforms/webexbot/client.js.map +1 -1
- package/dist/src/platforms/webexbot/index.d.ts +2 -0
- package/dist/src/platforms/webexbot/index.d.ts.map +1 -1
- package/dist/src/platforms/webexbot/index.js +1 -0
- package/dist/src/platforms/webexbot/index.js.map +1 -1
- package/dist/src/platforms/webexbot/listener.d.ts +3 -41
- package/dist/src/platforms/webexbot/listener.d.ts.map +1 -1
- package/dist/src/platforms/webexbot/listener.js +13 -208
- package/dist/src/platforms/webexbot/listener.js.map +1 -1
- package/dist/src/platforms/webexbot/types.d.ts +1 -18
- package/dist/src/platforms/webexbot/types.d.ts.map +1 -1
- package/dist/src/platforms/webexbot/types.js.map +1 -1
- package/docs/content/docs/cli/webex.mdx +38 -12
- package/docs/content/docs/sdk/webexbot.mdx +16 -0
- package/package.json +1 -1
- 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-telegrambot/SKILL.md +1 -1
- package/skills/agent-webex/SKILL.md +76 -22
- package/skills/agent-webex/references/authentication.md +55 -14
- package/skills/agent-webex/references/common-patterns.md +5 -2
- package/skills/agent-webexbot/SKILL.md +3 -1
- package/skills/agent-wechatbot/SKILL.md +1 -1
- package/skills/agent-whatsapp/SKILL.md +1 -1
- package/skills/agent-whatsappbot/SKILL.md +1 -1
- package/src/platforms/webex/cli.test.ts +31 -1
- package/src/platforms/webex/client.test.ts +57 -0
- package/src/platforms/webex/client.ts +39 -4
- package/src/platforms/webex/commands/auth.test.ts +189 -28
- package/src/platforms/webex/commands/auth.ts +194 -35
- package/src/platforms/webex/credential-manager.test.ts +40 -0
- package/src/platforms/webex/credential-manager.ts +7 -4
- package/src/platforms/webex/id-normalizer.test.ts +207 -0
- package/src/platforms/webex/id-normalizer.ts +76 -0
- package/src/platforms/webex/index.test.ts +10 -0
- package/src/platforms/webex/index.ts +6 -0
- package/src/platforms/webex/listener.test.ts +243 -0
- package/src/platforms/webex/listener.ts +285 -0
- package/src/platforms/webex/password-login.test.ts +193 -0
- package/src/platforms/webex/password-login.ts +332 -0
- package/src/platforms/webex/types.test.ts +16 -0
- package/src/platforms/webex/types.ts +2 -2
- package/src/platforms/{webexbot → webex}/wdm-discovery.ts +3 -3
- package/src/platforms/webexbot/client.test.ts +125 -1
- package/src/platforms/webexbot/client.ts +79 -8
- package/src/platforms/webexbot/index.ts +2 -0
- package/src/platforms/webexbot/listener.test.ts +37 -224
- package/src/platforms/webexbot/listener.ts +18 -250
- package/src/platforms/webexbot/types.ts +2 -23
- package/dist/src/platforms/webexbot/wdm-discovery.d.ts.map +0 -1
- package/dist/src/platforms/webexbot/wdm-discovery.js.map +0 -1
- /package/dist/src/platforms/{webexbot → webex}/wdm-discovery.d.ts +0 -0
- /package/src/platforms/{webexbot → webex}/wdm-discovery.test.ts +0 -0
|
@@ -326,6 +326,46 @@ describe('WebexCredentialManager', () => {
|
|
|
326
326
|
globalThis.fetch = originalFetch
|
|
327
327
|
})
|
|
328
328
|
|
|
329
|
+
it('getToken refreshes password tokens with stored web credentials', async () => {
|
|
330
|
+
const originalFetch = globalThis.fetch
|
|
331
|
+
let capturedBody = ''
|
|
332
|
+
globalThis.fetch = mock((_url: string, init?: RequestInit) => {
|
|
333
|
+
capturedBody = String(init?.body ?? '')
|
|
334
|
+
return Promise.resolve(
|
|
335
|
+
new Response(
|
|
336
|
+
JSON.stringify({
|
|
337
|
+
access_token: 'refreshed-password-token',
|
|
338
|
+
refresh_token: 'new-password-refresh',
|
|
339
|
+
expires_in: 3600,
|
|
340
|
+
}),
|
|
341
|
+
{ status: 200 },
|
|
342
|
+
),
|
|
343
|
+
)
|
|
344
|
+
}) as typeof fetch
|
|
345
|
+
|
|
346
|
+
await credManager.saveConfig({
|
|
347
|
+
accessToken: 'expired-password-token',
|
|
348
|
+
refreshToken: 'password-refresh',
|
|
349
|
+
expiresAt: Date.now() - 1000,
|
|
350
|
+
tokenType: 'password',
|
|
351
|
+
clientId: 'fake-client-id',
|
|
352
|
+
clientSecret: 'fake-client-secret',
|
|
353
|
+
})
|
|
354
|
+
|
|
355
|
+
const token = await credManager.getToken()
|
|
356
|
+
const params = new URLSearchParams(capturedBody)
|
|
357
|
+
|
|
358
|
+
expect(token).toBe('refreshed-password-token')
|
|
359
|
+
expect(params.get('client_id')).toBe('fake-client-id')
|
|
360
|
+
expect(params.get('client_secret')).toBe('fake-client-secret')
|
|
361
|
+
|
|
362
|
+
const config = await credManager.loadConfig()
|
|
363
|
+
expect(config?.tokenType).toBe('password')
|
|
364
|
+
expect(config?.accessToken).toBe('refreshed-password-token')
|
|
365
|
+
|
|
366
|
+
globalThis.fetch = originalFetch
|
|
367
|
+
})
|
|
368
|
+
|
|
329
369
|
it('getToken returns expired extracted token when refresh fails', async () => {
|
|
330
370
|
const originalFetch = globalThis.fetch
|
|
331
371
|
globalThis.fetch = mock(() =>
|
|
@@ -51,12 +51,15 @@ export class WebexCredentialManager {
|
|
|
51
51
|
|
|
52
52
|
const isExpired = config.expiresAt > 0 && config.expiresAt < Date.now() + 5 * 60 * 1000
|
|
53
53
|
|
|
54
|
-
if (config.tokenType === 'extracted') {
|
|
54
|
+
if (config.tokenType === 'extracted' || config.tokenType === 'password') {
|
|
55
55
|
if (isExpired && config.refreshToken) {
|
|
56
|
-
const builtinCreds = getWebexAppCredentials()
|
|
57
|
-
const
|
|
56
|
+
const builtinCreds = config.tokenType === 'password' ? null : getWebexAppCredentials()
|
|
57
|
+
const resolvedClientId = config.clientId ?? builtinCreds?.clientId
|
|
58
|
+
const resolvedClientSecret = config.clientSecret ?? builtinCreds?.clientSecret
|
|
59
|
+
if (!resolvedClientId || !resolvedClientSecret) return null
|
|
60
|
+
const refreshed = await this.refreshToken(config.refreshToken, resolvedClientId, resolvedClientSecret)
|
|
58
61
|
if (refreshed) {
|
|
59
|
-
await this.saveConfig({ ...config, ...refreshed, tokenType:
|
|
62
|
+
await this.saveConfig({ ...config, ...refreshed, tokenType: config.tokenType })
|
|
60
63
|
return refreshed.accessToken
|
|
61
64
|
}
|
|
62
65
|
}
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test'
|
|
2
|
+
|
|
3
|
+
import type {
|
|
4
|
+
AttachmentAction,
|
|
5
|
+
DecryptedMessage,
|
|
6
|
+
DeletedMessage,
|
|
7
|
+
MembershipActivity,
|
|
8
|
+
MercuryActivity,
|
|
9
|
+
RoomActivity,
|
|
10
|
+
} from 'webex-message-handler'
|
|
11
|
+
|
|
12
|
+
import {
|
|
13
|
+
fromRestId,
|
|
14
|
+
normalizeAttachmentAction,
|
|
15
|
+
normalizeDeletedMessage,
|
|
16
|
+
normalizeMembership,
|
|
17
|
+
normalizeMessage,
|
|
18
|
+
normalizeRoomActivity,
|
|
19
|
+
toRestId,
|
|
20
|
+
} from './id-normalizer'
|
|
21
|
+
|
|
22
|
+
const RAW: MercuryActivity = {
|
|
23
|
+
id: 'activity-uuid',
|
|
24
|
+
verb: 'post',
|
|
25
|
+
actor: { id: 'person-uuid', objectType: 'person', emailAddress: 'user@example.com' },
|
|
26
|
+
object: { id: 'object-uuid', objectType: 'comment', displayName: 'hi' },
|
|
27
|
+
target: { id: 'room-uuid', objectType: 'conversation' },
|
|
28
|
+
published: '2024-01-01T00:00:00Z',
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
describe('toRestId / fromRestId', () => {
|
|
32
|
+
it('encodes a uuid into a ciscospark REST id round-trippable to the uuid', () => {
|
|
33
|
+
const restId = toRestId('abc-123', 'PEOPLE')
|
|
34
|
+
expect(Buffer.from(restId, 'base64').toString('utf-8')).toBe('ciscospark://us/PEOPLE/abc-123')
|
|
35
|
+
expect(fromRestId(restId)).toBe('abc-123')
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('supports ATTACHMENT_ACTION which the upstream helper omits', () => {
|
|
39
|
+
expect(Buffer.from(toRestId('a-1', 'ATTACHMENT_ACTION'), 'base64').toString('utf-8')).toBe(
|
|
40
|
+
'ciscospark://us/ATTACHMENT_ACTION/a-1',
|
|
41
|
+
)
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('emits unpadded base64url ids matching the Webex REST API', () => {
|
|
45
|
+
// 36-char UUID -> 59-byte URI, which exposes the padding bug; a URI length
|
|
46
|
+
// that is a multiple of 3 produces no padding and would hide the regression.
|
|
47
|
+
const uuid = '12345678-1234-1234-1234-1234567890ab'
|
|
48
|
+
const restId = toRestId(uuid, 'PEOPLE')
|
|
49
|
+
|
|
50
|
+
expect(restId).toBe(Buffer.from(`ciscospark://us/PEOPLE/${uuid}`).toString('base64url'))
|
|
51
|
+
expect(restId).not.toContain('=')
|
|
52
|
+
expect(restId).not.toMatch(/[+/]/)
|
|
53
|
+
expect(fromRestId(restId)).toBe(uuid)
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('returns empty input unchanged', () => {
|
|
57
|
+
expect(toRestId('', 'MESSAGE')).toBe('')
|
|
58
|
+
})
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
describe('normalizeMessage', () => {
|
|
62
|
+
const message: DecryptedMessage = {
|
|
63
|
+
id: 'msg-uuid',
|
|
64
|
+
parentId: 'parent-uuid',
|
|
65
|
+
roomId: 'room-uuid',
|
|
66
|
+
personId: 'person-uuid',
|
|
67
|
+
personEmail: 'user@example.com',
|
|
68
|
+
text: 'hello',
|
|
69
|
+
html: '<p>hello</p>',
|
|
70
|
+
created: '2024-01-01T00:00:00Z',
|
|
71
|
+
roomType: 'group',
|
|
72
|
+
mentionedPeople: ['mention-uuid-1', 'mention-uuid-2'],
|
|
73
|
+
mentionedGroups: ['all'],
|
|
74
|
+
files: ['https://files.example.com/a.png'],
|
|
75
|
+
raw: RAW,
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
it('encodes message, room, person and mention ids to REST form', () => {
|
|
79
|
+
const result = normalizeMessage(message)
|
|
80
|
+
|
|
81
|
+
expect(result.id).toBe(toRestId('msg-uuid', 'MESSAGE'))
|
|
82
|
+
expect(result.parentId).toBe(toRestId('parent-uuid', 'MESSAGE'))
|
|
83
|
+
expect(result.roomId).toBe(toRestId('room-uuid', 'ROOM'))
|
|
84
|
+
expect(result.personId).toBe(toRestId('person-uuid', 'PEOPLE'))
|
|
85
|
+
expect(result.mentionedPeople).toEqual([toRestId('mention-uuid-1', 'PEOPLE'), toRestId('mention-uuid-2', 'PEOPLE')])
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it('leaves non-id fields and raw untouched', () => {
|
|
89
|
+
const result = normalizeMessage(message)
|
|
90
|
+
|
|
91
|
+
expect(result.mentionedGroups).toEqual(['all'])
|
|
92
|
+
expect(result.files).toEqual(['https://files.example.com/a.png'])
|
|
93
|
+
expect(result.personEmail).toBe('user@example.com')
|
|
94
|
+
expect(result.text).toBe('hello')
|
|
95
|
+
expect(result.raw).toBe(RAW)
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it('returns a new object without mutating the input', () => {
|
|
99
|
+
const result = normalizeMessage(message)
|
|
100
|
+
|
|
101
|
+
expect(result).not.toBe(message)
|
|
102
|
+
expect(message.personId).toBe('person-uuid')
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
it('preserves an absent parentId', () => {
|
|
106
|
+
const { parentId: _omit, ...withoutParent } = message
|
|
107
|
+
const result = normalizeMessage(withoutParent)
|
|
108
|
+
|
|
109
|
+
expect(result.parentId).toBeUndefined()
|
|
110
|
+
})
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
describe('normalizeDeletedMessage', () => {
|
|
114
|
+
it('encodes messageId, roomId and personId to REST form', () => {
|
|
115
|
+
const deleted: DeletedMessage = {
|
|
116
|
+
messageId: 'msg-uuid',
|
|
117
|
+
roomId: 'room-uuid',
|
|
118
|
+
personId: 'person-uuid',
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
expect(normalizeDeletedMessage(deleted)).toEqual({
|
|
122
|
+
messageId: toRestId('msg-uuid', 'MESSAGE'),
|
|
123
|
+
roomId: toRestId('room-uuid', 'ROOM'),
|
|
124
|
+
personId: toRestId('person-uuid', 'PEOPLE'),
|
|
125
|
+
})
|
|
126
|
+
})
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
describe('normalizeMembership', () => {
|
|
130
|
+
it('encodes person and room ids but leaves the activity id raw', () => {
|
|
131
|
+
const membership: MembershipActivity = {
|
|
132
|
+
id: 'activity-uuid',
|
|
133
|
+
actorId: 'actor-uuid',
|
|
134
|
+
personId: 'member-uuid',
|
|
135
|
+
roomId: 'room-uuid',
|
|
136
|
+
action: 'add',
|
|
137
|
+
created: '2024-01-01T00:00:00Z',
|
|
138
|
+
raw: RAW,
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const result = normalizeMembership(membership)
|
|
142
|
+
|
|
143
|
+
expect(result.id).toBe('activity-uuid')
|
|
144
|
+
expect(result.actorId).toBe(toRestId('actor-uuid', 'PEOPLE'))
|
|
145
|
+
expect(result.personId).toBe(toRestId('member-uuid', 'PEOPLE'))
|
|
146
|
+
expect(result.roomId).toBe(toRestId('room-uuid', 'ROOM'))
|
|
147
|
+
expect(result.raw).toBe(RAW)
|
|
148
|
+
})
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
describe('normalizeAttachmentAction', () => {
|
|
152
|
+
it('encodes the action id as ATTACHMENT_ACTION and the resource ids to REST form', () => {
|
|
153
|
+
const action: AttachmentAction = {
|
|
154
|
+
id: 'action-uuid',
|
|
155
|
+
messageId: 'msg-uuid',
|
|
156
|
+
personId: 'person-uuid',
|
|
157
|
+
personEmail: 'user@example.com',
|
|
158
|
+
roomId: 'room-uuid',
|
|
159
|
+
inputs: { choice: 'yes' },
|
|
160
|
+
created: '2024-01-01T00:00:00Z',
|
|
161
|
+
raw: RAW,
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const result = normalizeAttachmentAction(action)
|
|
165
|
+
|
|
166
|
+
expect(result.id).toBe(toRestId('action-uuid', 'ATTACHMENT_ACTION'))
|
|
167
|
+
expect(result.messageId).toBe(toRestId('msg-uuid', 'MESSAGE'))
|
|
168
|
+
expect(result.personId).toBe(toRestId('person-uuid', 'PEOPLE'))
|
|
169
|
+
expect(result.roomId).toBe(toRestId('room-uuid', 'ROOM'))
|
|
170
|
+
expect(result.inputs).toEqual({ choice: 'yes' })
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
it('preserves an empty messageId', () => {
|
|
174
|
+
const action: AttachmentAction = {
|
|
175
|
+
id: 'action-uuid',
|
|
176
|
+
messageId: '',
|
|
177
|
+
personId: 'person-uuid',
|
|
178
|
+
personEmail: 'user@example.com',
|
|
179
|
+
roomId: 'room-uuid',
|
|
180
|
+
inputs: {},
|
|
181
|
+
created: '2024-01-01T00:00:00Z',
|
|
182
|
+
raw: RAW,
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
expect(normalizeAttachmentAction(action).messageId).toBe('')
|
|
186
|
+
})
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
describe('normalizeRoomActivity', () => {
|
|
190
|
+
it('encodes room and actor ids but leaves the activity id raw', () => {
|
|
191
|
+
const room: RoomActivity = {
|
|
192
|
+
id: 'activity-uuid',
|
|
193
|
+
roomId: 'room-uuid',
|
|
194
|
+
actorId: 'actor-uuid',
|
|
195
|
+
action: 'created',
|
|
196
|
+
created: '2024-01-01T00:00:00Z',
|
|
197
|
+
raw: RAW,
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const result = normalizeRoomActivity(room)
|
|
201
|
+
|
|
202
|
+
expect(result.id).toBe('activity-uuid')
|
|
203
|
+
expect(result.roomId).toBe(toRestId('room-uuid', 'ROOM'))
|
|
204
|
+
expect(result.actorId).toBe(toRestId('actor-uuid', 'PEOPLE'))
|
|
205
|
+
expect(result.raw).toBe(RAW)
|
|
206
|
+
})
|
|
207
|
+
})
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { fromRestId } from 'webex-message-handler'
|
|
2
|
+
import type {
|
|
3
|
+
AttachmentAction,
|
|
4
|
+
DecryptedMessage,
|
|
5
|
+
DeletedMessage,
|
|
6
|
+
MembershipActivity,
|
|
7
|
+
RoomActivity,
|
|
8
|
+
} from 'webex-message-handler'
|
|
9
|
+
|
|
10
|
+
export { fromRestId }
|
|
11
|
+
|
|
12
|
+
// Superset of webex-message-handler's toRestId union, which omits ATTACHMENT_ACTION
|
|
13
|
+
// (the resource type behind GET /v1/attachment/actions).
|
|
14
|
+
export type WebexRestIdType = 'MESSAGE' | 'PEOPLE' | 'ROOM' | 'ATTACHMENT_ACTION'
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Encode a raw Mercury UUID as a Webex REST ID. Empty input is returned unchanged
|
|
18
|
+
* so an absent ID never becomes a bogus `ciscospark://us/{TYPE}/` value.
|
|
19
|
+
*
|
|
20
|
+
* Webex REST IDs are unpadded base64url. Padded base64 (trailing `=`) would not
|
|
21
|
+
* equal the ID the REST API returns for the same resource (e.g. the bot's own
|
|
22
|
+
* `/people/me` id), silently breaking equality checks such as mention detection.
|
|
23
|
+
*/
|
|
24
|
+
export function toRestId(uuid: string, type: WebexRestIdType): string {
|
|
25
|
+
if (!uuid) return uuid
|
|
26
|
+
return Buffer.from(`ciscospark://us/${type}/${uuid}`).toString('base64url')
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function normalizeMessage(message: DecryptedMessage): DecryptedMessage {
|
|
30
|
+
return {
|
|
31
|
+
...message,
|
|
32
|
+
id: toRestId(message.id, 'MESSAGE'),
|
|
33
|
+
parentId: message.parentId ? toRestId(message.parentId, 'MESSAGE') : message.parentId,
|
|
34
|
+
roomId: toRestId(message.roomId, 'ROOM'),
|
|
35
|
+
personId: toRestId(message.personId, 'PEOPLE'),
|
|
36
|
+
mentionedPeople: message.mentionedPeople.map((id) => toRestId(id, 'PEOPLE')),
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function normalizeDeletedMessage(message: DeletedMessage): DeletedMessage {
|
|
41
|
+
return {
|
|
42
|
+
messageId: toRestId(message.messageId, 'MESSAGE'),
|
|
43
|
+
roomId: toRestId(message.roomId, 'ROOM'),
|
|
44
|
+
personId: toRestId(message.personId, 'PEOPLE'),
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function normalizeMembership(activity: MembershipActivity): MembershipActivity {
|
|
49
|
+
// `id` stays raw: it is a Mercury activity UUID, not a REST membership ID.
|
|
50
|
+
return {
|
|
51
|
+
...activity,
|
|
52
|
+
actorId: toRestId(activity.actorId, 'PEOPLE'),
|
|
53
|
+
personId: toRestId(activity.personId, 'PEOPLE'),
|
|
54
|
+
roomId: toRestId(activity.roomId, 'ROOM'),
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function normalizeAttachmentAction(action: AttachmentAction): AttachmentAction {
|
|
59
|
+
return {
|
|
60
|
+
...action,
|
|
61
|
+
id: toRestId(action.id, 'ATTACHMENT_ACTION'),
|
|
62
|
+
messageId: action.messageId ? toRestId(action.messageId, 'MESSAGE') : action.messageId,
|
|
63
|
+
personId: toRestId(action.personId, 'PEOPLE'),
|
|
64
|
+
roomId: toRestId(action.roomId, 'ROOM'),
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function normalizeRoomActivity(activity: RoomActivity): RoomActivity {
|
|
69
|
+
// `id` stays raw: the Mercury conversation activity UUID has no
|
|
70
|
+
// consumer-facing REST resource (the comparable REST ID is `roomId`).
|
|
71
|
+
return {
|
|
72
|
+
...activity,
|
|
73
|
+
roomId: toRestId(activity.roomId, 'ROOM'),
|
|
74
|
+
actorId: toRestId(activity.actorId, 'PEOPLE'),
|
|
75
|
+
}
|
|
76
|
+
}
|
|
@@ -15,6 +15,16 @@ describe('webex barrel exports', () => {
|
|
|
15
15
|
expect(webex.WebexError).toBeDefined()
|
|
16
16
|
})
|
|
17
17
|
|
|
18
|
+
it('exports WebexListener', () => {
|
|
19
|
+
expect(webex.WebexListener).toBeDefined()
|
|
20
|
+
expect(webex.toRestId).toBeDefined()
|
|
21
|
+
expect(webex.fromRestId).toBeDefined()
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('exports loginWithPassword', () => {
|
|
25
|
+
expect(webex.loginWithPassword).toBeDefined()
|
|
26
|
+
})
|
|
27
|
+
|
|
18
28
|
it('exports Zod schemas', () => {
|
|
19
29
|
expect(webex.WebexSpaceSchema).toBeDefined()
|
|
20
30
|
expect(webex.WebexMessageSchema).toBeDefined()
|
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
export { WebexClient } from './client'
|
|
2
2
|
export { WebexCredentialManager } from './credential-manager'
|
|
3
|
+
export { fromRestId, toRestId } from './id-normalizer'
|
|
4
|
+
export type { WebexRestIdType } from './id-normalizer'
|
|
5
|
+
export { WebexListener } from './listener'
|
|
6
|
+
export type { WebexListenerClient, WebexListenerEventMap, WebexListenerOptions } from './listener'
|
|
7
|
+
export { loginWithPassword } from './password-login'
|
|
8
|
+
export type { PasswordLoginOptions, PasswordLoginResult } from './password-login'
|
|
3
9
|
export { WebexTokenExtractor } from './token-extractor'
|
|
4
10
|
export type { ExtractedWebexToken } from './token-extractor'
|
|
5
11
|
export { WebexError } from './types'
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
import { describe, expect, it, mock } from 'bun:test'
|
|
2
|
+
import { EventEmitter } from 'events'
|
|
3
|
+
|
|
4
|
+
import type {
|
|
5
|
+
DecryptedMessage,
|
|
6
|
+
HandlerStatus,
|
|
7
|
+
MercuryActivity,
|
|
8
|
+
WebexMessageHandlerConfig,
|
|
9
|
+
WebexMessageHandlerEvents,
|
|
10
|
+
} from 'webex-message-handler'
|
|
11
|
+
|
|
12
|
+
import { toRestId } from './id-normalizer'
|
|
13
|
+
import { WebexListener } from './listener'
|
|
14
|
+
|
|
15
|
+
const STATUS: HandlerStatus = {
|
|
16
|
+
status: 'connected',
|
|
17
|
+
webSocketOpen: true,
|
|
18
|
+
kmsInitialized: true,
|
|
19
|
+
deviceRegistered: true,
|
|
20
|
+
reconnectAttempt: 0,
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const RAW_ACTIVITY: MercuryActivity = {
|
|
24
|
+
id: 'activity-123',
|
|
25
|
+
verb: 'post',
|
|
26
|
+
actor: { id: 'person-123', objectType: 'person', emailAddress: 'user@example.com' },
|
|
27
|
+
object: { id: 'object-123', objectType: 'comment', displayName: 'hello' },
|
|
28
|
+
target: { id: 'room-123', objectType: 'conversation' },
|
|
29
|
+
published: '2024-01-01T00:00:00Z',
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const MESSAGE: DecryptedMessage = {
|
|
33
|
+
id: 'message-123',
|
|
34
|
+
roomId: 'room-123',
|
|
35
|
+
personId: 'person-123',
|
|
36
|
+
personEmail: 'user@example.com',
|
|
37
|
+
text: 'hello',
|
|
38
|
+
created: '2024-01-01T00:00:00Z',
|
|
39
|
+
mentionedPeople: [],
|
|
40
|
+
mentionedGroups: [],
|
|
41
|
+
files: [],
|
|
42
|
+
raw: RAW_ACTIVITY,
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
class FakeWebexMessageHandler extends EventEmitter {
|
|
46
|
+
connect = mock(() => Promise.resolve())
|
|
47
|
+
disconnect = mock(() => Promise.resolve())
|
|
48
|
+
connected = true
|
|
49
|
+
|
|
50
|
+
status(): HandlerStatus {
|
|
51
|
+
return STATUS
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
override on<K extends keyof WebexMessageHandlerEvents>(event: K, listener: WebexMessageHandlerEvents[K]): this {
|
|
55
|
+
return super.on(event, listener)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
override off<K extends keyof WebexMessageHandlerEvents>(event: K, listener: WebexMessageHandlerEvents[K]): this {
|
|
59
|
+
return super.off(event, listener)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
override once<K extends keyof WebexMessageHandlerEvents>(event: K, listener: WebexMessageHandlerEvents[K]): this {
|
|
63
|
+
return super.once(event, listener)
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
describe('WebexListener', () => {
|
|
68
|
+
it('bridges handler message events and webex_event', async () => {
|
|
69
|
+
const handler = new FakeWebexMessageHandler()
|
|
70
|
+
const client = { getToken: () => 'token123' }
|
|
71
|
+
const listener = new WebexListener(client, {
|
|
72
|
+
_handlerFactory: (_config: WebexMessageHandlerConfig) => handler,
|
|
73
|
+
})
|
|
74
|
+
const messageCreated = mock((_event: DecryptedMessage) => undefined)
|
|
75
|
+
const webexEvent = mock((_event: DecryptedMessage) => undefined)
|
|
76
|
+
listener.on('message_created', messageCreated)
|
|
77
|
+
listener.on('webex_event', webexEvent)
|
|
78
|
+
|
|
79
|
+
await listener.start()
|
|
80
|
+
handler.emit('message:created', MESSAGE)
|
|
81
|
+
|
|
82
|
+
const expected = expect.objectContaining({
|
|
83
|
+
id: toRestId('message-123', 'MESSAGE'),
|
|
84
|
+
roomId: toRestId('room-123', 'ROOM'),
|
|
85
|
+
personId: toRestId('person-123', 'PEOPLE'),
|
|
86
|
+
personEmail: 'user@example.com',
|
|
87
|
+
text: 'hello',
|
|
88
|
+
raw: RAW_ACTIVITY,
|
|
89
|
+
})
|
|
90
|
+
expect(messageCreated).toHaveBeenCalledWith(expected)
|
|
91
|
+
expect(webexEvent).toHaveBeenCalledWith(expected)
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('stop calls handler disconnect', async () => {
|
|
95
|
+
const handler = new FakeWebexMessageHandler()
|
|
96
|
+
const client = { getToken: () => 'token123' }
|
|
97
|
+
const listener = new WebexListener(client, {
|
|
98
|
+
_handlerFactory: (_config: WebexMessageHandlerConfig) => handler,
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
await listener.start()
|
|
102
|
+
await listener.stop()
|
|
103
|
+
|
|
104
|
+
expect(handler.disconnect).toHaveBeenCalled()
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it('start is idempotent', async () => {
|
|
108
|
+
const handler = new FakeWebexMessageHandler()
|
|
109
|
+
const client = { getToken: () => 'token123' }
|
|
110
|
+
const listener = new WebexListener(client, {
|
|
111
|
+
_handlerFactory: (_config: WebexMessageHandlerConfig) => handler,
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
await listener.start()
|
|
115
|
+
await listener.start()
|
|
116
|
+
|
|
117
|
+
expect(handler.connect).toHaveBeenCalledTimes(1)
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
it('start rethrows and resets state when connect fails, allowing retry', async () => {
|
|
121
|
+
const failing = new FakeWebexMessageHandler()
|
|
122
|
+
failing.connect = mock(() => Promise.reject(new Error('device registration failed')))
|
|
123
|
+
const ok = new FakeWebexMessageHandler()
|
|
124
|
+
const handlers = [failing, ok]
|
|
125
|
+
const client = { getToken: () => 'token123' }
|
|
126
|
+
const listener = new WebexListener(client, {
|
|
127
|
+
_handlerFactory: (_config: WebexMessageHandlerConfig) => handlers.shift()!,
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
await expect(listener.start()).rejects.toThrow('device registration failed')
|
|
131
|
+
expect(failing.disconnect).toHaveBeenCalled()
|
|
132
|
+
|
|
133
|
+
await listener.start()
|
|
134
|
+
expect(ok.connect).toHaveBeenCalledTimes(1)
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
it('does not throw when handler emits error with no error listener', async () => {
|
|
138
|
+
const handler = new FakeWebexMessageHandler()
|
|
139
|
+
const client = { getToken: () => 'token123' }
|
|
140
|
+
const listener = new WebexListener(client, {
|
|
141
|
+
_handlerFactory: (_config: WebexMessageHandlerConfig) => handler,
|
|
142
|
+
})
|
|
143
|
+
listener.on('message_created', () => undefined)
|
|
144
|
+
|
|
145
|
+
await listener.start()
|
|
146
|
+
|
|
147
|
+
expect(() => handler.emit('error', new Error('boom'))).not.toThrow()
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
it('ignores stale handler events after stop', async () => {
|
|
151
|
+
const handler = new FakeWebexMessageHandler()
|
|
152
|
+
const client = { getToken: () => 'token123' }
|
|
153
|
+
const listener = new WebexListener(client, {
|
|
154
|
+
_handlerFactory: (_config: WebexMessageHandlerConfig) => handler,
|
|
155
|
+
})
|
|
156
|
+
const messageCreated = mock((_event: DecryptedMessage) => undefined)
|
|
157
|
+
listener.on('message_created', messageCreated)
|
|
158
|
+
|
|
159
|
+
await listener.start()
|
|
160
|
+
await listener.stop()
|
|
161
|
+
handler.emit('message:created', MESSAGE)
|
|
162
|
+
|
|
163
|
+
expect(messageCreated).not.toHaveBeenCalled()
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
it('start-stop-start does not cross-talk between handlers', async () => {
|
|
167
|
+
const first = new FakeWebexMessageHandler()
|
|
168
|
+
const second = new FakeWebexMessageHandler()
|
|
169
|
+
const handlers = [first, second]
|
|
170
|
+
const client = { getToken: () => 'token123' }
|
|
171
|
+
const listener = new WebexListener(client, {
|
|
172
|
+
_handlerFactory: (_config: WebexMessageHandlerConfig) => handlers.shift()!,
|
|
173
|
+
})
|
|
174
|
+
const messageCreated = mock((_event: DecryptedMessage) => undefined)
|
|
175
|
+
listener.on('message_created', messageCreated)
|
|
176
|
+
|
|
177
|
+
await listener.start()
|
|
178
|
+
await listener.stop()
|
|
179
|
+
await listener.start()
|
|
180
|
+
|
|
181
|
+
first.emit('message:created', MESSAGE)
|
|
182
|
+
expect(messageCreated).not.toHaveBeenCalled()
|
|
183
|
+
|
|
184
|
+
second.emit('message:created', MESSAGE)
|
|
185
|
+
expect(messageCreated).toHaveBeenCalledTimes(1)
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
it('preserves disconnected reason', async () => {
|
|
189
|
+
const handler = new FakeWebexMessageHandler()
|
|
190
|
+
const client = { getToken: () => 'token123' }
|
|
191
|
+
const listener = new WebexListener(client, {
|
|
192
|
+
_handlerFactory: (_config: WebexMessageHandlerConfig) => handler,
|
|
193
|
+
})
|
|
194
|
+
const disconnected = mock((_reason: string) => undefined)
|
|
195
|
+
listener.on('disconnected', disconnected)
|
|
196
|
+
|
|
197
|
+
await listener.start()
|
|
198
|
+
handler.emit('disconnected', 'network lost')
|
|
199
|
+
|
|
200
|
+
expect(disconnected).toHaveBeenCalledWith('network lost')
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
it('concurrent start() calls share the same connect failure', async () => {
|
|
204
|
+
const handler = new FakeWebexMessageHandler()
|
|
205
|
+
handler.connect = mock(() => Promise.reject(new Error('connect failed')))
|
|
206
|
+
const client = { getToken: () => 'token123' }
|
|
207
|
+
const listener = new WebexListener(client, {
|
|
208
|
+
_handlerFactory: (_config: WebexMessageHandlerConfig) => handler,
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
const first = listener.start()
|
|
212
|
+
const second = listener.start()
|
|
213
|
+
const firstResult = first.then(
|
|
214
|
+
() => 'ok',
|
|
215
|
+
(e: Error) => e.message,
|
|
216
|
+
)
|
|
217
|
+
const secondResult = second.then(
|
|
218
|
+
() => 'ok',
|
|
219
|
+
(e: Error) => e.message,
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
expect(await firstResult).toBe('connect failed')
|
|
223
|
+
expect(await secondResult).toBe('connect failed')
|
|
224
|
+
expect(handler.connect).toHaveBeenCalledTimes(1)
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
it('disconnects a handler whose connect resolves after stop', async () => {
|
|
228
|
+
const handler = new FakeWebexMessageHandler()
|
|
229
|
+
let resolveConnect: () => void = () => undefined
|
|
230
|
+
handler.connect = mock(() => new Promise<void>((resolve) => (resolveConnect = resolve)))
|
|
231
|
+
const client = { getToken: () => 'token123' }
|
|
232
|
+
const listener = new WebexListener(client, {
|
|
233
|
+
_handlerFactory: (_config: WebexMessageHandlerConfig) => handler,
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
const starting = listener.start()
|
|
237
|
+
const stopping = listener.stop()
|
|
238
|
+
resolveConnect()
|
|
239
|
+
await Promise.all([starting, stopping])
|
|
240
|
+
|
|
241
|
+
expect(handler.disconnect).toHaveBeenCalled()
|
|
242
|
+
})
|
|
243
|
+
})
|