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.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +1 -1
- package/dist/package.json +1 -1
- package/dist/src/platforms/discord/commands/auth.d.ts +4 -0
- package/dist/src/platforms/discord/commands/auth.d.ts.map +1 -1
- package/dist/src/platforms/discord/commands/auth.js +61 -1
- package/dist/src/platforms/discord/commands/auth.js.map +1 -1
- package/dist/src/platforms/discord/index.d.ts +2 -0
- package/dist/src/platforms/discord/index.d.ts.map +1 -1
- package/dist/src/platforms/discord/index.js +1 -0
- package/dist/src/platforms/discord/index.js.map +1 -1
- package/dist/src/platforms/discord/remote-auth.d.ts +42 -0
- package/dist/src/platforms/discord/remote-auth.d.ts.map +1 -0
- package/dist/src/platforms/discord/remote-auth.js +208 -0
- package/dist/src/platforms/discord/remote-auth.js.map +1 -0
- package/docs/content/docs/sdk/discord.mdx +22 -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 +9 -2
- 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 +1 -1
- 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/discord/commands/auth.ts +81 -1
- package/src/platforms/discord/index.ts +2 -0
- package/src/platforms/discord/remote-auth.test.ts +261 -0
- 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
|
+
}
|