agent-messenger 2.20.5 → 2.22.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 (137) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +8 -5
  3. package/dist/package.json +9 -1
  4. package/dist/src/cli.d.ts.map +1 -1
  5. package/dist/src/cli.js +3 -0
  6. package/dist/src/cli.js.map +1 -1
  7. package/dist/src/platforms/webex/client.d.ts +19 -0
  8. package/dist/src/platforms/webex/client.d.ts.map +1 -1
  9. package/dist/src/platforms/webex/client.js +81 -1
  10. package/dist/src/platforms/webex/client.js.map +1 -1
  11. package/dist/src/platforms/webexbot/cli.d.ts +5 -0
  12. package/dist/src/platforms/webexbot/cli.d.ts.map +1 -0
  13. package/dist/src/platforms/webexbot/cli.js +33 -0
  14. package/dist/src/platforms/webexbot/cli.js.map +1 -0
  15. package/dist/src/platforms/webexbot/client.d.ts +61 -0
  16. package/dist/src/platforms/webexbot/client.d.ts.map +1 -0
  17. package/dist/src/platforms/webexbot/client.js +80 -0
  18. package/dist/src/platforms/webexbot/client.js.map +1 -0
  19. package/dist/src/platforms/webexbot/commands/auth.d.ts +28 -0
  20. package/dist/src/platforms/webexbot/commands/auth.d.ts.map +1 -0
  21. package/dist/src/platforms/webexbot/commands/auth.js +166 -0
  22. package/dist/src/platforms/webexbot/commands/auth.js.map +1 -0
  23. package/dist/src/platforms/webexbot/commands/file.d.ts +22 -0
  24. package/dist/src/platforms/webexbot/commands/file.d.ts.map +1 -0
  25. package/dist/src/platforms/webexbot/commands/file.js +64 -0
  26. package/dist/src/platforms/webexbot/commands/file.js.map +1 -0
  27. package/dist/src/platforms/webexbot/commands/index.d.ts +10 -0
  28. package/dist/src/platforms/webexbot/commands/index.d.ts.map +1 -0
  29. package/dist/src/platforms/webexbot/commands/index.js +10 -0
  30. package/dist/src/platforms/webexbot/commands/index.js.map +1 -0
  31. package/dist/src/platforms/webexbot/commands/listen.d.ts +12 -0
  32. package/dist/src/platforms/webexbot/commands/listen.d.ts.map +1 -0
  33. package/dist/src/platforms/webexbot/commands/listen.js +85 -0
  34. package/dist/src/platforms/webexbot/commands/listen.js.map +1 -0
  35. package/dist/src/platforms/webexbot/commands/member.d.ts +19 -0
  36. package/dist/src/platforms/webexbot/commands/member.d.ts.map +1 -0
  37. package/dist/src/platforms/webexbot/commands/member.js +33 -0
  38. package/dist/src/platforms/webexbot/commands/member.js.map +1 -0
  39. package/dist/src/platforms/webexbot/commands/message.d.ts +44 -0
  40. package/dist/src/platforms/webexbot/commands/message.d.ts.map +1 -0
  41. package/dist/src/platforms/webexbot/commands/message.js +193 -0
  42. package/dist/src/platforms/webexbot/commands/message.js.map +1 -0
  43. package/dist/src/platforms/webexbot/commands/shared.d.ts +9 -0
  44. package/dist/src/platforms/webexbot/commands/shared.d.ts.map +1 -0
  45. package/dist/src/platforms/webexbot/commands/shared.js +13 -0
  46. package/dist/src/platforms/webexbot/commands/shared.js.map +1 -0
  47. package/dist/src/platforms/webexbot/commands/snapshot.d.ts +24 -0
  48. package/dist/src/platforms/webexbot/commands/snapshot.d.ts.map +1 -0
  49. package/dist/src/platforms/webexbot/commands/snapshot.js +37 -0
  50. package/dist/src/platforms/webexbot/commands/snapshot.js.map +1 -0
  51. package/dist/src/platforms/webexbot/commands/space.d.ts +28 -0
  52. package/dist/src/platforms/webexbot/commands/space.d.ts.map +1 -0
  53. package/dist/src/platforms/webexbot/commands/space.js +61 -0
  54. package/dist/src/platforms/webexbot/commands/space.js.map +1 -0
  55. package/dist/src/platforms/webexbot/commands/user.d.ts +30 -0
  56. package/dist/src/platforms/webexbot/commands/user.d.ts.map +1 -0
  57. package/dist/src/platforms/webexbot/commands/user.js +66 -0
  58. package/dist/src/platforms/webexbot/commands/user.js.map +1 -0
  59. package/dist/src/platforms/webexbot/commands/whoami.d.ts +16 -0
  60. package/dist/src/platforms/webexbot/commands/whoami.d.ts.map +1 -0
  61. package/dist/src/platforms/webexbot/commands/whoami.js +29 -0
  62. package/dist/src/platforms/webexbot/commands/whoami.js.map +1 -0
  63. package/dist/src/platforms/webexbot/credential-manager.d.ts +17 -0
  64. package/dist/src/platforms/webexbot/credential-manager.d.ts.map +1 -0
  65. package/dist/src/platforms/webexbot/credential-manager.js +120 -0
  66. package/dist/src/platforms/webexbot/credential-manager.js.map +1 -0
  67. package/dist/src/platforms/webexbot/index.d.ts +9 -0
  68. package/dist/src/platforms/webexbot/index.d.ts.map +1 -0
  69. package/dist/src/platforms/webexbot/index.js +6 -0
  70. package/dist/src/platforms/webexbot/index.js.map +1 -0
  71. package/dist/src/platforms/webexbot/listener.d.ts +44 -0
  72. package/dist/src/platforms/webexbot/listener.d.ts.map +1 -0
  73. package/dist/src/platforms/webexbot/listener.js +214 -0
  74. package/dist/src/platforms/webexbot/listener.js.map +1 -0
  75. package/dist/src/platforms/webexbot/types.d.ts +60 -0
  76. package/dist/src/platforms/webexbot/types.d.ts.map +1 -0
  77. package/dist/src/platforms/webexbot/types.js +28 -0
  78. package/dist/src/platforms/webexbot/types.js.map +1 -0
  79. package/dist/src/platforms/webexbot/wdm-discovery.d.ts +4 -0
  80. package/dist/src/platforms/webexbot/wdm-discovery.d.ts.map +1 -0
  81. package/dist/src/platforms/webexbot/wdm-discovery.js +36 -0
  82. package/dist/src/platforms/webexbot/wdm-discovery.js.map +1 -0
  83. package/docs/content/docs/cli/meta.json +1 -0
  84. package/docs/content/docs/cli/webexbot.mdx +292 -0
  85. package/docs/content/docs/sdk/meta.json +1 -0
  86. package/docs/content/docs/sdk/webexbot.mdx +342 -0
  87. package/docs/src/app/page.tsx +115 -19
  88. package/package.json +9 -1
  89. package/skills/agent-channeltalk/SKILL.md +1 -1
  90. package/skills/agent-channeltalkbot/SKILL.md +1 -1
  91. package/skills/agent-discord/SKILL.md +1 -1
  92. package/skills/agent-discordbot/SKILL.md +1 -1
  93. package/skills/agent-instagram/SKILL.md +1 -1
  94. package/skills/agent-kakaotalk/SKILL.md +1 -1
  95. package/skills/agent-line/SKILL.md +1 -1
  96. package/skills/agent-slack/SKILL.md +1 -1
  97. package/skills/agent-slackbot/SKILL.md +1 -1
  98. package/skills/agent-teams/SKILL.md +1 -1
  99. package/skills/agent-telegram/SKILL.md +1 -1
  100. package/skills/agent-telegrambot/SKILL.md +1 -1
  101. package/skills/agent-webex/SKILL.md +1 -1
  102. package/skills/agent-webexbot/SKILL.md +414 -0
  103. package/skills/agent-webexbot/references/authentication.md +225 -0
  104. package/skills/agent-webexbot/references/common-patterns.md +708 -0
  105. package/skills/agent-wechatbot/SKILL.md +1 -1
  106. package/skills/agent-whatsapp/SKILL.md +1 -1
  107. package/skills/agent-whatsappbot/SKILL.md +1 -1
  108. package/src/cli.ts +4 -0
  109. package/src/platforms/webex/client.test.ts +10 -0
  110. package/src/platforms/webex/client.ts +97 -3
  111. package/src/platforms/webex/typings/webex-message-handler.d.ts +360 -29
  112. package/src/platforms/webexbot/cli.ts +48 -0
  113. package/src/platforms/webexbot/client.test.ts +198 -0
  114. package/src/platforms/webexbot/client.ts +113 -0
  115. package/src/platforms/webexbot/commands/auth.test.ts +185 -0
  116. package/src/platforms/webexbot/commands/auth.ts +210 -0
  117. package/src/platforms/webexbot/commands/file.ts +104 -0
  118. package/src/platforms/webexbot/commands/index.ts +9 -0
  119. package/src/platforms/webexbot/commands/listen.test.ts +20 -0
  120. package/src/platforms/webexbot/commands/listen.ts +104 -0
  121. package/src/platforms/webexbot/commands/member.ts +51 -0
  122. package/src/platforms/webexbot/commands/message.ts +263 -0
  123. package/src/platforms/webexbot/commands/shared.ts +22 -0
  124. package/src/platforms/webexbot/commands/snapshot.ts +60 -0
  125. package/src/platforms/webexbot/commands/space.ts +88 -0
  126. package/src/platforms/webexbot/commands/user.test.ts +77 -0
  127. package/src/platforms/webexbot/commands/user.ts +98 -0
  128. package/src/platforms/webexbot/commands/whoami.ts +43 -0
  129. package/src/platforms/webexbot/credential-manager.test.ts +182 -0
  130. package/src/platforms/webexbot/credential-manager.ts +149 -0
  131. package/src/platforms/webexbot/index.ts +8 -0
  132. package/src/platforms/webexbot/listener.test.ts +234 -0
  133. package/src/platforms/webexbot/listener.ts +255 -0
  134. package/src/platforms/webexbot/types.test.ts +87 -0
  135. package/src/platforms/webexbot/types.ts +72 -0
  136. package/src/platforms/webexbot/wdm-discovery.test.ts +97 -0
  137. package/src/platforms/webexbot/wdm-discovery.ts +43 -0
