agent-messenger 2.22.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 (102) 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 +6 -0
  5. package/dist/src/platforms/webex/client.d.ts.map +1 -1
  6. package/dist/src/platforms/webex/client.js +34 -4
  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/client.d.ts +4 -0
  39. package/dist/src/platforms/webexbot/client.d.ts.map +1 -1
  40. package/dist/src/platforms/webexbot/client.js +70 -8
  41. package/dist/src/platforms/webexbot/client.js.map +1 -1
  42. package/dist/src/platforms/webexbot/index.d.ts +2 -0
  43. package/dist/src/platforms/webexbot/index.d.ts.map +1 -1
  44. package/dist/src/platforms/webexbot/index.js +1 -0
  45. package/dist/src/platforms/webexbot/index.js.map +1 -1
  46. package/dist/src/platforms/webexbot/listener.d.ts +3 -41
  47. package/dist/src/platforms/webexbot/listener.d.ts.map +1 -1
  48. package/dist/src/platforms/webexbot/listener.js +13 -208
  49. package/dist/src/platforms/webexbot/listener.js.map +1 -1
  50. package/dist/src/platforms/webexbot/types.d.ts +1 -18
  51. package/dist/src/platforms/webexbot/types.d.ts.map +1 -1
  52. package/dist/src/platforms/webexbot/types.js.map +1 -1
  53. package/docs/content/docs/cli/webex.mdx +38 -12
  54. package/docs/content/docs/sdk/webexbot.mdx +16 -0
  55. package/package.json +1 -1
  56. package/skills/agent-channeltalk/SKILL.md +1 -1
  57. package/skills/agent-channeltalkbot/SKILL.md +1 -1
  58. package/skills/agent-discord/SKILL.md +1 -1
  59. package/skills/agent-discordbot/SKILL.md +1 -1
  60. package/skills/agent-instagram/SKILL.md +1 -1
  61. package/skills/agent-kakaotalk/SKILL.md +1 -1
  62. package/skills/agent-line/SKILL.md +1 -1
  63. package/skills/agent-slack/SKILL.md +1 -1
  64. package/skills/agent-slackbot/SKILL.md +1 -1
  65. package/skills/agent-teams/SKILL.md +1 -1
  66. package/skills/agent-telegram/SKILL.md +1 -1
  67. package/skills/agent-telegrambot/SKILL.md +1 -1
  68. package/skills/agent-webex/SKILL.md +76 -22
  69. package/skills/agent-webex/references/authentication.md +55 -14
  70. package/skills/agent-webex/references/common-patterns.md +5 -2
  71. package/skills/agent-webexbot/SKILL.md +3 -1
  72. package/skills/agent-wechatbot/SKILL.md +1 -1
  73. package/skills/agent-whatsapp/SKILL.md +1 -1
  74. package/skills/agent-whatsappbot/SKILL.md +1 -1
  75. package/src/platforms/webex/cli.test.ts +31 -1
  76. package/src/platforms/webex/client.test.ts +57 -0
  77. package/src/platforms/webex/client.ts +39 -4
  78. package/src/platforms/webex/commands/auth.test.ts +189 -28
  79. package/src/platforms/webex/commands/auth.ts +194 -35
  80. package/src/platforms/webex/credential-manager.test.ts +40 -0
  81. package/src/platforms/webex/credential-manager.ts +7 -4
  82. package/src/platforms/webex/id-normalizer.test.ts +207 -0
  83. package/src/platforms/webex/id-normalizer.ts +76 -0
  84. package/src/platforms/webex/index.test.ts +6 -0
  85. package/src/platforms/webex/index.ts +4 -0
  86. package/src/platforms/webex/listener.test.ts +243 -0
  87. package/src/platforms/webex/listener.ts +285 -0
  88. package/src/platforms/webex/password-login.test.ts +193 -0
  89. package/src/platforms/webex/password-login.ts +332 -0
  90. package/src/platforms/webex/types.test.ts +16 -0
  91. package/src/platforms/webex/types.ts +2 -2
  92. package/src/platforms/{webexbot → webex}/wdm-discovery.ts +3 -3
  93. package/src/platforms/webexbot/client.test.ts +125 -1
  94. package/src/platforms/webexbot/client.ts +79 -8
  95. package/src/platforms/webexbot/index.ts +2 -0
  96. package/src/platforms/webexbot/listener.test.ts +37 -224
  97. package/src/platforms/webexbot/listener.ts +18 -250
  98. package/src/platforms/webexbot/types.ts +2 -23
  99. package/dist/src/platforms/webexbot/wdm-discovery.d.ts.map +0 -1
  100. package/dist/src/platforms/webexbot/wdm-discovery.js.map +0 -1
  101. /package/dist/src/platforms/{webexbot → webex}/wdm-discovery.d.ts +0 -0
  102. /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 refreshed = await this.refreshToken(config.refreshToken, builtinCreds.clientId, builtinCreds.clientSecret)
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: 'extracted' })
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,12 @@ 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
+
18
24
  it('exports Zod schemas', () => {
19
25
  expect(webex.WebexSpaceSchema).toBeDefined()
20
26
  expect(webex.WebexMessageSchema).toBeDefined()
@@ -1,5 +1,9 @@
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'
3
7
  export { WebexTokenExtractor } from './token-extractor'
4
8
  export type { ExtractedWebexToken } from './token-extractor'
5
9
  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
+ })