agent-messenger 2.24.1 → 2.26.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 (52) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +1 -1
  3. package/dist/package.json +1 -1
  4. package/dist/src/platforms/discord/commands/auth.d.ts +4 -0
  5. package/dist/src/platforms/discord/commands/auth.d.ts.map +1 -1
  6. package/dist/src/platforms/discord/commands/auth.js +61 -1
  7. package/dist/src/platforms/discord/commands/auth.js.map +1 -1
  8. package/dist/src/platforms/discord/index.d.ts +2 -0
  9. package/dist/src/platforms/discord/index.d.ts.map +1 -1
  10. package/dist/src/platforms/discord/index.js +1 -0
  11. package/dist/src/platforms/discord/index.js.map +1 -1
  12. package/dist/src/platforms/discord/remote-auth.d.ts +42 -0
  13. package/dist/src/platforms/discord/remote-auth.d.ts.map +1 -0
  14. package/dist/src/platforms/discord/remote-auth.js +208 -0
  15. package/dist/src/platforms/discord/remote-auth.js.map +1 -0
  16. package/dist/src/platforms/webex/client.d.ts +1 -0
  17. package/dist/src/platforms/webex/client.d.ts.map +1 -1
  18. package/dist/src/platforms/webex/client.js +17 -1
  19. package/dist/src/platforms/webex/client.js.map +1 -1
  20. package/dist/src/platforms/webex/commands/message.d.ts +4 -0
  21. package/dist/src/platforms/webex/commands/message.d.ts.map +1 -1
  22. package/dist/src/platforms/webex/commands/message.js +16 -1
  23. package/dist/src/platforms/webex/commands/message.js.map +1 -1
  24. package/docs/content/docs/cli/webex.mdx +7 -0
  25. package/docs/content/docs/sdk/discord.mdx +22 -0
  26. package/docs/content/docs/sdk/webex.mdx +7 -0
  27. package/package.json +1 -1
  28. package/skills/agent-channeltalk/SKILL.md +1 -1
  29. package/skills/agent-channeltalkbot/SKILL.md +1 -1
  30. package/skills/agent-discord/SKILL.md +9 -2
  31. package/skills/agent-discordbot/SKILL.md +1 -1
  32. package/skills/agent-instagram/SKILL.md +1 -1
  33. package/skills/agent-kakaotalk/SKILL.md +1 -1
  34. package/skills/agent-line/SKILL.md +1 -1
  35. package/skills/agent-slack/SKILL.md +1 -1
  36. package/skills/agent-slackbot/SKILL.md +1 -1
  37. package/skills/agent-teams/SKILL.md +1 -1
  38. package/skills/agent-telegram/SKILL.md +1 -1
  39. package/skills/agent-telegrambot/SKILL.md +1 -1
  40. package/skills/agent-webex/SKILL.md +6 -1
  41. package/skills/agent-webexbot/SKILL.md +1 -1
  42. package/skills/agent-wechatbot/SKILL.md +1 -1
  43. package/skills/agent-whatsapp/SKILL.md +1 -1
  44. package/skills/agent-whatsappbot/SKILL.md +1 -1
  45. package/src/platforms/discord/commands/auth.ts +81 -1
  46. package/src/platforms/discord/index.ts +2 -0
  47. package/src/platforms/discord/remote-auth.test.ts +261 -0
  48. package/src/platforms/discord/remote-auth.ts +317 -0
  49. package/src/platforms/webex/client.test.ts +74 -0
  50. package/src/platforms/webex/client.ts +20 -1
  51. package/src/platforms/webex/commands/message.test.ts +29 -1
  52. package/src/platforms/webex/commands/message.ts +18 -0
@@ -1,6 +1,8 @@
1
1
  export { DiscordClient, DiscordError } from './client'
2
2
  export { DiscordCredentialManager } from './credential-manager'
3
3
  export { DiscordListener } from './listener'
