agent-messenger 2.20.4 → 2.21.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/plugin.json +1 -1
- package/README.md +8 -5
- package/bun.lock +2 -2
- 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/webexbot/cli.d.ts +5 -0
- package/dist/src/platforms/webexbot/cli.d.ts.map +1 -0
- package/dist/src/platforms/webexbot/cli.js +30 -0
- package/dist/src/platforms/webexbot/cli.js.map +1 -0
- package/dist/src/platforms/webexbot/client.d.ts +41 -0
- package/dist/src/platforms/webexbot/client.d.ts.map +1 -0
- package/dist/src/platforms/webexbot/client.js +66 -0
- package/dist/src/platforms/webexbot/client.js.map +1 -0
- package/dist/src/platforms/webexbot/commands/auth.d.ts +28 -0
- package/dist/src/platforms/webexbot/commands/auth.d.ts.map +1 -0
- package/dist/src/platforms/webexbot/commands/auth.js +166 -0
- package/dist/src/platforms/webexbot/commands/auth.js.map +1 -0
- package/dist/src/platforms/webexbot/commands/index.d.ts +7 -0
- package/dist/src/platforms/webexbot/commands/index.d.ts.map +1 -0
- package/dist/src/platforms/webexbot/commands/index.js +7 -0
- package/dist/src/platforms/webexbot/commands/index.js.map +1 -0
- package/dist/src/platforms/webexbot/commands/listen.d.ts +12 -0
- package/dist/src/platforms/webexbot/commands/listen.d.ts.map +1 -0
- package/dist/src/platforms/webexbot/commands/listen.js +85 -0
- package/dist/src/platforms/webexbot/commands/listen.js.map +1 -0
- package/dist/src/platforms/webexbot/commands/member.d.ts +19 -0
- package/dist/src/platforms/webexbot/commands/member.d.ts.map +1 -0
- package/dist/src/platforms/webexbot/commands/member.js +33 -0
- package/dist/src/platforms/webexbot/commands/member.js.map +1 -0
- package/dist/src/platforms/webexbot/commands/message.d.ts +37 -0
- package/dist/src/platforms/webexbot/commands/message.d.ts.map +1 -0
- package/dist/src/platforms/webexbot/commands/message.js +142 -0
- package/dist/src/platforms/webexbot/commands/message.js.map +1 -0
- package/dist/src/platforms/webexbot/commands/shared.d.ts +9 -0
- package/dist/src/platforms/webexbot/commands/shared.d.ts.map +1 -0
- package/dist/src/platforms/webexbot/commands/shared.js +13 -0
- package/dist/src/platforms/webexbot/commands/shared.js.map +1 -0
- package/dist/src/platforms/webexbot/commands/space.d.ts +28 -0
- package/dist/src/platforms/webexbot/commands/space.d.ts.map +1 -0
- package/dist/src/platforms/webexbot/commands/space.js +61 -0
- package/dist/src/platforms/webexbot/commands/space.js.map +1 -0
- package/dist/src/platforms/webexbot/commands/whoami.d.ts +16 -0
- package/dist/src/platforms/webexbot/commands/whoami.d.ts.map +1 -0
- package/dist/src/platforms/webexbot/commands/whoami.js +29 -0
- package/dist/src/platforms/webexbot/commands/whoami.js.map +1 -0
- package/dist/src/platforms/webexbot/credential-manager.d.ts +17 -0
- package/dist/src/platforms/webexbot/credential-manager.d.ts.map +1 -0
- package/dist/src/platforms/webexbot/credential-manager.js +120 -0
- package/dist/src/platforms/webexbot/credential-manager.js.map +1 -0
- package/dist/src/platforms/webexbot/index.d.ts +9 -0
- package/dist/src/platforms/webexbot/index.d.ts.map +1 -0
- package/dist/src/platforms/webexbot/index.js +6 -0
- package/dist/src/platforms/webexbot/index.js.map +1 -0
- package/dist/src/platforms/webexbot/listener.d.ts +44 -0
- package/dist/src/platforms/webexbot/listener.d.ts.map +1 -0
- package/dist/src/platforms/webexbot/listener.js +214 -0
- package/dist/src/platforms/webexbot/listener.js.map +1 -0
- package/dist/src/platforms/webexbot/types.d.ts +60 -0
- package/dist/src/platforms/webexbot/types.d.ts.map +1 -0
- package/dist/src/platforms/webexbot/types.js +28 -0
- package/dist/src/platforms/webexbot/types.js.map +1 -0
- package/dist/src/platforms/webexbot/wdm-discovery.d.ts +4 -0
- package/dist/src/platforms/webexbot/wdm-discovery.d.ts.map +1 -0
- package/dist/src/platforms/webexbot/wdm-discovery.js +36 -0
- package/dist/src/platforms/webexbot/wdm-discovery.js.map +1 -0
- package/docs/content/docs/cli/meta.json +1 -0
- package/docs/content/docs/cli/webexbot.mdx +290 -0
- package/docs/content/docs/sdk/meta.json +1 -0
- package/docs/content/docs/sdk/webexbot.mdx +340 -0
- package/docs/src/app/page.tsx +115 -19
- 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-telegrambot/SKILL.md +1 -1
- package/skills/agent-webex/SKILL.md +1 -1
- package/skills/agent-webexbot/SKILL.md +361 -0
- package/skills/agent-webexbot/references/authentication.md +225 -0
- package/skills/agent-webexbot/references/common-patterns.md +590 -0
- 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/cli.ts +4 -0
- package/src/platforms/webex/typings/webex-message-handler.d.ts +360 -29
- package/src/platforms/webexbot/cli.ts +42 -0
- package/src/platforms/webexbot/client.ts +87 -0
- package/src/platforms/webexbot/commands/auth.test.ts +185 -0
- package/src/platforms/webexbot/commands/auth.ts +210 -0
- package/src/platforms/webexbot/commands/index.ts +6 -0
- package/src/platforms/webexbot/commands/listen.test.ts +20 -0
- package/src/platforms/webexbot/commands/listen.ts +104 -0
- package/src/platforms/webexbot/commands/member.ts +51 -0
- package/src/platforms/webexbot/commands/message.ts +197 -0
- package/src/platforms/webexbot/commands/shared.ts +22 -0
- package/src/platforms/webexbot/commands/space.ts +88 -0
- package/src/platforms/webexbot/commands/whoami.ts +43 -0
- package/src/platforms/webexbot/credential-manager.test.ts +182 -0
- package/src/platforms/webexbot/credential-manager.ts +149 -0
- package/src/platforms/webexbot/index.ts +8 -0
- package/src/platforms/webexbot/listener.test.ts +234 -0
- package/src/platforms/webexbot/listener.ts +255 -0
- package/src/platforms/webexbot/types.test.ts +87 -0
- package/src/platforms/webexbot/types.ts +72 -0
- package/src/platforms/webexbot/wdm-discovery.test.ts +97 -0
- package/src/platforms/webexbot/wdm-discovery.ts +43 -0
|
@@ -0,0 +1,234 @@
|
|
|
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 { WebexBotListener } from './listener'
|
|
13
|
+
|
|
14
|
+
const STATUS: HandlerStatus = {
|
|
15
|
+
status: 'connected',
|
|
16
|
+
webSocketOpen: true,
|
|
17
|
+
kmsInitialized: true,
|
|
18
|
+
deviceRegistered: true,
|
|
19
|
+
reconnectAttempt: 0,
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const RAW_ACTIVITY: MercuryActivity = {
|
|
23
|
+
id: 'activity-123',
|
|
24
|
+
verb: 'post',
|
|
25
|
+
actor: { id: 'person-123', objectType: 'person', emailAddress: 'user@example.com' },
|
|
26
|
+
object: { id: 'object-123', objectType: 'comment', displayName: 'hello' },
|
|
27
|
+
target: { id: 'room-123', objectType: 'conversation' },
|
|
28
|
+
published: '2024-01-01T00:00:00Z',
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const MESSAGE: DecryptedMessage = {
|
|
32
|
+
id: 'message-123',
|
|
33
|
+
roomId: 'room-123',
|
|
34
|
+
personId: 'person-123',
|
|
35
|
+
personEmail: 'user@example.com',
|
|
36
|
+
text: 'hello',
|
|
37
|
+
created: '2024-01-01T00:00:00Z',
|
|
38
|
+
mentionedPeople: [],
|
|
39
|
+
mentionedGroups: [],
|
|
40
|
+
files: [],
|
|
41
|
+
raw: RAW_ACTIVITY,
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
class FakeWebexMessageHandler extends EventEmitter {
|
|
45
|
+
connect = mock(() => Promise.resolve())
|
|
46
|
+
disconnect = mock(() => Promise.resolve())
|
|
47
|
+
connected = true
|
|
48
|
+
|
|
49
|
+
status(): HandlerStatus {
|
|
50
|
+
return STATUS
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
override on<K extends keyof WebexMessageHandlerEvents>(event: K, listener: WebexMessageHandlerEvents[K]): this {
|
|
54
|
+
return super.on(event, listener)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
override off<K extends keyof WebexMessageHandlerEvents>(event: K, listener: WebexMessageHandlerEvents[K]): this {
|
|
58
|
+
return super.off(event, listener)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
override once<K extends keyof WebexMessageHandlerEvents>(event: K, listener: WebexMessageHandlerEvents[K]): this {
|
|
62
|
+
return super.once(event, listener)
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
describe('WebexBotListener', () => {
|
|
67
|
+
it('bridges handler message events and webex_event', async () => {
|
|
68
|
+
const handler = new FakeWebexMessageHandler()
|
|
69
|
+
const client = { getToken: () => 'token123' }
|
|
70
|
+
const listener = new WebexBotListener(client, {
|
|
71
|
+
_handlerFactory: (_config: WebexMessageHandlerConfig) => handler,
|
|
72
|
+
})
|
|
73
|
+
const messageCreated = mock((_event: DecryptedMessage) => undefined)
|
|
74
|
+
const webexEvent = mock((_event: DecryptedMessage) => undefined)
|
|
75
|
+
listener.on('message_created', messageCreated)
|
|
76
|
+
listener.on('webex_event', webexEvent)
|
|
77
|
+
|
|
78
|
+
await listener.start()
|
|
79
|
+
handler.emit('message:created', MESSAGE)
|
|
80
|
+
|
|
81
|
+
expect(messageCreated).toHaveBeenCalledWith(MESSAGE)
|
|
82
|
+
expect(webexEvent).toHaveBeenCalledWith(MESSAGE)
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('stop calls handler disconnect', async () => {
|
|
86
|
+
const handler = new FakeWebexMessageHandler()
|
|
87
|
+
const client = { getToken: () => 'token123' }
|
|
88
|
+
const listener = new WebexBotListener(client, {
|
|
89
|
+
_handlerFactory: (_config: WebexMessageHandlerConfig) => handler,
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
await listener.start()
|
|
93
|
+
await listener.stop()
|
|
94
|
+
|
|
95
|
+
expect(handler.disconnect).toHaveBeenCalled()
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it('start is idempotent', async () => {
|
|
99
|
+
const handler = new FakeWebexMessageHandler()
|
|
100
|
+
const client = { getToken: () => 'token123' }
|
|
101
|
+
const listener = new WebexBotListener(client, {
|
|
102
|
+
_handlerFactory: (_config: WebexMessageHandlerConfig) => handler,
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
await listener.start()
|
|
106
|
+
await listener.start()
|
|
107
|
+
|
|
108
|
+
expect(handler.connect).toHaveBeenCalledTimes(1)
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it('start rethrows and resets state when connect fails, allowing retry', async () => {
|
|
112
|
+
const failing = new FakeWebexMessageHandler()
|
|
113
|
+
failing.connect = mock(() => Promise.reject(new Error('device registration failed')))
|
|
114
|
+
const ok = new FakeWebexMessageHandler()
|
|
115
|
+
const handlers = [failing, ok]
|
|
116
|
+
const client = { getToken: () => 'token123' }
|
|
117
|
+
const listener = new WebexBotListener(client, {
|
|
118
|
+
_handlerFactory: (_config: WebexMessageHandlerConfig) => handlers.shift()!,
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
await expect(listener.start()).rejects.toThrow('device registration failed')
|
|
122
|
+
expect(failing.disconnect).toHaveBeenCalled()
|
|
123
|
+
|
|
124
|
+
await listener.start()
|
|
125
|
+
expect(ok.connect).toHaveBeenCalledTimes(1)
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
it('does not throw when handler emits error with no error listener', async () => {
|
|
129
|
+
const handler = new FakeWebexMessageHandler()
|
|
130
|
+
const client = { getToken: () => 'token123' }
|
|
131
|
+
const listener = new WebexBotListener(client, {
|
|
132
|
+
_handlerFactory: (_config: WebexMessageHandlerConfig) => handler,
|
|
133
|
+
})
|
|
134
|
+
listener.on('message_created', () => undefined)
|
|
135
|
+
|
|
136
|
+
await listener.start()
|
|
137
|
+
|
|
138
|
+
expect(() => handler.emit('error', new Error('boom'))).not.toThrow()
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
it('ignores stale handler events after stop', async () => {
|
|
142
|
+
const handler = new FakeWebexMessageHandler()
|
|
143
|
+
const client = { getToken: () => 'token123' }
|
|
144
|
+
const listener = new WebexBotListener(client, {
|
|
145
|
+
_handlerFactory: (_config: WebexMessageHandlerConfig) => handler,
|
|
146
|
+
})
|
|
147
|
+
const messageCreated = mock((_event: DecryptedMessage) => undefined)
|
|
148
|
+
listener.on('message_created', messageCreated)
|
|
149
|
+
|
|
150
|
+
await listener.start()
|
|
151
|
+
await listener.stop()
|
|
152
|
+
handler.emit('message:created', MESSAGE)
|
|
153
|
+
|
|
154
|
+
expect(messageCreated).not.toHaveBeenCalled()
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
it('start-stop-start does not cross-talk between handlers', async () => {
|
|
158
|
+
const first = new FakeWebexMessageHandler()
|
|
159
|
+
const second = new FakeWebexMessageHandler()
|
|
160
|
+
const handlers = [first, second]
|
|
161
|
+
const client = { getToken: () => 'token123' }
|
|
162
|
+
const listener = new WebexBotListener(client, {
|
|
163
|
+
_handlerFactory: (_config: WebexMessageHandlerConfig) => handlers.shift()!,
|
|
164
|
+
})
|
|
165
|
+
const messageCreated = mock((_event: DecryptedMessage) => undefined)
|
|
166
|
+
listener.on('message_created', messageCreated)
|
|
167
|
+
|
|
168
|
+
await listener.start()
|
|
169
|
+
await listener.stop()
|
|
170
|
+
await listener.start()
|
|
171
|
+
|
|
172
|
+
first.emit('message:created', MESSAGE)
|
|
173
|
+
expect(messageCreated).not.toHaveBeenCalled()
|
|
174
|
+
|
|
175
|
+
second.emit('message:created', MESSAGE)
|
|
176
|
+
expect(messageCreated).toHaveBeenCalledTimes(1)
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
it('preserves disconnected reason', async () => {
|
|
180
|
+
const handler = new FakeWebexMessageHandler()
|
|
181
|
+
const client = { getToken: () => 'token123' }
|
|
182
|
+
const listener = new WebexBotListener(client, {
|
|
183
|
+
_handlerFactory: (_config: WebexMessageHandlerConfig) => handler,
|
|
184
|
+
})
|
|
185
|
+
const disconnected = mock((_reason: string) => undefined)
|
|
186
|
+
listener.on('disconnected', disconnected)
|
|
187
|
+
|
|
188
|
+
await listener.start()
|
|
189
|
+
handler.emit('disconnected', 'network lost')
|
|
190
|
+
|
|
191
|
+
expect(disconnected).toHaveBeenCalledWith('network lost')
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
it('concurrent start() calls share the same connect failure', async () => {
|
|
195
|
+
const handler = new FakeWebexMessageHandler()
|
|
196
|
+
handler.connect = mock(() => Promise.reject(new Error('connect failed')))
|
|
197
|
+
const client = { getToken: () => 'token123' }
|
|
198
|
+
const listener = new WebexBotListener(client, {
|
|
199
|
+
_handlerFactory: (_config: WebexMessageHandlerConfig) => handler,
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
const first = listener.start()
|
|
203
|
+
const second = listener.start()
|
|
204
|
+
const firstResult = first.then(
|
|
205
|
+
() => 'ok',
|
|
206
|
+
(e: Error) => e.message,
|
|
207
|
+
)
|
|
208
|
+
const secondResult = second.then(
|
|
209
|
+
() => 'ok',
|
|
210
|
+
(e: Error) => e.message,
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
expect(await firstResult).toBe('connect failed')
|
|
214
|
+
expect(await secondResult).toBe('connect failed')
|
|
215
|
+
expect(handler.connect).toHaveBeenCalledTimes(1)
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
it('disconnects a handler whose connect resolves after stop', async () => {
|
|
219
|
+
const handler = new FakeWebexMessageHandler()
|
|
220
|
+
let resolveConnect: () => void = () => undefined
|
|
221
|
+
handler.connect = mock(() => new Promise<void>((resolve) => (resolveConnect = resolve)))
|
|
222
|
+
const client = { getToken: () => 'token123' }
|
|
223
|
+
const listener = new WebexBotListener(client, {
|
|
224
|
+
_handlerFactory: (_config: WebexMessageHandlerConfig) => handler,
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
const starting = listener.start()
|
|
228
|
+
const stopping = listener.stop()
|
|
229
|
+
resolveConnect()
|
|
230
|
+
await Promise.all([starting, stopping])
|
|
231
|
+
|
|
232
|
+
expect(handler.disconnect).toHaveBeenCalled()
|
|
233
|
+
})
|
|
234
|
+
})
|
|
@@ -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
|
+
}
|