agent-messenger 2.25.0 → 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 (38) 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/docs/content/docs/sdk/discord.mdx +22 -0
  17. package/package.json +1 -1
  18. package/skills/agent-channeltalk/SKILL.md +1 -1
  19. package/skills/agent-channeltalkbot/SKILL.md +1 -1
  20. package/skills/agent-discord/SKILL.md +9 -2
  21. package/skills/agent-discordbot/SKILL.md +1 -1
  22. package/skills/agent-instagram/SKILL.md +1 -1
  23. package/skills/agent-kakaotalk/SKILL.md +1 -1
  24. package/skills/agent-line/SKILL.md +1 -1
  25. package/skills/agent-slack/SKILL.md +1 -1
  26. package/skills/agent-slackbot/SKILL.md +1 -1
  27. package/skills/agent-teams/SKILL.md +1 -1
  28. package/skills/agent-telegram/SKILL.md +1 -1
  29. package/skills/agent-telegrambot/SKILL.md +1 -1
  30. package/skills/agent-webex/SKILL.md +1 -1
  31. package/skills/agent-webexbot/SKILL.md +1 -1
  32. package/skills/agent-wechatbot/SKILL.md +1 -1
  33. package/skills/agent-whatsapp/SKILL.md +1 -1
  34. package/skills/agent-whatsappbot/SKILL.md +1 -1
  35. package/src/platforms/discord/commands/auth.ts +81 -1
  36. package/src/platforms/discord/index.ts +2 -0
  37. package/src/platforms/discord/remote-auth.test.ts +261 -0
  38. package/src/platforms/discord/remote-auth.ts +317 -0
@@ -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
+ }