4
+ export { loginWithRemoteAuth } from './remote-auth'
5
+ export type { RemoteAuthOptions, RemoteAuthSession } from './remote-auth'
4
6
  export type {
5
7
  DiscordChannel,
6
8
  DiscordConfig,
@@ -0,0 +1,261 @@
1
+ import { afterEach, describe, expect, it } from 'bun:test'
2
+ import { constants as cryptoConstants, createHash, generateKeyPairSync, publicEncrypt } from 'node:crypto'
3
+ import { EventEmitter } from 'node:events'
4
+
5
+ import { DiscordError } from '@/platforms/discord/client'
6
+ import { __test, loginWithRemoteAuth } from '@/platforms/discord/remote-auth'
7
+
8
+ const USER = { id: '852892297661906993', username: 'alice', discriminator: '0', avatar: 'abc' }
9
+ const USER_PAYLOAD = `${USER.id}:${USER.discriminator}:${USER.avatar}:${USER.username}`
10
+ const TOKEN = 'mfa.AbCdEfGhIjKlMnOpQrStUvWxYz'
11
+
12
+ class FakeWebSocket extends EventEmitter {
13
+ sent: Record<string, unknown>[] = []
14
+ closed = false
15
+
16
+ send(raw: string): void {
17
+ this.sent.push(JSON.parse(raw))
18
+ }
19
+
20
+ close(): void {
21
+ this.closed = true
22
+ }
23
+
24
+ emitMessage(payload: Record<string, unknown>): void {
25
+ this.emit('message', Buffer.from(JSON.stringify(payload)))
26
+ }
27
+
28
+ lastOp(op: string): Record<string, unknown> | undefined {
29
+ return [...this.sent].reverse().find((m) => m.op === op)
30
+ }
31
+ }
32
+
33
+ function encryptForClient(encodedPublicKey: string, data: Buffer | string): string {
34
+ const der = Buffer.from(encodedPublicKey, 'base64')
35
+ const publicKey = __test.createPublicKey({ key: der, format: 'der', type: 'spki' })
36
+ const encrypted = publicEncrypt(
37
+ { key: publicKey, padding: cryptoConstants.RSA_PKCS1_OAEP_PADDING, oaepHash: 'sha256' },
38
+ typeof data === 'string' ? Buffer.from(data) : data,
39
+ )
40
+ return encrypted.toString('base64')
41
+ }
42
+
43
+ const originalFetch = globalThis.fetch
44
+ afterEach(() => {
45
+ globalThis.fetch = originalFetch
46
+ })
47
+
48
+ describe('crypto helpers', () => {
49
+ it('computes nonce proof as base64url(sha256(decrypted_nonce))', () => {
50
+ // Given a keypair and a nonce encrypted to its public key
51
+ const { publicKey, privateKey } = generateKeyPairSync('rsa', { modulusLength: 2048 })
52
+ const nonce = Buffer.from('the-nonce-bytes')
53
+ const encoded = publicKey.export({ type: 'spki', format: 'der' }).toString('base64')
54
+ const encryptedNonce = encryptForClient(encoded, nonce)
55
+
56
+ // When computing the proof
57
+ const proof = __test.computeNonceProof(privateKey, encryptedNonce)
58
+
59
+ // Then it matches the spec: hash first, then base64url without padding
60
+ const expected = createHash('sha256').update(nonce).digest('base64url')
61
+ expect(proof).toBe(expected)
62
+ })
63
+
64
+ it('parses the colon-delimited user payload', () => {
65
+ expect(__test.parseUserPayload(USER_PAYLOAD)).toEqual({
66
+ id: USER.id,
67
+ discriminator: USER.discriminator,
68
+ avatar: USER.avatar,
69
+ username: USER.username,
70
+ })
71
+ })
72
+ })
73
+
74
+ describe('loginWithRemoteAuth', () => {
75
+ it('completes the full handshake and returns the decrypted token', async () => {
76
+ // Given a fake gateway that drives the protocol
77
+ const ws = new FakeWebSocket()
78
+ const qrUrls: string[] = []
79
+ const pendingUsers: string[] = []
80
+
81
+ globalThis.fetch = (async (_url: string, init?: { body?: string }) => {
82
+ const body = JSON.parse(init?.body ?? '{}') as { ticket?: string }
83
+ expect(body.ticket).toBe('the-ticket')
84
+ const initMsg = ws.lastOp('init') as { encoded_public_key: string }
85
+ return new Response(JSON.stringify({ encrypted_token: encryptForClient(initMsg.encoded_public_key, TOKEN) }), {
86
+ status: 200,
87
+ })
88
+ }) as typeof fetch
89
+
90
+ const sessionPromise = loginWithRemoteAuth({
91
+ createWebSocket: () => ws as unknown as import('ws').WebSocket,
92
+ onQrUrl: (url) => {
93
+ qrUrls.push(url)
94
+ },
95
+ onPendingLogin: (user) => {
96
+ pendingUsers.push(user.username)
97
+ },
98
+ })
99
+
100
+ // When the gateway walks through the protocol
101
+ ws.emit('open')
102
+ ws.emitMessage({ op: 'hello', heartbeat_interval: 41250, timeout_ms: 142637 })
103
+
104
+ const init = ws.lastOp('init') as { encoded_public_key: string }
105
+ expect(init).toBeDefined()
106
+
107
+ ws.emitMessage({ op: 'nonce_proof', encrypted_nonce: encryptForClient(init.encoded_public_key, 'nonce') })
108
+ expect(ws.lastOp('nonce_proof')).toBeDefined()
109
+
110
+ ws.emitMessage({ op: 'pending_remote_init', fingerprint: 'FINGERPRINT123' })
111
+ ws.emitMessage({
112
+ op: 'pending_ticket',
113
+ encrypted_user_payload: encryptForClient(init.encoded_public_key, USER_PAYLOAD),
114
+ })
115
+ ws.emitMessage({ op: 'pending_login', ticket: 'the-ticket' })
116
+
117
+ // Then the session resolves with the decrypted token and scanned user
118
+ const session = await sessionPromise
119
+ expect(session.token).toBe(TOKEN)
120
+ expect(session.user.username).toBe('alice')
121
+ expect(qrUrls).toEqual(['https://discord.com/ra/FINGERPRINT123'])
122
+ expect(pendingUsers).toEqual(['alice'])
123
+ expect(ws.closed).toBe(true)
124
+ })
125
+
126
+ it('rejects with a captcha error when the login endpoint demands one', async () => {
127
+ // Given a gateway that reaches ticket exchange but the API returns a captcha challenge
128
+ const ws = new FakeWebSocket()
129
+ globalThis.fetch = (async () =>
130
+ new Response(JSON.stringify({ captcha_sitekey: 'sitekey', captcha_key: ['captcha-required'] }), {
131
+ status: 400,
132
+ })) as typeof fetch
133
+
134
+ const sessionPromise = loginWithRemoteAuth({
135
+ createWebSocket: () => ws as unknown as import('ws').WebSocket,
136
+ })
137
+
138
+ ws.emitMessage({ op: 'hello', heartbeat_interval: 41250 })
139
+ const init = ws.lastOp('init') as { encoded_public_key: string }
140
+ ws.emitMessage({ op: 'nonce_proof', encrypted_nonce: encryptForClient(init.encoded_public_key, 'n') })
141
+ ws.emitMessage({ op: 'pending_remote_init', fingerprint: 'FP' })
142
+ ws.emitMessage({ op: 'pending_login', ticket: 't' })
143
+
144
+ // Then the promise rejects with a captcha-specific error code
145
+ await expect(sessionPromise).rejects.toMatchObject({ code: 'remote_auth_captcha' })
146
+ })
147
+
148
+ it('rejects when the gateway times out (close code 4003)', async () => {
149
+ // Given a gateway that closes with the timeout code before completion
150
+ const ws = new FakeWebSocket()
151
+ const sessionPromise = loginWithRemoteAuth({
152
+ createWebSocket: () => ws as unknown as import('ws').WebSocket,
153
+ })
154
+
155
+ ws.emitMessage({ op: 'hello', heartbeat_interval: 41250 })
156
+ ws.emit('close', 4003)
157
+
158
+ // Then it surfaces a timeout error
159
+ await expect(sessionPromise).rejects.toBeInstanceOf(DiscordError)
160
+ await expect(sessionPromise).rejects.toMatchObject({ code: 'remote_auth_timeout' })
161
+ })
162
+
163
+ it('does not reject on normal close (1000) while the ticket exchange is in flight', async () => {
164
+ // Given a fetch that stays pending until we release it, so close(1000) lands mid-exchange
165
+ const ws = new FakeWebSocket()
166
+ let releaseFetch: (() => void) | undefined
167
+ globalThis.fetch = (async () => {
168
+ await new Promise<void>((resolve) => {
169
+ releaseFetch = resolve
170
+ })
171
+ const init = ws.lastOp('init') as { encoded_public_key: string }
172
+ return new Response(JSON.stringify({ encrypted_token: encryptForClient(init.encoded_public_key, TOKEN) }), {
173
+ status: 200,
174
+ })
175
+ }) as typeof fetch
176
+
177
+ const sessionPromise = loginWithRemoteAuth({
178
+ createWebSocket: () => ws as unknown as import('ws').WebSocket,
179
+ })
180
+
181
+ ws.emitMessage({ op: 'hello', heartbeat_interval: 41250 })
182
+ const init = ws.lastOp('init') as { encoded_public_key: string }
183
+ ws.emitMessage({ op: 'nonce_proof', encrypted_nonce: encryptForClient(init.encoded_public_key, 'n') })
184
+ ws.emitMessage({ op: 'pending_login', ticket: 'the-ticket' })
185
+
186
+ // When the gateway closes normally before the exchange resolves
187
+ ws.emit('close', 1000)
188
+ releaseFetch?.()
189
+
190
+ // Then the login still succeeds with the token from the completed exchange
191
+ const session = await sessionPromise
192
+ expect(session.token).toBe(TOKEN)
193
+ })
194
+
195
+ it('rejects (does not crash) when an encrypted payload is malformed', async () => {
196
+ // Given a nonce_proof whose ciphertext cannot be decrypted by the private key
197
+ const ws = new FakeWebSocket()
198
+ const sessionPromise = loginWithRemoteAuth({
199
+ createWebSocket: () => ws as unknown as import('ws').WebSocket,
200
+ })
201
+
202
+ ws.emitMessage({ op: 'hello', heartbeat_interval: 41250 })
203
+ // When garbage arrives where ciphertext is expected
204
+ ws.emitMessage({ op: 'nonce_proof', encrypted_nonce: 'not-valid-base64-ciphertext' })
205
+
206
+ // Then the decrypt error is surfaced as a rejection instead of throwing out of the handler
207
+ await expect(sessionPromise).rejects.toBeInstanceOf(Error)
208
+ })
209
+
210
+ it('resolves with a null user when pending_login arrives without pending_ticket', async () => {
211
+ // Given the gateway skips pending_ticket and goes straight to pending_login
212
+ const ws = new FakeWebSocket()
213
+ globalThis.fetch = (async () => {
214
+ const init = ws.lastOp('init') as { encoded_public_key: string }
215
+ return new Response(JSON.stringify({ encrypted_token: encryptForClient(init.encoded_public_key, TOKEN) }), {
216
+ status: 200,
217
+ })
218
+ }) as typeof fetch
219
+
220
+ const sessionPromise = loginWithRemoteAuth({
221
+ createWebSocket: () => ws as unknown as import('ws').WebSocket,
222
+ })
223
+
224
+ ws.emitMessage({ op: 'hello', heartbeat_interval: 41250 })
225
+ const init = ws.lastOp('init') as { encoded_public_key: string }
226
+ ws.emitMessage({ op: 'nonce_proof', encrypted_nonce: encryptForClient(init.encoded_public_key, 'n') })
227
+ ws.emitMessage({ op: 'pending_login', ticket: 'the-ticket' })
228
+
229
+ // Then the token is returned but user is null rather than a fabricated blank user
230
+ const session = await sessionPromise
231
+ expect(session.token).toBe(TOKEN)
232
+ expect(session.user).toBeNull()
233
+ })
234
+
235
+ it('exchanges the ticket only once even if pending_login is delivered twice', async () => {
236
+ // Given a duplicate pending_login from the gateway
237
+ const ws = new FakeWebSocket()
238
+ let fetchCalls = 0
239
+ globalThis.fetch = (async () => {
240
+ fetchCalls++
241
+ const init = ws.lastOp('init') as { encoded_public_key: string }
242
+ return new Response(JSON.stringify({ encrypted_token: encryptForClient(init.encoded_public_key, TOKEN) }), {
243
+ status: 200,
244
+ })
245
+ }) as typeof fetch
246
+
247
+ const sessionPromise = loginWithRemoteAuth({
248
+ createWebSocket: () => ws as unknown as import('ws').WebSocket,
249
+ })
250
+
251
+ ws.emitMessage({ op: 'hello', heartbeat_interval: 41250 })
252
+ const init = ws.lastOp('init') as { encoded_public_key: string }
253
+ ws.emitMessage({ op: 'nonce_proof', encrypted_nonce: encryptForClient(init.encoded_public_key, 'n') })
254
+ ws.emitMessage({ op: 'pending_login', ticket: 't' })
255
+ ws.emitMessage({ op: 'pending_login', ticket: 't' })
256
+
257
+ // Then only a single token exchange is performed
258
+ await sessionPromise
259
+ expect(fetchCalls).toBe(1)
260
+ })
261
+ })
@@ -0,0 +1,317 @@
1
+ import {
2
+ constants as cryptoConstants,
3
+ createHash,
4
+ createPrivateKey,
5
+ createPublicKey,
6
+ generateKeyPairSync,
7
+ privateDecrypt,
8
+ } from 'node:crypto'
9
+
10
+ import WebSocket from 'ws'
11
+
12
+ import { DiscordError } from './client'
13
+ import { getDiscordHeaders } from './super-properties'
14
+
15
+ const REMOTE_AUTH_GATEWAY_URL = 'wss://remote-auth-gateway.discord.gg/?v=2'
16
+ const REMOTE_AUTH_ORIGIN = 'https://discord.com'
17
+ const REMOTE_AUTH_LOGIN_URL = 'https://discord.com/api/v9/users/@me/remote-auth/login'
18
+ const QR_URL_PREFIX = 'https://discord.com/ra/'
19
+ const DEFAULT_TIMEOUT_MS = 150_000
20
+
21
+ interface RemoteAuthUser {
22
+ id: string
23
+ username: string
24
+ discriminator: string
25
+ avatar: string | null
26
+ }
27
+
28
+ export interface RemoteAuthSession {
29
+ token: string
30
+ user: RemoteAuthUser | null
31
+ }
32
+
33
+ export interface RemoteAuthOptions {
34
+ onQrUrl?: (url: string) => void | Promise<void>
35
+ onPendingLogin?: (user: RemoteAuthUser) => void
36
+ debug?: (message: string) => void
37
+ fetchImpl?: typeof fetch
38
+ timeoutMs?: number
39
+ createWebSocket?: (url: string) => WebSocket
40
+ }
41
+
42
+ interface RemoteAuthHello {
43
+ op: 'hello'
44
+ timeout_ms?: number
45
+ heartbeat_interval: number
46
+ }
47
+
48
+ interface RemoteAuthNonceProof {
49
+ op: 'nonce_proof'
50
+ encrypted_nonce: string
51
+ }
52
+
53
+ interface RemoteAuthPendingRemoteInit {
54
+ op: 'pending_remote_init'
55
+ fingerprint: string
56
+ }
57
+
58
+ interface RemoteAuthPendingTicket {
59
+ op: 'pending_ticket'
60
+ encrypted_user_payload: string
61
+ }
62
+
63
+ interface RemoteAuthPendingLogin {
64
+ op: 'pending_login'
65
+ ticket: string
66
+ }
67
+
68
+ type RemoteAuthMessage =
69
+ | RemoteAuthHello
70
+ | RemoteAuthNonceProof
71
+ | RemoteAuthPendingRemoteInit
72
+ | RemoteAuthPendingTicket
73
+ | RemoteAuthPendingLogin
74
+ | { op: 'heartbeat_ack' }
75
+ | { op: 'cancel' }
76
+ | { op: string; [key: string]: unknown }
77
+
78
+ /**
79
+ * Authenticate a Discord user account via the Remote Auth ("scan QR with the mobile app") flow.
80
+ *
81
+ * The desktop side (this function) opens a WebSocket to Discord's remote-auth gateway, performs an
82
+ * RSA-OAEP key exchange, and renders a QR code. Once the user scans it with an authenticated Discord
83
+ * mobile app and confirms, Discord returns an encrypted ticket which is exchanged for the user token.
84
+ */
85
+ export function loginWithRemoteAuth(options: RemoteAuthOptions = {}): Promise<RemoteAuthSession> {
86
+ const debug = options.debug
87
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS
88
+
89
+ const { publicKey, privateKey } = generateKeyPairSync('rsa', {
90
+ modulusLength: 2048,
91
+ publicExponent: 0x10001,
92
+ })
93
+
94
+ const ws = options.createWebSocket
95
+ ? options.createWebSocket(REMOTE_AUTH_GATEWAY_URL)
96
+ : new WebSocket(REMOTE_AUTH_GATEWAY_URL, { headers: { Origin: REMOTE_AUTH_ORIGIN } })
97
+
98
+ return new Promise<RemoteAuthSession>((resolve, reject) => {
99
+ let settled = false
100
+ let heartbeatTimer: ReturnType<typeof setInterval> | null = null
101
+ let pendingUser: RemoteAuthUser | null = null
102
+ let exchangeStarted = false
103
+
104
+ const cleanup = (): void => {
105
+ if (heartbeatTimer) {
106
+ clearInterval(heartbeatTimer)
107
+ heartbeatTimer = null
108
+ }
109
+ clearTimeout(timeoutTimer)
110
+ try {
111
+ ws.close()
112
+ } catch {}
113
+ }
114
+
115
+ const fail = (error: Error): void => {
116
+ if (settled) return
117
+ settled = true
118
+ cleanup()
119
+ reject(error)
120
+ }
121
+
122
+ const succeed = (session: RemoteAuthSession): void => {
123
+ if (settled) return
124
+ settled = true
125
+ cleanup()
126
+ resolve(session)
127
+ }
128
+
129
+ const timeoutTimer = setTimeout(() => {
130
+ fail(new DiscordError('Remote auth timed out before the QR code was scanned.', 'remote_auth_timeout'))
131
+ }, timeoutMs)
132
+ timeoutTimer.unref?.()
133
+
134
+ const send = (payload: Record<string, unknown>): void => {
135
+ try {
136
+ ws.send(JSON.stringify(payload))
137
+ } catch (error) {
138
+ fail(error instanceof Error ? error : new Error(String(error)))
139
+ }
140
+ }
141
+
142
+ ws.on('open', () => {
143
+ debug?.('Connected to remote auth gateway')
144
+ })
145
+
146
+ ws.on('message', (raw: WebSocket.RawData) => {
147
+ let message: RemoteAuthMessage
148
+ try {
149
+ message = JSON.parse(raw.toString()) as RemoteAuthMessage
150
+ } catch {
151
+ return
152
+ }
153
+
154
+ try {
155
+ handleMessage(message)
156
+ } catch (error) {
157
+ fail(error instanceof Error ? error : new Error(String(error)))
158
+ }
159
+ })
160
+
161
+ const handleMessage = (message: RemoteAuthMessage): void => {
162
+ switch (message.op) {
163
+ case 'hello': {
164
+ const interval = (message as RemoteAuthHello).heartbeat_interval
165
+ heartbeatTimer = setInterval(() => send({ op: 'heartbeat' }), interval)
166
+ heartbeatTimer.unref?.()
167
+ send({ op: 'init', encoded_public_key: encodePublicKey(publicKey) })
168
+ debug?.('Sent init with public key')
169
+ break
170
+ }
171
+
172
+ case 'nonce_proof': {
173
+ const encrypted = (message as RemoteAuthNonceProof).encrypted_nonce
174
+ const proof = computeNonceProof(privateKey, encrypted)
175
+ send({ op: 'nonce_proof', proof })
176
+ debug?.('Sent nonce proof')
177
+ break
178
+ }
179
+
180
+ case 'pending_remote_init': {
181
+ const { fingerprint } = message as RemoteAuthPendingRemoteInit
182
+ const qrUrl = `${QR_URL_PREFIX}${fingerprint}`
183
+ debug?.('Received fingerprint; QR ready')
184
+ Promise.resolve(options.onQrUrl?.(qrUrl)).catch((error) => {
185
+ fail(error instanceof Error ? error : new Error(String(error)))
186
+ })
187
+ break
188
+ }
189
+
190
+ case 'pending_ticket': {
191
+ const payload = decryptPayload(privateKey, (message as RemoteAuthPendingTicket).encrypted_user_payload)
192
+ pendingUser = parseUserPayload(payload.toString('utf8'))
193
+ options.onPendingLogin?.(pendingUser)
194
+ debug?.(`QR scanned by ${pendingUser.username}`)
195
+ break
196
+ }
197
+
198
+ case 'pending_login': {
199
+ if (exchangeStarted) break
200
+ exchangeStarted = true
201
+ const { ticket } = message as RemoteAuthPendingLogin
202
+ debug?.('Exchanging ticket for token')
203
+ exchangeTicket(ticket, privateKey, options.fetchImpl ?? fetch)
204
+ .then((token) => succeed({ token, user: pendingUser }))
205
+ .catch(fail)
206
+ break
207
+ }
208
+
209
+ case 'cancel': {
210
+ fail(new DiscordError('Remote auth was cancelled from the mobile device.', 'remote_auth_cancelled'))
211
+ break
212
+ }
213
+ }
214
+ }
215
+
216
+ ws.on('close', (code: number) => {
217
+ if (settled) return
218
+ // The gateway closes the socket right after pending_login; the in-flight
219
+ // ticket exchange settles the result, so this close must not preempt it.
220
+ if (exchangeStarted) return
221
+ // 4003 is the gateway's timeout close code.
222
+ if (code === 4003) {
223
+ fail(
224
+ new DiscordError('Remote auth session expired. Generate a new QR code and try again.', 'remote_auth_timeout'),
225
+ )
226
+ return
227
+ }
228
+ fail(new DiscordError(`Remote auth connection closed before completion (code ${code}).`, 'remote_auth_closed'))
229
+ })
230
+
231
+ ws.on('error', (error: Error) => {
232
+ fail(error instanceof Error ? error : new Error(String(error)))
233
+ })
234
+ })
235
+ }
236
+
237
+ function encodePublicKey(publicKey: ReturnType<typeof generateKeyPairSync>['publicKey']): string {
238
+ const spki = publicKey.export({ type: 'spki', format: 'der' })
239
+ return spki.toString('base64')
240
+ }
241
+
242
+ function decryptPayload(
243
+ privateKey: ReturnType<typeof generateKeyPairSync>['privateKey'],
244
+ encryptedBase64: string,
245
+ ): Buffer {
246
+ return privateDecrypt(
247
+ {
248
+ key: privateKey,
249
+ padding: cryptoConstants.RSA_PKCS1_OAEP_PADDING,
250
+ oaepHash: 'sha256',
251
+ },
252
+ Buffer.from(encryptedBase64, 'base64'),
253
+ )
254
+ }
255
+
256
+ function computeNonceProof(
257
+ privateKey: ReturnType<typeof generateKeyPairSync>['privateKey'],
258
+ encryptedNonce: string,
259
+ ): string {
260
+ const nonce = decryptPayload(privateKey, encryptedNonce)
261
+ return base64UrlNoPadding(createHash('sha256').update(nonce).digest())
262
+ }
263
+
264
+ function parseUserPayload(payload: string): RemoteAuthUser {
265
+ const [id = '', discriminator = '0', avatar = '', username = ''] = payload.split(':')
266
+ return { id, username, discriminator, avatar: avatar || null }
267
+ }
268
+
269
+ async function exchangeTicket(
270
+ ticket: string,
271
+ privateKey: ReturnType<typeof generateKeyPairSync>['privateKey'],
272
+ fetchImpl: typeof fetch,
273
+ ): Promise<string> {
274
+ const { Authorization: _omit, ...headers } = getDiscordHeaders('')
275
+ const response = await fetchImpl(REMOTE_AUTH_LOGIN_URL, {
276
+ method: 'POST',
277
+ headers: { ...headers, 'Content-Type': 'application/json' },
278
+ body: JSON.stringify({ ticket }),
279
+ })
280
+
281
+ if (response.status === 400) {
282
+ const body = (await response.json().catch(() => ({}))) as { captcha_sitekey?: string }
283
+ if (body.captcha_sitekey) {
284
+ throw new DiscordError(
285
+ 'Discord requires a captcha to complete this login. Captcha solving is not supported; use `auth extract` instead.',
286
+ 'remote_auth_captcha',
287
+ )
288
+ }
289
+ }
290
+
291
+ if (!response.ok) {
292
+ throw new DiscordError(
293
+ `Failed to exchange remote auth ticket (HTTP ${response.status}).`,
294
+ 'remote_auth_login_failed',
295
+ )
296
+ }
297
+
298
+ const body = (await response.json()) as { encrypted_token?: string }
299
+ if (!body.encrypted_token) {
300
+ throw new DiscordError('Remote auth response did not include a token.', 'remote_auth_login_failed')
301
+ }
302
+
303
+ return decryptPayload(privateKey, body.encrypted_token).toString('utf8')
304
+ }
305
+
306
+ function base64UrlNoPadding(buffer: Buffer): string {
307
+ return buffer.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
308
+ }
309
+
310
+ export const __test = {
311
+ encodePublicKey,
312
+ computeNonceProof,
313
+ parseUserPayload,
314
+ base64UrlNoPadding,
315
+ createPublicKey,
316
+ createPrivateKey,
317
+ }
@@ -760,6 +760,54 @@ describe('WebexClient', () => {
760
760
  })
761
761
  })