@@ -0,0 +1,255 @@
1
+ import { EventEmitter } from 'events'
2
+
3
+ import { WebexMessageHandler } from 'webex-message-handler'
4
+ import type {
5
+ AttachmentAction,
6
+ DecryptedMessage,
7
+ DeletedMessage,
8
+ HandlerStatus,
9
+ InjectedWebSocket,
10
+ MembershipActivity,
11
+ RoomActivity,
12
+ WebexMessageHandlerConfig,
13
+ WebexMessageHandlerEvents,
14
+ } from 'webex-message-handler'
15
+ import WebSocket from 'ws'
16
+
17
+ import type { WebexBotClient } from './client'
18
+ import type { WebexBotListenerEventMap } from './types'
19
+ import { createWdmRewriteFetch, discoverWdmDevicesUrl } from './wdm-discovery'
20
+
21
+ type EventKey = keyof WebexBotListenerEventMap
22
+ type WebexBotClientLike = Pick<WebexBotClient, 'getToken'>
23
+
24
+ interface WebexMessageHandlerLike {
25
+ connect(): Promise<void>
26
+ disconnect(): Promise<void>
27
+ status(): HandlerStatus
28
+ get connected(): boolean
29
+ on<K extends keyof WebexMessageHandlerEvents>(event: K, listener: WebexMessageHandlerEvents[K]): this
30
+ off<K extends keyof WebexMessageHandlerEvents>(event: K, listener: WebexMessageHandlerEvents[K]): this
31
+ once<K extends keyof WebexMessageHandlerEvents>(event: K, listener: WebexMessageHandlerEvents[K]): this
32
+ }
33
+
34
+ export interface WebexBotListenerOptions {
35
+ ignoreSelfMessages?: boolean
36
+ pingInterval?: number
37
+ pongTimeout?: number
38
+ reconnectBackoffMax?: number
39
+ maxReconnectAttempts?: number
40
+ _handlerFactory?: (config: WebexMessageHandlerConfig) => WebexMessageHandlerLike
41
+ }
42
+
43
+ export class WebexBotListener {
44
+ private client: WebexBotClientLike
45
+ private options: WebexBotListenerOptions
46
+ private running = false
47
+ private emitter = new EventEmitter()
48
+ private handler: WebexMessageHandlerLike | null = null
49
+ private generation = 0
50
+ private detachHandler: (() => void) | null = null
51
+ private startPromise: Promise<void> | null = null
52
+
53
+ constructor(client: WebexBotClientLike, options: WebexBotListenerOptions = {}) {
54
+ this.client = client
55
+ this.options = options
56
+ }
57
+
58
+ async start(): Promise<void> {
59
+ if (this.startPromise) return this.startPromise
60
+ if (this.running) return
61
+
62
+ this.running = true
63
+ const generation = ++this.generation
64
+
65
+ let startPromise!: Promise<void>
66
+ startPromise = (async () => {
67
+ let handler: WebexMessageHandlerLike | null = null
68
+ try {
69
+ handler = await this.createHandler()
70
+
71
+ if (!this.running || this.generation !== generation) {
72
+ await handler.disconnect().catch(() => undefined)
73
+ return
74
+ }
75
+ this.handler = handler
76
+ this.detachHandler = this.wireHandler(handler, generation)
77
+
78
+ await handler.connect()
79
+
80
+ if (!this.running || this.handler !== handler || this.generation !== generation) {
81
+ await handler.disconnect().catch(() => undefined)
82
+ return
83
+ }
84
+ } catch (error) {
85
+ if (this.handler === handler && this.generation === generation) {
86
+ this.detachHandler?.()
87
+ this.detachHandler = null
88
+ this.handler = null
89
+ }
90
+ if (this.generation === generation) {
91
+ this.running = false
92
+ }
93
+ await handler?.disconnect().catch(() => undefined)
94
+ throw error
95
+ } finally {
96
+ if (this.startPromise === startPromise) {
97
+ this.startPromise = null
98
+ }
99
+ }
100
+ })()
101
+
102
+ this.startPromise = startPromise
103
+ return startPromise
104
+ }
105
+
106
+ async stop(): Promise<void> {
107
+ const pendingStart = this.startPromise
108
+ this.startPromise = null
109
+
110
+ this.running = false
111
+ this.generation++
112
+
113
+ const detach = this.detachHandler
114
+ this.detachHandler = null
115
+ detach?.()
116
+
117
+ const handler = this.handler
118
+ this.handler = null
119
+ if (handler) {
120
+ await handler.disconnect().catch(() => undefined)
121
+ }
122
+
123
+ if (pendingStart) {
124
+ await pendingStart.catch(() => undefined)
125
+ }
126
+ }
127
+
128
+ on<K extends EventKey>(event: K, listener: (...args: WebexBotListenerEventMap[K]) => void): this {
129
+ this.emitter.on(event, listener as (...args: any[]) => void)
130
+ return this
131
+ }
132
+
133
+ off<K extends EventKey>(event: K, listener: (...args: WebexBotListenerEventMap[K]) => void): this {
134
+ this.emitter.off(event, listener as (...args: any[]) => void)
135
+ return this
136
+ }
137
+
138
+ once<K extends EventKey>(event: K, listener: (...args: WebexBotListenerEventMap[K]) => void): this {
139
+ this.emitter.once(event, listener as (...args: any[]) => void)
140
+ return this
141
+ }
142
+
143
+ private isCurrent(handler: WebexMessageHandlerLike, generation: number): boolean {
144
+ return this.running && this.handler === handler && this.generation === generation
145
+ }
146
+
147
+ // Node's EventEmitter throws synchronously if an 'error' event is emitted with
148
+ // no registered listener. Guard it so SDK consumers that only subscribe to
149
+ // message events are never crashed by a transient connection error.
150
+ private emitError(error: Error): void {
151
+ if (this.emitter.listenerCount('error') > 0) {
152
+ this.emitter.emit('error', error)
153
+ }
154
+ }
155
+
156
+ private async createHandler(): Promise<WebexMessageHandlerLike> {
157
+ const token = this.client.getToken()
158
+ const config: WebexMessageHandlerConfig = { token }
159
+ if (this.options.ignoreSelfMessages !== undefined) config.ignoreSelfMessages = this.options.ignoreSelfMessages
160
+ if (this.options.pingInterval !== undefined) config.pingInterval = this.options.pingInterval
161
+ if (this.options.pongTimeout !== undefined) config.pongTimeout = this.options.pongTimeout
162
+ if (this.options.reconnectBackoffMax !== undefined) config.reconnectBackoffMax = this.options.reconnectBackoffMax
163
+ if (this.options.maxReconnectAttempts !== undefined) config.maxReconnectAttempts = this.options.maxReconnectAttempts
164
+
165
+ if (this.options._handlerFactory) {
166
+ return this.options._handlerFactory(config)
167
+ }
168
+
169
+ const wdmDevicesUrl = await discoverWdmDevicesUrl(token)
170
+ config.mode = 'injected'
171
+ config.fetch = createWdmRewriteFetch(wdmDevicesUrl)
172
+ config.webSocketFactory = (url: string) => new WebSocket(url) as unknown as InjectedWebSocket
173
+ return new WebexMessageHandler(config)
174
+ }
175
+
176
+ private wireHandler(handler: WebexMessageHandlerLike, generation: number): () => void {
177
+ const onMessageCreated = (event: DecryptedMessage) => {
178
+ if (!this.isCurrent(handler, generation)) return
179
+ this.emitter.emit('message_created', event)
180
+ this.emitter.emit('webex_event', event)
181
+ }
182
+ const onMessageUpdated = (event: DecryptedMessage) => {
183
+ if (!this.isCurrent(handler, generation)) return
184
+ this.emitter.emit('message_updated', event)
185
+ this.emitter.emit('webex_event', event)
186
+ }
187
+ const onMessageDeleted = (event: DeletedMessage) => {
188
+ if (!this.isCurrent(handler, generation)) return
189
+ this.emitter.emit('message_deleted', event)
190
+ this.emitter.emit('webex_event', event)
191
+ }
192
+ const onMembershipCreated = (event: MembershipActivity) => {
193
+ if (!this.isCurrent(handler, generation)) return
194
+ this.emitter.emit('membership_created', event)
195
+ this.emitter.emit('webex_event', event)
196
+ }
197
+ const onAttachmentAction = (event: AttachmentAction) => {
198
+ if (!this.isCurrent(handler, generation)) return
199
+ this.emitter.emit('attachment_action', event)
200
+ this.emitter.emit('webex_event', event)
201
+ }
202
+ const onRoomCreated = (event: RoomActivity) => {
203
+ if (!this.isCurrent(handler, generation)) return
204
+ this.emitter.emit('room_created', event)
205
+ this.emitter.emit('webex_event', event)
206
+ }
207
+ const onRoomUpdated = (event: RoomActivity) => {
208
+ if (!this.isCurrent(handler, generation)) return
209
+ this.emitter.emit('room_updated', event)
210
+ this.emitter.emit('webex_event', event)
211
+ }
212
+ const onConnected = () => {
213
+ if (!this.isCurrent(handler, generation)) return
214
+ this.emitter.emit('connected', { connected: handler.connected, status: handler.status() })
215
+ }
216
+ const onReconnecting = (attempt: number) => {
217
+ if (!this.isCurrent(handler, generation)) return
218
+ this.emitter.emit('reconnecting', attempt)
219
+ }
220
+ const onDisconnected = (reason: string) => {
221
+ if (!this.isCurrent(handler, generation)) return
222
+ this.emitter.emit('disconnected', reason)
223
+ }
224
+ const onError = (error: Error) => {
225
+ if (!this.isCurrent(handler, generation)) return
226
+ this.emitError(error)
227
+ }
228
+
229
+ handler.on('message:created', onMessageCreated)
230
+ handler.on('message:updated', onMessageUpdated)
231
+ handler.on('message:deleted', onMessageDeleted)
232
+ handler.on('membership:created', onMembershipCreated)
233
+ handler.on('attachmentAction:created', onAttachmentAction)
234
+ handler.on('room:created', onRoomCreated)
235
+ handler.on('room:updated', onRoomUpdated)
236
+ handler.on('connected', onConnected)
237
+ handler.on('reconnecting', onReconnecting)
238
+ handler.on('disconnected', onDisconnected)
239
+ handler.on('error', onError)
240
+
241
+ return () => {
242
+ handler.off('message:created', onMessageCreated)
243
+ handler.off('message:updated', onMessageUpdated)
244
+ handler.off('message:deleted', onMessageDeleted)
245
+ handler.off('membership:created', onMembershipCreated)
246
+ handler.off('attachmentAction:created', onAttachmentAction)
247
+ handler.off('room:created', onRoomCreated)
248
+ handler.off('room:updated', onRoomUpdated)
249
+ handler.off('connected', onConnected)
250
+ handler.off('reconnecting', onReconnecting)
251
+ handler.off('disconnected', onDisconnected)
252
+ handler.off('error', onError)
253
+ }
254
+ }
255
+ }
@@ -0,0 +1,87 @@
1
+ import { describe, expect, it } from 'bun:test'
2
+
3
+ import { WebexBotConfigSchema, WebexBotCredentialsSchema, WebexBotEntrySchema, WebexBotError } from './types'
4
+
5
+ describe('WebexBotError', () => {
6
+ it('creates error with message and code', () => {
7
+ const error = new WebexBotError('Test error', 'TEST_CODE')
8
+ expect(error.message).toBe('Test error')
9
+ expect(error.code).toBe('TEST_CODE')
10
+ expect(error.name).toBe('WebexBotError')
11
+ })
12
+ })
13
+
14
+ describe('WebexBotEntrySchema', () => {
15
+ it('validates correct bot entry', () => {
16
+ const data = {
17
+ bot_id: 'bot123',
18
+ bot_name: 'My Bot',
19
+ token: 'token123',
20
+ }
21
+
22
+ const result = WebexBotEntrySchema.safeParse(data)
23
+
24
+ expect(result.success).toBe(true)
25
+ })
26
+
27
+ it('rejects missing token', () => {
28
+ const data = {
29
+ bot_id: 'bot123',
30
+ bot_name: 'My Bot',
31
+ }
32
+
33
+ const result = WebexBotEntrySchema.safeParse(data)
34
+
35
+ expect(result.success).toBe(false)
36
+ })
37
+ })
38
+
39
+ describe('WebexBotConfigSchema', () => {
40
+ it('validates correct config with bots', () => {
41
+ const data = {
42
+ current: { bot_id: 'bot123' },
43
+ bots: {
44
+ bot123: {
45
+ bot_id: 'bot123',
46
+ bot_name: 'My Bot',
47
+ token: 'token123',
48
+ },
49
+ },
50
+ }
51
+
52
+ const result = WebexBotConfigSchema.safeParse(data)
53
+
54
+ expect(result.success).toBe(true)
55
+ })
56
+
57
+ it('validates config with null current', () => {
58
+ const result = WebexBotConfigSchema.safeParse({ current: null, bots: {} })
59
+
60
+ expect(result.success).toBe(true)
61
+ })
62
+ })
63
+
64
+ describe('WebexBotCredentialsSchema', () => {
65
+ it('validates credentials with required fields', () => {
66
+ const data = {
67
+ token: 'token123',
68
+ bot_id: 'bot123',
69
+ bot_name: 'My Bot',
70
+ }
71
+
72
+ const result = WebexBotCredentialsSchema.safeParse(data)
73
+
74
+ expect(result.success).toBe(true)
75
+ })
76
+
77
+ it('rejects missing bot_id', () => {
78
+ const data = {
79
+ token: 'token123',
80
+ bot_name: 'My Bot',
81
+ }
82
+
83
+ const result = WebexBotCredentialsSchema.safeParse(data)
84
+
85
+ expect(result.success).toBe(false)
86
+ })
87
+ })
@@ -0,0 +1,72 @@
1
+ import type {
2
+ AttachmentAction,
3
+ DecryptedMessage,
4
+ DeletedMessage,
5
+ HandlerStatus,
6
+ MembershipActivity,
7
+ RoomActivity,
8
+ } from 'webex-message-handler'
9
+ import { z } from 'zod'
10
+
11
+ export interface WebexBotEntry {
12
+ bot_id: string
13
+ bot_name: string
14
+ token: string
15
+ }
16
+
17
+ export interface WebexBotConfig {
18
+ current: { bot_id: string } | null
19
+ bots: Record<string, WebexBotEntry>
20
+ }
21
+
22
+ export interface WebexBotCredentials {
23
+ token: string
24
+ bot_id: string
25
+ bot_name: string
26
+ }
27
+
28
+ export class WebexBotError extends Error {
29
+ code: string
30
+
31
+ constructor(message: string, code: string) {
32
+ super(message)
33
+ this.name = 'WebexBotError'
34
+ this.code = code
35
+ }
36
+ }
37
+
38
+ export const WebexBotEntrySchema = z.object({
39
+ bot_id: z.string(),
40
+ bot_name: z.string(),
41
+ token: z.string(),
42
+ })
43
+
44
+ export const WebexBotConfigSchema = z.object({
45
+ current: z
46
+ .object({
47
+ bot_id: z.string(),
48
+ })
49
+ .nullable(),
50
+ bots: z.record(z.string(), WebexBotEntrySchema),
51
+ })
52
+
53
+ export const WebexBotCredentialsSchema = z.object({
54
+ token: z.string(),
55
+ bot_id: z.string(),
56
+ bot_name: z.string(),
57
+ })
58
+
59
+ export interface WebexBotListenerEventMap {
60
+ message_created: [event: DecryptedMessage]
61
+ message_updated: [event: DecryptedMessage]
62
+ message_deleted: [event: DeletedMessage]
63
+ membership_created: [event: MembershipActivity]
64
+ attachment_action: [event: AttachmentAction]
65
+ room_created: [event: RoomActivity]
66
+ room_updated: [event: RoomActivity]
67
+ webex_event: [event: DecryptedMessage | DeletedMessage | MembershipActivity | AttachmentAction | RoomActivity]
68
+ connected: [info: { connected: boolean; status: HandlerStatus }]
69
+ reconnecting: [attempt: number]
70
+ disconnected: [reason: string]
71
+ error: [error: Error]
72
+ }
@@ -0,0 +1,97 @@
1
+ import { afterEach, describe, expect, it, mock } from 'bun:test'
2
+
3
+ import { createWdmRewriteFetch, discoverWdmDevicesUrl } from './wdm-discovery'
4
+
5
+ const realFetch = globalThis.fetch
6
+
7
+ afterEach(() => {
8
+ globalThis.fetch = realFetch
9
+ })
10
+
11
+ function jsonResponse(body: unknown, ok = true, status = 200): Response {
12
+ return { ok, status, json: () => Promise.resolve(body) } as unknown as Response
13
+ }
14
+
15
+ describe('discoverWdmDevicesUrl', () => {
16
+ it('returns the U2C wdm serviceLink with /devices appended', async () => {
17
+ globalThis.fetch = mock(() =>
18
+ Promise.resolve(jsonResponse({ serviceLinks: { wdm: 'https://wdm-r.wbx2.com/wdm/api/v1' } })),
19
+ )
20
+
21
+ const url = await discoverWdmDevicesUrl('token123')
22
+
23
+ expect(url).toBe('https://wdm-r.wbx2.com/wdm/api/v1/devices')
24
+ })
25
+
26
+ it('strips a trailing slash before appending /devices', async () => {
27
+ globalThis.fetch = mock(() =>
28
+ Promise.resolve(jsonResponse({ serviceLinks: { wdm: 'https://wdm-r.wbx2.com/wdm/api/v1/' } })),
29
+ )
30
+
31
+ const url = await discoverWdmDevicesUrl('token123')
32
+
33
+ expect(url).toBe('https://wdm-r.wbx2.com/wdm/api/v1/devices')
34
+ })
35
+
36
+ it('throws when the catalog request fails', async () => {
37
+ globalThis.fetch = mock(() => Promise.resolve(jsonResponse({}, false, 401)))
38
+
39
+ await expect(discoverWdmDevicesUrl('bad')).rejects.toThrow('Failed to discover Webex WDM cluster')
40
+ })
41
+
42
+ it('throws when the catalog lacks serviceLinks.wdm', async () => {
43
+ globalThis.fetch = mock(() => Promise.resolve(jsonResponse({ serviceLinks: {} })))
44
+
45
+ await expect(discoverWdmDevicesUrl('token123')).rejects.toThrow('did not include serviceLinks.wdm')
46
+ })
47
+ })
48
+
49
+ describe('createWdmRewriteFetch', () => {
50
+ it('rewrites the hardcoded WDM device-registration URL to the discovered cluster', async () => {
51
+ const seen: string[] = []
52
+ globalThis.fetch = mock((url: string) => {
53
+ seen.push(url)
54
+ return Promise.resolve(jsonResponse({ ok: true }))
55
+ })
56
+
57
+ const fetchFn = createWdmRewriteFetch('https://wdm-r.wbx2.com/wdm/api/v1/devices')
58
+ await fetchFn({
59
+ url: 'https://wdm-a.wbx2.com/wdm/api/v1/devices',
60
+ method: 'POST',
61
+ headers: { Authorization: 'Bearer x' },
62
+ body: '{}',
63
+ })
64
+
65
+ expect(seen[0]).toBe('https://wdm-r.wbx2.com/wdm/api/v1/devices')
66
+ })
67
+
68
+ it('rewrites WDM device sub-paths too', async () => {
69
+ const seen: string[] = []
70
+ globalThis.fetch = mock((url: string) => {
71
+ seen.push(url)
72
+ return Promise.resolve(jsonResponse({}))
73
+ })
74
+
75
+ const fetchFn = createWdmRewriteFetch('https://wdm-r.wbx2.com/wdm/api/v1/devices')
76
+ await fetchFn({
77
+ url: 'https://wdm-a.wbx2.com/wdm/api/v1/devices/abc-123',
78
+ method: 'DELETE',
79
+ headers: {},
80
+ })
81
+
82
+ expect(seen[0]).toBe('https://wdm-r.wbx2.com/wdm/api/v1/devices/abc-123')
83
+ })
84
+
85
+ it('leaves non-WDM URLs untouched', async () => {
86
+ const seen: string[] = []
87
+ globalThis.fetch = mock((url: string) => {
88
+ seen.push(url)
89
+ return Promise.resolve(jsonResponse({}))
90
+ })
91
+
92
+ const fetchFn = createWdmRewriteFetch('https://wdm-r.wbx2.com/wdm/api/v1/devices')
93
+ await fetchFn({ url: 'https://webexapis.com/v1/people/me', method: 'GET', headers: {} })
94
+
95
+ expect(seen[0]).toBe('https://webexapis.com/v1/people/me')
96
+ })
97
+ })
@@ -0,0 +1,43 @@
1
+ import type { FetchFunction, FetchRequest, FetchResponse } from 'webex-message-handler'
2
+
3
+ import { WebexBotError } from './types'
4
+
5
+ const U2C_CATALOG_URL = 'https://u2c.wbx2.com/u2c/api/v1/catalog?format=hostmap'
6
+ const HARDCODED_WDM_DEVICES_URL = 'https://wdm-a.wbx2.com/wdm/api/v1/devices'
7
+
8
+ // webex-message-handler hardcodes the WDM cluster to wdm-a. A bot whose org lives
9
+ // on another cluster (e.g. wdm-r) registers its device on the wrong cluster, so
10
+ // Webex never routes conversation activities to its Mercury socket — the socket
11
+ // connects but no messages arrive. The U2C catalog returns the correct WDM cluster
12
+ // for the token; rewriting the device-registration request to it makes the bot
13
+ // receive real-time events.
14
+ export async function discoverWdmDevicesUrl(token: string): Promise<string> {
15
+ const response = await fetch(U2C_CATALOG_URL, { headers: { Authorization: `Bearer ${token}` } })
16
+ if (!response.ok) {
17
+ throw new WebexBotError(`Failed to discover Webex WDM cluster: HTTP ${response.status}`, 'wdm_discovery_failed')
18
+ }
19
+
20
+ const catalog = (await response.json()) as { serviceLinks?: { wdm?: string } }
21
+ const wdm = catalog.serviceLinks?.wdm
22
+ if (!wdm) {
23
+ throw new WebexBotError('Webex U2C catalog did not include serviceLinks.wdm', 'wdm_discovery_failed')
24
+ }
25
+
26
+ return `${wdm.replace(/\/$/, '')}/devices`
27
+ }
28
+
29
+ export function createWdmRewriteFetch(wdmDevicesUrl: string): FetchFunction {
30
+ return async (req: FetchRequest): Promise<FetchResponse> => {
31
+ const url = req.url.startsWith(HARDCODED_WDM_DEVICES_URL)
32
+ ? wdmDevicesUrl + req.url.slice(HARDCODED_WDM_DEVICES_URL.length)
33
+ : req.url
34
+
35
+ const res = await fetch(url, { method: req.method, headers: req.headers, body: req.body })
36
+ return {
37
+ status: res.status,
38
+ ok: res.ok,
39
+ json: () => res.json(),
40
+ text: () => res.text(),
41
+ }
42
+ }
43
+ }