agent-messenger 2.21.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.
- 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 +25 -0
- package/dist/src/platforms/webex/client.d.ts.map +1 -1
- package/dist/src/platforms/webex/client.js +115 -5
- 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 +4 -0
- package/dist/src/platforms/webex/index.d.ts.map +1 -1
- package/dist/src/platforms/webex/index.js +2 -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/cli.d.ts.map +1 -1
- package/dist/src/platforms/webexbot/cli.js +4 -1
- package/dist/src/platforms/webexbot/cli.js.map +1 -1
- package/dist/src/platforms/webexbot/client.d.ts +24 -0
- package/dist/src/platforms/webexbot/client.d.ts.map +1 -1
- package/dist/src/platforms/webexbot/client.js +81 -5
- package/dist/src/platforms/webexbot/client.js.map +1 -1
- package/dist/src/platforms/webexbot/commands/file.d.ts +22 -0
- package/dist/src/platforms/webexbot/commands/file.d.ts.map +1 -0
- package/dist/src/platforms/webexbot/commands/file.js +64 -0
- package/dist/src/platforms/webexbot/commands/file.js.map +1 -0
- package/dist/src/platforms/webexbot/commands/index.d.ts +3 -0
- package/dist/src/platforms/webexbot/commands/index.d.ts.map +1 -1
- package/dist/src/platforms/webexbot/commands/index.js +3 -0
- package/dist/src/platforms/webexbot/commands/index.js.map +1 -1
- package/dist/src/platforms/webexbot/commands/message.d.ts +7 -0
- package/dist/src/platforms/webexbot/commands/message.d.ts.map +1 -1
- package/dist/src/platforms/webexbot/commands/message.js +52 -1
- package/dist/src/platforms/webexbot/commands/message.js.map +1 -1
- package/dist/src/platforms/webexbot/commands/snapshot.d.ts +24 -0
- package/dist/src/platforms/webexbot/commands/snapshot.d.ts.map +1 -0
- package/dist/src/platforms/webexbot/commands/snapshot.js +37 -0
- package/dist/src/platforms/webexbot/commands/snapshot.js.map +1 -0
- package/dist/src/platforms/webexbot/commands/user.d.ts +30 -0
- package/dist/src/platforms/webexbot/commands/user.d.ts.map +1 -0
- package/dist/src/platforms/webexbot/commands/user.js +66 -0
- package/dist/src/platforms/webexbot/commands/user.js.map +1 -0
- 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/cli/webexbot.mdx +2 -0
- package/docs/content/docs/sdk/webexbot.mdx +18 -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 +60 -5
- package/skills/agent-webexbot/references/common-patterns.md +118 -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/platforms/webex/cli.test.ts +31 -1
- package/src/platforms/webex/client.test.ts +67 -0
- package/src/platforms/webex/client.ts +136 -7
- 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 +6 -0
- package/src/platforms/webex/index.ts +4 -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/cli.ts +6 -0
- package/src/platforms/webexbot/client.test.ts +322 -0
- package/src/platforms/webexbot/client.ts +104 -7
- package/src/platforms/webexbot/commands/file.ts +104 -0
- package/src/platforms/webexbot/commands/index.ts +3 -0
- package/src/platforms/webexbot/commands/message.ts +68 -2
- package/src/platforms/webexbot/commands/snapshot.ts +60 -0
- package/src/platforms/webexbot/commands/user.test.ts +77 -0
- package/src/platforms/webexbot/commands/user.ts +98 -0
- 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
|
@@ -0,0 +1,285 @@
|
|
|
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 {
|
|
18
|
+
normalizeAttachmentAction,
|
|
19
|
+
normalizeDeletedMessage,
|
|
20
|
+
normalizeMembership,
|
|
21
|
+
normalizeMessage,
|
|
22
|
+
normalizeRoomActivity,
|
|
23
|
+
} from './id-normalizer'
|
|
24
|
+
import { createWdmRewriteFetch, discoverWdmDevicesUrl } from './wdm-discovery'
|
|
25
|
+
|
|
26
|
+
type EventKey = keyof WebexListenerEventMap
|
|
27
|
+
|
|
28
|
+
export interface WebexListenerClient {
|
|
29
|
+
getToken(): string
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface WebexMessageHandlerLike {
|
|
33
|
+
connect(): Promise<void>
|
|
34
|
+
disconnect(): Promise<void>
|
|
35
|
+
status(): HandlerStatus
|
|
36
|
+
get connected(): boolean
|
|
37
|
+
on<K extends keyof WebexMessageHandlerEvents>(event: K, listener: WebexMessageHandlerEvents[K]): this
|
|
38
|
+
off<K extends keyof WebexMessageHandlerEvents>(event: K, listener: WebexMessageHandlerEvents[K]): this
|
|
39
|
+
once<K extends keyof WebexMessageHandlerEvents>(event: K, listener: WebexMessageHandlerEvents[K]): this
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface WebexListenerOptions {
|
|
43
|
+
ignoreSelfMessages?: boolean
|
|
44
|
+
pingInterval?: number
|
|
45
|
+
pongTimeout?: number
|
|
46
|
+
reconnectBackoffMax?: number
|
|
47
|
+
maxReconnectAttempts?: number
|
|
48
|
+
_handlerFactory?: (config: WebexMessageHandlerConfig) => WebexMessageHandlerLike
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface WebexListenerEventMap {
|
|
52
|
+
message_created: [event: DecryptedMessage]
|
|
53
|
+
message_updated: [event: DecryptedMessage]
|
|
54
|
+
message_deleted: [event: DeletedMessage]
|
|
55
|
+
membership_created: [event: MembershipActivity]
|
|
56
|
+
attachment_action: [event: AttachmentAction]
|
|
57
|
+
room_created: [event: RoomActivity]
|
|
58
|
+
room_updated: [event: RoomActivity]
|
|
59
|
+
webex_event: [event: DecryptedMessage | DeletedMessage | MembershipActivity | AttachmentAction | RoomActivity]
|
|
60
|
+
connected: [info: { connected: boolean; status: HandlerStatus }]
|
|
61
|
+
reconnecting: [attempt: number]
|
|
62
|
+
disconnected: [reason: string]
|
|
63
|
+
error: [error: Error]
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export class WebexListener {
|
|
67
|
+
private client: WebexListenerClient
|
|
68
|
+
private options: WebexListenerOptions
|
|
69
|
+
private running = false
|
|
70
|
+
private emitter = new EventEmitter()
|
|
71
|
+
private handler: WebexMessageHandlerLike | null = null
|
|
72
|
+
private generation = 0
|
|
73
|
+
private detachHandler: (() => void) | null = null
|
|
74
|
+
private startPromise: Promise<void> | null = null
|
|
75
|
+
|
|
76
|
+
constructor(client: WebexListenerClient, options: WebexListenerOptions = {}) {
|
|
77
|
+
this.client = client
|
|
78
|
+
this.options = options
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async start(): Promise<void> {
|
|
82
|
+
if (this.startPromise) return this.startPromise
|
|
83
|
+
if (this.running) return
|
|
84
|
+
|
|
85
|
+
this.running = true
|
|
86
|
+
const generation = ++this.generation
|
|
87
|
+
|
|
88
|
+
let startPromise!: Promise<void>
|
|
89
|
+
startPromise = (async () => {
|
|
90
|
+
let handler: WebexMessageHandlerLike | null = null
|
|
91
|
+
try {
|
|
92
|
+
handler = await this.createHandler()
|
|
93
|
+
|
|
94
|
+
if (!this.running || this.generation !== generation) {
|
|
95
|
+
await handler.disconnect().catch(() => undefined)
|
|
96
|
+
return
|
|
97
|
+
}
|
|
98
|
+
this.handler = handler
|
|
99
|
+
this.detachHandler = this.wireHandler(handler, generation)
|
|
100
|
+
|
|
101
|
+
await handler.connect()
|
|
102
|
+
|
|
103
|
+
if (!this.running || this.handler !== handler || this.generation !== generation) {
|
|
104
|
+
await handler.disconnect().catch(() => undefined)
|
|
105
|
+
return
|
|
106
|
+
}
|
|
107
|
+
} catch (error) {
|
|
108
|
+
if (this.handler === handler && this.generation === generation) {
|
|
109
|
+
this.detachHandler?.()
|
|
110
|
+
this.detachHandler = null
|
|
111
|
+
this.handler = null
|
|
112
|
+
}
|
|
113
|
+
if (this.generation === generation) {
|
|
114
|
+
this.running = false
|
|
115
|
+
}
|
|
116
|
+
await handler?.disconnect().catch(() => undefined)
|
|
117
|
+
throw error
|
|
118
|
+
} finally {
|
|
119
|
+
if (this.startPromise === startPromise) {
|
|
120
|
+
this.startPromise = null
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
})()
|
|
124
|
+
|
|
125
|
+
this.startPromise = startPromise
|
|
126
|
+
return startPromise
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async stop(): Promise<void> {
|
|
130
|
+
const pendingStart = this.startPromise
|
|
131
|
+
this.startPromise = null
|
|
132
|
+
|
|
133
|
+
this.running = false
|
|
134
|
+
this.generation++
|
|
135
|
+
|
|
136
|
+
const detach = this.detachHandler
|
|
137
|
+
this.detachHandler = null
|
|
138
|
+
detach?.()
|
|
139
|
+
|
|
140
|
+
const handler = this.handler
|
|
141
|
+
this.handler = null
|
|
142
|
+
if (handler) {
|
|
143
|
+
await handler.disconnect().catch(() => undefined)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (pendingStart) {
|
|
147
|
+
await pendingStart.catch(() => undefined)
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
on<K extends EventKey>(event: K, listener: (...args: WebexListenerEventMap[K]) => void): this {
|
|
152
|
+
this.emitter.on(event, listener as (...args: unknown[]) => void)
|
|
153
|
+
return this
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
off<K extends EventKey>(event: K, listener: (...args: WebexListenerEventMap[K]) => void): this {
|
|
157
|
+
this.emitter.off(event, listener as (...args: unknown[]) => void)
|
|
158
|
+
return this
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
once<K extends EventKey>(event: K, listener: (...args: WebexListenerEventMap[K]) => void): this {
|
|
162
|
+
this.emitter.once(event, listener as (...args: unknown[]) => void)
|
|
163
|
+
return this
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
private isCurrent(handler: WebexMessageHandlerLike, generation: number): boolean {
|
|
167
|
+
return this.running && this.handler === handler && this.generation === generation
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Node's EventEmitter throws synchronously if an 'error' event is emitted with
|
|
171
|
+
// no registered listener. Guard it so SDK consumers that only subscribe to
|
|
172
|
+
// message events are never crashed by a transient connection error.
|
|
173
|
+
private emitError(error: Error): void {
|
|
174
|
+
if (this.emitter.listenerCount('error') > 0) {
|
|
175
|
+
this.emitter.emit('error', error)
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
private async createHandler(): Promise<WebexMessageHandlerLike> {
|
|
180
|
+
const token = this.client.getToken()
|
|
181
|
+
const config: WebexMessageHandlerConfig = { token }
|
|
182
|
+
if (this.options.ignoreSelfMessages !== undefined) config.ignoreSelfMessages = this.options.ignoreSelfMessages
|
|
183
|
+
if (this.options.pingInterval !== undefined) config.pingInterval = this.options.pingInterval
|
|
184
|
+
if (this.options.pongTimeout !== undefined) config.pongTimeout = this.options.pongTimeout
|
|
185
|
+
if (this.options.reconnectBackoffMax !== undefined) config.reconnectBackoffMax = this.options.reconnectBackoffMax
|
|
186
|
+
if (this.options.maxReconnectAttempts !== undefined) config.maxReconnectAttempts = this.options.maxReconnectAttempts
|
|
187
|
+
|
|
188
|
+
if (this.options._handlerFactory) {
|
|
189
|
+
return this.options._handlerFactory(config)
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const wdmDevicesUrl = await discoverWdmDevicesUrl(token)
|
|
193
|
+
config.mode = 'injected'
|
|
194
|
+
config.fetch = createWdmRewriteFetch(wdmDevicesUrl)
|
|
195
|
+
config.webSocketFactory = (url: string) => new WebSocket(url) as unknown as InjectedWebSocket
|
|
196
|
+
return new WebexMessageHandler(config)
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
private wireHandler(handler: WebexMessageHandlerLike, generation: number): () => void {
|
|
200
|
+
const onMessageCreated = (event: DecryptedMessage) => {
|
|
201
|
+
if (!this.isCurrent(handler, generation)) return
|
|
202
|
+
const normalized = normalizeMessage(event)
|
|
203
|
+
this.emitter.emit('message_created', normalized)
|
|
204
|
+
this.emitter.emit('webex_event', normalized)
|
|
205
|
+
}
|
|
206
|
+
const onMessageUpdated = (event: DecryptedMessage) => {
|
|
207
|
+
if (!this.isCurrent(handler, generation)) return
|
|
208
|
+
const normalized = normalizeMessage(event)
|
|
209
|
+
this.emitter.emit('message_updated', normalized)
|
|
210
|
+
this.emitter.emit('webex_event', normalized)
|
|
211
|
+
}
|
|
212
|
+
const onMessageDeleted = (event: DeletedMessage) => {
|
|
213
|
+
if (!this.isCurrent(handler, generation)) return
|
|
214
|
+
const normalized = normalizeDeletedMessage(event)
|
|
215
|
+
this.emitter.emit('message_deleted', normalized)
|
|
216
|
+
this.emitter.emit('webex_event', normalized)
|
|
217
|
+
}
|
|
218
|
+
const onMembershipCreated = (event: MembershipActivity) => {
|
|
219
|
+
if (!this.isCurrent(handler, generation)) return
|
|
220
|
+
const normalized = normalizeMembership(event)
|
|
221
|
+
this.emitter.emit('membership_created', normalized)
|
|
222
|
+
this.emitter.emit('webex_event', normalized)
|
|
223
|
+
}
|
|
224
|
+
const onAttachmentAction = (event: AttachmentAction) => {
|
|
225
|
+
if (!this.isCurrent(handler, generation)) return
|
|
226
|
+
const normalized = normalizeAttachmentAction(event)
|
|
227
|
+
this.emitter.emit('attachment_action', normalized)
|
|
228
|
+
this.emitter.emit('webex_event', normalized)
|
|
229
|
+
}
|
|
230
|
+
const onRoomCreated = (event: RoomActivity) => {
|
|
231
|
+
if (!this.isCurrent(handler, generation)) return
|
|
232
|
+
const normalized = normalizeRoomActivity(event)
|
|
233
|
+
this.emitter.emit('room_created', normalized)
|
|
234
|
+
this.emitter.emit('webex_event', normalized)
|
|
235
|
+
}
|
|
236
|
+
const onRoomUpdated = (event: RoomActivity) => {
|
|
237
|
+
if (!this.isCurrent(handler, generation)) return
|
|
238
|
+
const normalized = normalizeRoomActivity(event)
|
|
239
|
+
this.emitter.emit('room_updated', normalized)
|
|
240
|
+
this.emitter.emit('webex_event', normalized)
|
|
241
|
+
}
|
|
242
|
+
const onConnected = () => {
|
|
243
|
+
if (!this.isCurrent(handler, generation)) return
|
|
244
|
+
this.emitter.emit('connected', { connected: handler.connected, status: handler.status() })
|
|
245
|
+
}
|
|
246
|
+
const onReconnecting = (attempt: number) => {
|
|
247
|
+
if (!this.isCurrent(handler, generation)) return
|
|
248
|
+
this.emitter.emit('reconnecting', attempt)
|
|
249
|
+
}
|
|
250
|
+
const onDisconnected = (reason: string) => {
|
|
251
|
+
if (!this.isCurrent(handler, generation)) return
|
|
252
|
+
this.emitter.emit('disconnected', reason)
|
|
253
|
+
}
|
|
254
|
+
const onError = (error: Error) => {
|
|
255
|
+
if (!this.isCurrent(handler, generation)) return
|
|
256
|
+
this.emitError(error)
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
handler.on('message:created', onMessageCreated)
|
|
260
|
+
handler.on('message:updated', onMessageUpdated)
|
|
261
|
+
handler.on('message:deleted', onMessageDeleted)
|
|
262
|
+
handler.on('membership:created', onMembershipCreated)
|
|
263
|
+
handler.on('attachmentAction:created', onAttachmentAction)
|
|
264
|
+
handler.on('room:created', onRoomCreated)
|
|
265
|
+
handler.on('room:updated', onRoomUpdated)
|
|
266
|
+
handler.on('connected', onConnected)
|
|
267
|
+
handler.on('reconnecting', onReconnecting)
|
|
268
|
+
handler.on('disconnected', onDisconnected)
|
|
269
|
+
handler.on('error', onError)
|
|
270
|
+
|
|
271
|
+
return () => {
|
|
272
|
+
handler.off('message:created', onMessageCreated)
|
|
273
|
+
handler.off('message:updated', onMessageUpdated)
|
|
274
|
+
handler.off('message:deleted', onMessageDeleted)
|
|
275
|
+
handler.off('membership:created', onMembershipCreated)
|
|
276
|
+
handler.off('attachmentAction:created', onAttachmentAction)
|
|
277
|
+
handler.off('room:created', onRoomCreated)
|
|
278
|
+
handler.off('room:updated', onRoomUpdated)
|
|
279
|
+
handler.off('connected', onConnected)
|
|
280
|
+
handler.off('reconnecting', onReconnecting)
|
|
281
|
+
handler.off('disconnected', onDisconnected)
|
|
282
|
+
handler.off('error', onError)
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, mock } from 'bun:test'
|
|
2
|
+
import { createHash } from 'node:crypto'
|
|
3
|
+
|
|
4
|
+
import { createPkcePair, loginWithPassword } from './password-login'
|
|
5
|
+
import { WebexError } from './types'
|
|
6
|
+
|
|
7
|
+
const realFetch = globalThis.fetch
|
|
8
|
+
|
|
9
|
+
describe('loginWithPassword', () => {
|
|
10
|
+
afterEach(() => {
|
|
11
|
+
globalThis.fetch = realFetch
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
it('exchanges email and password for tokens and registers a device', async () => {
|
|
15
|
+
const postedBodies: string[] = []
|
|
16
|
+
|
|
17
|
+
globalThis.fetch = mock((input: RequestInfo | URL, init?: RequestInit) => {
|
|
18
|
+
const url = toUrl(input)
|
|
19
|
+
if (typeof init?.body === 'string') postedBodies.push(init.body)
|
|
20
|
+
|
|
21
|
+
if (url.startsWith('https://u2c.svc.webex.com/')) {
|
|
22
|
+
return Promise.resolve(
|
|
23
|
+
jsonResponse({
|
|
24
|
+
serviceLinks: { idbroker: 'https://idbroker-test.webex.com', wdm: 'https://wdm-test.wbx2.com' },
|
|
25
|
+
}),
|
|
26
|
+
)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (url.startsWith('https://idbroker-test.webex.com/idb/oauth2/v1/authorize')) {
|
|
30
|
+
return Promise.resolve(
|
|
31
|
+
new Response(
|
|
32
|
+
'<form><input type="hidden" name="SunQueryParamsString" value="one&two=@"><input name="goto" value="https://web.webex.com"><input name="encoded" value="true"><input name="gx_charset" value="UTF-8"><input name="webAuthnEnabledFlow" value="false"><input name="webAuthnResponse" value=""><input name="isAudioCaptcha" value="false"></form>',
|
|
33
|
+
{ status: 200, headers: { 'set-cookie': 'sid=abc; Path=/' } },
|
|
34
|
+
),
|
|
35
|
+
)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (url === 'https://idbroker-test.webex.com/idb/UI/Login') {
|
|
39
|
+
return Promise.resolve(
|
|
40
|
+
new Response('', { status: 302, headers: { location: 'https://web.webex.com/?code=TESTCODE' } }),
|
|
41
|
+
)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (url === 'https://idbroker-test.webex.com/idb/oauth2/v1/access_token') {
|
|
45
|
+
return Promise.resolve(
|
|
46
|
+
jsonResponse({
|
|
47
|
+
access_token: 'fake-access-token',
|
|
48
|
+
refresh_token: 'fake-refresh-token',
|
|
49
|
+
expires_in: 3600,
|
|
50
|
+
refresh_token_expires_in: 7200,
|
|
51
|
+
scope: 'spark:kms',
|
|
52
|
+
token_type: 'Bearer',
|
|
53
|
+
}),
|
|
54
|
+
)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (url === 'https://wdm-test.wbx2.com/wdm/api/v1/devices') {
|
|
58
|
+
return Promise.resolve(
|
|
59
|
+
jsonResponse({ url: 'https://wdm-test.wbx2.com/wdm/api/v1/devices/device-1', userId: 'user-1' }),
|
|
60
|
+
)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return Promise.resolve(new Response('', { status: 404 }))
|
|
64
|
+
}) as typeof fetch
|
|
65
|
+
|
|
66
|
+
const result = await loginWithPassword('user@example.com', 'test-password')
|
|
67
|
+
const loginBody = new URLSearchParams(postedBodies.find((body) => body.includes('IDToken2')))
|
|
68
|
+
|
|
69
|
+
expect(result.accessToken).toBe('fake-access-token')
|
|
70
|
+
expect(result.refreshToken).toBe('fake-refresh-token')
|
|
71
|
+
expect(result.expiresAt).toBeGreaterThan(Date.now())
|
|
72
|
+
expect(result.deviceUrl).toBe('https://wdm-test.wbx2.com/wdm/api/v1/devices/device-1')
|
|
73
|
+
expect(result.userId).toBe('user-1')
|
|
74
|
+
expect(loginBody.get('IDToken1')).toBe('user@example.com')
|
|
75
|
+
expect(loginBody.get('IDToken2')).toBe('test-password')
|
|
76
|
+
expect(loginBody.get('SunQueryParamsString')).toBe('one&two=@')
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('creates an S256 PKCE challenge from the verifier', () => {
|
|
80
|
+
const verifier = 'test-verifier'
|
|
81
|
+
const expectedChallenge = createHash('sha256')
|
|
82
|
+
.update(verifier)
|
|
83
|
+
.digest('base64')
|
|
84
|
+
.replace(/\+/g, '-')
|
|
85
|
+
.replace(/\//g, '_')
|
|
86
|
+
.replace(/=+$/, '')
|
|
87
|
+
|
|
88
|
+
expect(createPkcePair(verifier)).toEqual({ verifier, challenge: expectedChallenge })
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('rejects without persisting when WDM device registration fails', async () => {
|
|
92
|
+
globalThis.fetch = mock((input: RequestInfo | URL) => {
|
|
93
|
+
const url = toUrl(input)
|
|
94
|
+
|
|
95
|
+
if (url.startsWith('https://u2c.svc.webex.com/')) {
|
|
96
|
+
return Promise.resolve(
|
|
97
|
+
jsonResponse({
|
|
98
|
+
serviceLinks: { idbroker: 'https://idbroker-test.webex.com', wdm: 'https://wdm-test.wbx2.com' },
|
|
99
|
+
}),
|
|
100
|
+
)
|
|
101
|
+
}
|
|
102
|
+
if (url.startsWith('https://idbroker-test.webex.com/idb/oauth2/v1/authorize')) {
|
|
103
|
+
return Promise.resolve(
|
|
104
|
+
new Response('<input name="SunQueryParamsString" value="x">', {
|
|
105
|
+
status: 200,
|
|
106
|
+
headers: { 'set-cookie': 'sid=abc; Path=/' },
|
|
107
|
+
}),
|
|
108
|
+
)
|
|
109
|
+
}
|
|
110
|
+
if (url === 'https://idbroker-test.webex.com/idb/UI/Login') {
|
|
111
|
+
return Promise.resolve(
|
|
112
|
+
new Response('', { status: 302, headers: { location: 'https://web.webex.com/?code=TESTCODE' } }),
|
|
113
|
+
)
|
|
114
|
+
}
|
|
115
|
+
if (url === 'https://idbroker-test.webex.com/idb/oauth2/v1/access_token') {
|
|
116
|
+
return Promise.resolve(
|
|
117
|
+
jsonResponse({ access_token: 'fake-access-token', refresh_token: 'fake-refresh-token', expires_in: 3600 }),
|
|
118
|
+
)
|
|
119
|
+
}
|
|
120
|
+
if (url === 'https://wdm-test.wbx2.com/wdm/api/v1/devices') {
|
|
121
|
+
return Promise.resolve(new Response('', { status: 500 }))
|
|
122
|
+
}
|
|
123
|
+
return Promise.resolve(new Response('', { status: 404 }))
|
|
124
|
+
}) as typeof fetch
|
|
125
|
+
|
|
126
|
+
await expect(loginWithPassword('user@example.com', 'test-password')).rejects.toMatchObject({
|
|
127
|
+
name: 'WebexError',
|
|
128
|
+
code: 'device_registration_failed',
|
|
129
|
+
} satisfies Partial<WebexError>)
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
it('rejects as sso_required without posting credentials when authorize redirects to an external IdP', async () => {
|
|
133
|
+
const postedBodies: string[] = []
|
|
134
|
+
globalThis.fetch = mock((input: RequestInfo | URL, init?: RequestInit) => {
|
|
135
|
+
const url = toUrl(input)
|
|
136
|
+
if (typeof init?.body === 'string') postedBodies.push(init.body)
|
|
137
|
+
|
|
138
|
+
if (url.startsWith('https://u2c.svc.webex.com/')) {
|
|
139
|
+
return Promise.resolve(jsonResponse({ serviceLinks: { idbroker: 'https://idbroker-test.webex.com' } }))
|
|
140
|
+
}
|
|
141
|
+
if (url.startsWith('https://idbroker-test.webex.com/idb/oauth2/v1/authorize')) {
|
|
142
|
+
return Promise.resolve(
|
|
143
|
+
new Response('', { status: 302, headers: { location: 'https://idp.example.com/login' } }),
|
|
144
|
+
)
|
|
145
|
+
}
|
|
146
|
+
if (url.startsWith('https://idp.example.com/')) {
|
|
147
|
+
return Promise.resolve(new Response('<input name="IDToken1"><input name="IDToken2">', { status: 200 }))
|
|
148
|
+
}
|
|
149
|
+
return Promise.resolve(new Response('', { status: 404 }))
|
|
150
|
+
}) as typeof fetch
|
|
151
|
+
|
|
152
|
+
await expect(loginWithPassword('user@example.com', 'test-password')).rejects.toMatchObject({
|
|
153
|
+
name: 'WebexError',
|
|
154
|
+
code: 'sso_required',
|
|
155
|
+
} satisfies Partial<WebexError>)
|
|
156
|
+
expect(postedBodies.some((body) => body.includes('test-password'))).toBe(false)
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
it('throws mfa_required when login lands on an OTP step', async () => {
|
|
160
|
+
globalThis.fetch = mock((input: RequestInfo | URL) => {
|
|
161
|
+
const url = toUrl(input)
|
|
162
|
+
|
|
163
|
+
if (url.startsWith('https://u2c.svc.webex.com/')) {
|
|
164
|
+
return Promise.resolve(jsonResponse({ serviceLinks: { idbroker: 'https://idbroker-test.webex.com' } }))
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (url.startsWith('https://idbroker-test.webex.com/idb/oauth2/v1/authorize')) {
|
|
168
|
+
return Promise.resolve(new Response('<input name="SunQueryParamsString" value="x">', { status: 200 }))
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (url === 'https://idbroker-test.webex.com/idb/UI/Login') {
|
|
172
|
+
return Promise.resolve(new Response('<input name="IDToken1"><p>verification code</p>', { status: 200 }))
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return Promise.resolve(new Response('', { status: 404 }))
|
|
176
|
+
}) as typeof fetch
|
|
177
|
+
|
|
178
|
+
await expect(loginWithPassword('user@example.com', 'test-password')).rejects.toMatchObject({
|
|
179
|
+
name: 'WebexError',
|
|
180
|
+
code: 'mfa_required',
|
|
181
|
+
} satisfies Partial<WebexError>)
|
|
182
|
+
})
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
function jsonResponse(body: unknown): Response {
|
|
186
|
+
return new Response(JSON.stringify(body), { status: 200, headers: { 'Content-Type': 'application/json' } })
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function toUrl(input: RequestInfo | URL): string {
|
|
190
|
+
if (typeof input === 'string') return input
|
|
191
|
+
if (input instanceof URL) return input.toString()
|
|
192
|
+
return input.url
|
|
193
|
+
}
|