762
762
 
763
+ describe('setTyping', () => {
764
+ it('sends start_typing via internal API for extracted token', async () => {
765
+ // given
766
+ mockResponse(null, 204)
767
+ const client = await createExtractedClient()
768
+
769
+ // when
770
+ await client.setTyping(TEST_ROOM_ID)
771
+
772
+ // then
773
+ expect(fetchCalls).toHaveLength(1)
774
+ expect(fetchCalls[0].url).toContain('conv-r.wbx2.com/conversation/api/v1/conversations/')
775
+ expect(fetchCalls[0].url).toContain('/status/typing')
776
+ expect(fetchCalls[0].options?.method).toBe('POST')
777
+ const body = JSON.parse(fetchCalls[0].options?.body as string)
778
+ expect(body).toEqual({
779
+ conversationId: TEST_CONV_UUID,
780
+ eventType: 'status.start_typing',
781
+ })
782
+ })
783
+
784
+ it('sends stop_typing when typing is false', async () => {
785
+ // given
786
+ mockResponse(null, 204)
787
+ const client = await createExtractedClient()
788
+
789
+ // when
790
+ await client.setTyping(TEST_ROOM_ID, false)
791
+
792
+ // then
793
+ const body = JSON.parse(fetchCalls[0].options?.body as string)
794
+ expect(body.eventType).toBe('status.stop_typing')
795
+ expect(body.conversationId).toBe(TEST_CONV_UUID)
796
+ })
797
+
798
+ it('throws for non-internal token', async () => {
799
+ // given
800
+ const client = await new WebexClient().login({ token: 'plain-token' })
801
+
802
+ // when / then
803
+ await expect(client.setTyping('roomId')).rejects.toThrow(WebexError)
804
+ await expect(client.setTyping('roomId')).rejects.toThrow(
805
+ 'Typing indicator requires an extracted or password token with a device URL',
806
+ )
807
+ expect(fetchCalls).toHaveLength(0)
808
+ })
809
+ })
810
+
763
811
  describe('listMessages', () => {
764
812
  it('calls GET on conversations endpoint with activitiesLimit and participantsLimit', async () => {
765
813
  mockResponse(mockConversation([mockActivity('Hello')]))
@@ -1209,6 +1257,32 @@ describe('WebexClient', () => {
1209
1257
 
1210
1258
  await expect(client.uploadFile(TEST_ROOM_ID, file())).resolves.toBeDefined()
1211
1259
  })
1260
+
1261
+ it('accepts webexcontent.com upload urls returned by the server', async () => {
1262
+ mockResponse({ id: TEST_CONV_UUID })
1263
+ mockResponse({ spaceUrl: 'https://files-prod-us-west-2.webexcontent.com/spaces/sp1' })
1264
+ mockResponse({
1265
+ uploadUrl: 'https://files-prod-us-west-2.webexcontent.com/upload/sess1',
1266
+ finishUploadUrl: 'https://files-prod-us-west-2.webexcontent.com/upload/sess1/finish',
1267
+ })
1268
+ mockResponse({}, 200)
1269
+ mockResponse({ downloadUrl: 'https://files-prod-us-west-2.webexcontent.com/files/f1' })
1270
+ mockResponse({ ...mockActivity(''), verb: 'share' })
1271
+
1272
+ const client = await createExtractedClient()
1273
+
1274
+ await expect(client.uploadFile(TEST_ROOM_ID, file())).resolves.toBeDefined()
1275
+ })
1276
+
1277
+ it('refuses non-webex upload hosts that merely contain a trusted host', async () => {
1278
+ mockResponse({ id: TEST_CONV_UUID })
1279
+ mockResponse({ spaceUrl: 'https://webexcontent.com.evil.example/spaces/sp1' })
1280
+
1281
+ const client = await createExtractedClient()
1282
+
1283
+ await expect(client.uploadFile(TEST_ROOM_ID, file())).rejects.toThrow('untrusted host')
1284
+ expect(fetchCalls.every((c) => !c.url.includes('evil.example'))).toBe(true)
1285
+ })
1212
1286
  })
1213
1287
 
1214
1288
  describe('error handling', () => {