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
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: agent-telegram
3
3
  description: Interact with Telegram through TDLib - authenticate, inspect chats, and send messages
4
- version: 2.25.0
4
+ version: 2.26.0
5
5
  allowed-tools: Bash(agent-telegram:*)
6
6
  ---
7
7
 
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: agent-telegrambot
3
3
  description: Interact with Telegram using bot tokens - send messages, read chats, manage reactions
4
- version: 2.25.0
4
+ version: 2.26.0
5
5
  allowed-tools: Bash(agent-telegrambot:*)
6
6
  metadata:
7
7
  openclaw:
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: agent-webex
3
3
  description: Interact with Cisco Webex - send messages, read spaces, manage memberships
4
- version: 2.25.0
4
+ version: 2.26.0
5
5
  allowed-tools: Bash(agent-webex:*)
6
6
  metadata:
7
7
  openclaw:
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: agent-webexbot
3
3
  description: Interact with Cisco Webex using bot tokens - send messages, reply in threads, upload and download files, look up people, read spaces, manage memberships, stream real-time events
4
- version: 2.25.0
4
+ version: 2.26.0
5
5
  allowed-tools: Bash(agent-webexbot:*)
6
6
  metadata:
7
7
  openclaw:
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: agent-wechatbot
3
3
  description: Interact with WeChat Official Account using API credentials - send messages, manage templates, list followers
4
- version: 2.25.0
4
+ version: 2.26.0
5
5
  allowed-tools: Bash(agent-wechatbot:*)
6
6
  metadata:
7
7
  openclaw:
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: agent-whatsapp
3
3
  description: Interact with WhatsApp - send messages, read chats, manage conversations
4
- version: 2.25.0
4
+ version: 2.26.0
5
5
  allowed-tools: Bash(agent-whatsapp:*)
6
6
  metadata:
7
7
  openclaw:
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: agent-whatsappbot
3
3
  description: Interact with WhatsApp using Cloud API credentials - send messages, manage templates
4
- version: 2.25.0
4
+ version: 2.26.0
5
5
  allowed-tools: Bash(agent-whatsappbot:*)
6
6
  metadata:
7
7
  openclaw:
@@ -3,10 +3,12 @@ import { Command } from 'commander'
3
3
  import { collectBrowserProfileOption } from '@/shared/chromium'
4
4
  import { handleError } from '@/shared/utils/error-handler'
5
5
  import { formatOutput } from '@/shared/utils/output'
6
- import { debug } from '@/shared/utils/stderr'
6
+ import { displayQR } from '@/shared/utils/qr'
7
+ import { debug, info } from '@/shared/utils/stderr'
7
8
 
8
9
  import { DiscordClient } from '../client'
9
10
  import { DiscordCredentialManager } from '../credential-manager'
11
+ import { loginWithRemoteAuth } from '../remote-auth'
10
12
  import { DiscordTokenExtractor } from '../token-extractor'
11
13
 
12
14
  export async function extractAction(options: {
@@ -137,6 +139,77 @@ export async function extractAction(options: {
137
139
  }
138
140
  }
139
141
 
142
+ async function saveTokenWithServers(
143
+ token: string,
144
+ credManager: DiscordCredentialManager,
145
+ ): Promise<{ username: string; servers: { id: string; name: string }[]; current: string } | null> {
146
+ const client = await new DiscordClient().login({ token })
147
+ const authInfo = await client.testAuth()
148
+ const servers = await client.listServers()
149
+ if (servers.length === 0) return null
150
+
151
+ const serverMap: Record<string, { server_id: string; server_name: string }> = {}
152
+ for (const server of servers) {
153
+ serverMap[server.id] = { server_id: server.id, server_name: server.name }
154
+ }
155
+
156
+ await credManager.save({ token, current_server: servers[0].id, servers: serverMap })
157
+ return {
158
+ username: authInfo.username,
159
+ servers: servers.map((s) => ({ id: s.id, name: s.name })),
160
+ current: servers[0].id,
161
+ }
162
+ }
163
+
164
+ export async function qrAction(options: { pretty?: boolean; debug?: boolean }): Promise<void> {
165
+ try {
166
+ const interactive = Boolean(process.stdout.isTTY)
167
+ const debugLog = options.debug ? (msg: string) => debug(`[debug] ${msg}`) : undefined
168
+
169
+ const session = await loginWithRemoteAuth({
170
+ debug: debugLog,
171
+ onQrUrl: async (url) => {
172
+ await displayQR(url, {
173
+ platform: 'Discord',
174
+ brandColor: '#5865F2',
175
+ scanInstruction: 'Scan with the Discord mobile app (Settings → Scan QR Code)',
176
+ interactive,
177
+ formatOutput,
178
+ pretty: options.pretty,
179
+ })
180
+ },
181
+ onPendingLogin: (user) => {
182
+ if (interactive) info(`\nQR scanned by ${user.username}. Confirm the login on your phone...\n`)
183
+ },
184
+ })
185
+
186
+ const credManager = new DiscordCredentialManager()
187
+ const saved = await saveTokenWithServers(session.token, credManager)
188
+
189
+ if (!saved) {
190
+ console.log(
191
+ formatOutput(
192
+ {
193
+ error: 'Logged in, but this account is not a member of any server.',
194
+ hint: 'Join at least one server, then run this command again.',
195
+ },
196
+ options.pretty,
197
+ ),
198
+ )
199
+ process.exit(1)
200
+ }
201
+
202
+ console.log(
203
+ formatOutput(
204
+ { user: saved.username, servers: saved.servers.map((s) => `${s.id}/${s.name}`), current: saved.current },
205
+ options.pretty,
206
+ ),
207
+ )
208
+ } catch (error) {
209
+ handleError(error as Error)
210
+ }
211
+ }
212
+
140
213
  export async function logoutAction(options: { pretty?: boolean }): Promise<void> {
141
214
  try {
142
215
  const credManager = new DiscordCredentialManager()
@@ -208,6 +281,13 @@ export const authCommand = new Command('auth')
208
281
  )
209
282
  .action(extractAction),
210
283
  )
284
+ .addCommand(
285
+ new Command('qr')
286
+ .description('Sign in by scanning a QR code with the Discord mobile app')
287
+ .option('--pretty', 'Pretty print JSON output')
288
+ .option('--debug', 'Show debug output for troubleshooting')
289
+ .action(qrAction),
290
+ )
211
291
  .addCommand(
212
292
  new Command('logout')
213
293
  .description('Logout from Discord')
@@ -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
+ })