Skip to content

Commit b817e07

Browse files
committed
feat(client): resolve whatsapp usernames via the mex graphql lookup
1 parent 69497bd commit b817e07

5 files changed

Lines changed: 249 additions & 4 deletions

File tree

docs/content/api-reference.mdx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,8 @@ client.on('text', (ctx) => {
5858
| `pnToLid(pn)` | `(pn: string): Promise<string \| null>` | Resolve a phone-number JID to its `@lid` JID (`null` if unknown). Needs a connected socket. |
5959
| `lidToPns(lids)` | `(lids: string[]): Promise<Map<string, string>>` | Bulk `lidToPn` in one round trip. Unresolved jids are absent from the map. |
6060
| `pnToLids(pns)` | `(pns: string[]): Promise<Map<string, string>>` | Bulk `pnToLid` in one round trip. Unresolved jids are absent from the map. |
61+
| `getUsername(jid)` | `(jid: string): Promise<string \| null>` | The account's WhatsApp username (`@handle`). `null` when WhatsApp withholds it — see [Usernames](/client#usernames). |
62+
| `usernames(jids)` | `(jids: string[]): Promise<Map<string, string>>` | Bulk `getUsername` in one round trip. Accounts with no disclosed username are absent. |
6163
| `broadcast(jids, build, opts?)` | `(jids: string[], build, opts?): Promise<BroadcastResult>` | Send to many recipients. See [Broadcast & Schedule](/automation). |
6264
| `scheduleAt(date, build, opts?)` | `(date: Date, build, opts?): Promise<ScheduleHandle>` | Schedule a message for later. |
6365
| `command(spec, handler)` | `(spec: string, handler: CommandHandler): this` | Register a command. See [Commands](/commands). |

docs/content/client.mdx

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -414,6 +414,41 @@ Jids WhatsApp could not resolve are simply **absent** from the map rather than m
414414
lookup yields an empty map.
415415
</Callout>
416416

417+
### Usernames
418+
419+
| Method | Signature | Description |
420+
| ------ | --------- | ----------- |
421+
| `getUsername` | `getUsername(jid: string): Promise<string \| null>` | The account's WhatsApp username (`@handle`), without the `@`. |
422+
| `usernames` | `usernames(jids: string[]): Promise<Map<string, string>>` | Bulk form, one round trip. Accounts with no username are absent. |
423+
424+
```typescript
425+
await client.getUsername('115362955808779@lid') // 'hacelo' | null
426+
427+
const map = await client.usernames(['115362955808779@lid', '234256710246613@lid'])
428+
// Map { '115362955808779@lid' => 'hacelo', '234256710246613@lid' => 'hanntylor' }
429+
```
430+
431+
<Callout type="warn">
432+
**WhatsApp gives you the phone number _or_ the username — never both.** A username is the fallback
433+
handle for someone whose number you cannot see. If your account already knows a contact's phone
434+
number, the lookup returns nothing for them, even when that person really has a username set. This
435+
is WhatsApp's disclosure policy, not a limitation of zaileys — the official client behaves the same.
436+
437+
In practice that means usernames come back for **`@lid` jids whose `phoneNumber` is unknown**, which
438+
is exactly where you need a readable handle. Use [`lidToPn`](#lid--phone-number) first; if it returns
439+
`null`, ask for the username.
440+
</Callout>
441+
442+
<Callout type="info">
443+
This uses WhatsApp's current GraphQL lookup. The `username` USync protocol that baileys still ships
444+
is **retired server-side** — it answers with an empty node for every jid — so `client.getUsername()`
445+
is the only path that works.
446+
</Callout>
447+
448+
Group metadata carries usernames for free under the same rule: `client.group.metadata(jid)` returns
449+
`participants[].username` (plus `ownerUsername`, `subjectOwnerUsername`, `descOwnerUsername`)
450+
populated for members whose phone number WhatsApp withholds — no extra call needed.
451+
417452
<Callout type="info">
418453
For pure, offline JID inspection (decode, normalize, predicates) use the
419454
[JID helpers](/utilities#jid-helpers) instead — those need no socket.

docs/content/message-payload.mdx

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -228,7 +228,9 @@ A few values come from your `Client` config rather than the raw message:
228228
- **Methods are lazy & mostly async.** `roomName()`, `replied()`, `media.buffer()`, `citation.*` do real work — `await` them; they show as `[Function]` when you log the object.
229229
- **`roomId` follows the conversation, not the direction** — safe as a reply target for both inbound and your own (`isFromMe`) messages.
230230
- **`isSpam` is reserved** and always `false` today.
231-
- **`senderUsername` is usually `null`.** WhatsApp only attaches a username when the sender has
232-
actually set one, and in practice it arrives mainly on **group** messages from senders your account
233-
knows only by LID. Treat it as a bonus, never as an identity you can rely on — `senderId` remains
234-
the field to use for authorization.
231+
- **`senderUsername` is usually `null` — WhatsApp sends the phone number _or_ the username, never
232+
both.** A username is the fallback handle for a sender whose number you cannot see, so it stays
233+
`null` for anyone whose phone number your account already knows (which is most people). It arrives
234+
mainly on **group** messages from LID-only senders. Never use it for authorization — `senderId` is
235+
the field for that. To ask for one explicitly, see
236+
[`client.getUsername(jid)`](/client#usernames).

src/client/client.ts

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import makeWASocket, {
22
fetchLatestBaileysVersion,
33
initAuthCreds,
4+
S_WHATSAPP_NET,
45
type AuthenticationCreds,
56
type UserFacingSocketConfig,
67
type WAMessage,
@@ -132,6 +133,46 @@ interface SocketCleanup {
132133
off: () => void
133134
}
134135

136+
/**
137+
* WhatsApp retired the USync `username` protocol baileys still ships — it answers with an empty node
138+
* for everyone. This GraphQL document is what the official client moved to.
139+
*/
140+
const MEX_FETCH_USERS_QUERY_ID = '29829202653362039'
141+
142+
/** Structural view of the raw socket bits used for GraphQL lookups baileys does not wrap. */
143+
interface MexCapableSocket {
144+
query?: (node: unknown) => Promise<unknown>
145+
generateMessageTag?: () => string
146+
}
147+
148+
/**
149+
* Reads the `<result>` payload of a `w:mex` reply. A jid with no username comes back as an
150+
* `XWA2ResponseStatus` instead of an `XWA2Username`, so entries without one are skipped.
151+
*/
152+
export const parseMexUsers = (result: unknown): Array<{ jid: string; username: string }> => {
153+
const children = (result as { content?: unknown })?.content
154+
if (!Array.isArray(children)) return []
155+
const body = children.find((c) => (c as { tag?: string })?.tag === 'result')?.content
156+
if (!Buffer.isBuffer(body)) return []
157+
let parsed: unknown
158+
try {
159+
parsed = JSON.parse(body.toString())
160+
} catch {
161+
return []
162+
}
163+
const users = (parsed as { data?: { xwa2_fetch_wa_users?: unknown } })?.data?.xwa2_fetch_wa_users
164+
if (!Array.isArray(users)) return []
165+
const out: Array<{ jid: string; username: string }> = []
166+
for (const user of users) {
167+
const jid = (user as { jid?: unknown })?.jid
168+
const username = (user as { username_info?: { username?: unknown } })?.username_info?.username
169+
if (typeof jid === 'string' && typeof username === 'string' && username.length > 0) {
170+
out.push({ jid, username })
171+
}
172+
}
173+
return out
174+
}
175+
135176
/** Structural view of baileys' `signalRepository.lidMapping`, which no public socket type exposes. */
136177
interface LidMappingStore {
137178
getPNForLID?: (lid: string) => Promise<string | null>
@@ -1159,6 +1200,48 @@ export class Client extends TypedEventEmitter<ClientEventMap> {
11591200
return this.lidMapBulk('getLIDsForPNs', pns, 'pn')
11601201
}
11611202

1203+
/**
1204+
* Looks up WhatsApp usernames (`@handle`) for many jids in one round trip. Returns a `jid -> username`
1205+
* map; jids whose owner has not set a username are simply absent. Works best with `@lid` jids.
1206+
*/
1207+
async usernames(jids: string[]): Promise<Map<string, string>> {
1208+
const out = new Map<string, string>()
1209+
if (jids.length === 0) return out
1210+
const socket = this._socket as MexCapableSocket | undefined
1211+
const send = socket?.query
1212+
const nextTag = socket?.generateMessageTag
1213+
if (typeof send !== 'function' || typeof nextTag !== 'function') return out
1214+
1215+
const variables = {
1216+
input: { query_input: jids.map((jid) => ({ jid })), telemetry: { context: 'INTERACTIVE' } },
1217+
include_username: true,
1218+
include_about_status: false,
1219+
include_country_code: false,
1220+
}
1221+
try {
1222+
const result = await send({
1223+
tag: 'iq',
1224+
attrs: { id: nextTag(), type: 'get', to: S_WHATSAPP_NET, xmlns: 'w:mex' },
1225+
content: [
1226+
{
1227+
tag: 'query',
1228+
attrs: { query_id: MEX_FETCH_USERS_QUERY_ID },
1229+
content: Buffer.from(JSON.stringify({ variables }), 'utf-8'),
1230+
},
1231+
],
1232+
})
1233+
for (const entry of parseMexUsers(result)) out.set(entry.jid, entry.username)
1234+
} catch {
1235+
return out
1236+
}
1237+
return out
1238+
}
1239+
1240+
/** Single-jid {@link usernames}. `null` when the account has no username or the lookup fails. */
1241+
async getUsername(jid: string): Promise<string | null> {
1242+
return (await this.usernames([jid])).get(jid) ?? null
1243+
}
1244+
11621245
/**
11631246
* Download media bytes for a message stored in the message store (by key).
11641247
* Tries both `fromMe` variants. `null` when the message is unknown or carries no media.

tests/client/usernames.test.ts

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
import { describe, expect, it, vi } from 'vitest'
2+
import { Client, parseMexUsers } from '../../src/client/client.js'
3+
import { MemoryAuthStore } from '../../src/auth/adapters/memory.js'
4+
5+
const LID = '115362955808779@lid'
6+
const LID2 = '234256710246613@lid'
7+
8+
/** Shaped from a real `w:mex` reply captured on the wire. */
9+
const mexReply = (users: unknown[]) => ({
10+
tag: 'iq',
11+
attrs: { from: '@s.whatsapp.net', type: 'result' },
12+
content: [
13+
{ tag: 'result', attrs: {}, content: Buffer.from(JSON.stringify({ data: { xwa2_fetch_wa_users: users } })) },
14+
],
15+
})
16+
17+
const namedUser = (jid: string, username: string) => ({
18+
__typename: 'XWA2User',
19+
id: null,
20+
jid,
21+
username_info: { __typename: 'XWA2Username', pin: null, state: null, timestamp: null, username },
22+
})
23+
24+
const emptyUser = (jid: string) => ({
25+
__typename: 'XWA2User',
26+
id: null,
27+
jid,
28+
username_info: { __typename: 'XWA2ResponseStatus', status: 'EMPTY' },
29+
})
30+
31+
const connected = (query: unknown) => {
32+
const c = new Client({ auth: new MemoryAuthStore(), qrTerminal: false, autoConnect: false })
33+
;(c as unknown as { _socket: unknown })._socket = { query, generateMessageTag: () => 'TAG1' }
34+
return c
35+
}
36+
37+
describe('parseMexUsers', () => {
38+
it('reads the usernames out of a real reply', () => {
39+
expect(parseMexUsers(mexReply([namedUser(LID, 'hacelo'), namedUser(LID2, 'hanntylor')]))).toEqual([
40+
{ jid: LID, username: 'hacelo' },
41+
{ jid: LID2, username: 'hanntylor' },
42+
])
43+
})
44+
45+
it('skips users WhatsApp reports as having no username', () => {
46+
expect(parseMexUsers(mexReply([emptyUser(LID), namedUser(LID2, 'hanntylor')]))).toEqual([
47+
{ jid: LID2, username: 'hanntylor' },
48+
])
49+
})
50+
51+
it('survives a malformed or unexpected payload', () => {
52+
expect(parseMexUsers(undefined)).toEqual([])
53+
expect(parseMexUsers({ content: 'not-an-array' })).toEqual([])
54+
expect(parseMexUsers({ content: [{ tag: 'other', content: Buffer.from('{}') }] })).toEqual([])
55+
expect(parseMexUsers({ content: [{ tag: 'result', content: Buffer.from('not json') }] })).toEqual([])
56+
expect(parseMexUsers({ content: [{ tag: 'result', content: Buffer.from('{"data":{}}') }] })).toEqual([])
57+
})
58+
59+
it('ignores an entry whose username is empty', () => {
60+
expect(parseMexUsers(mexReply([namedUser(LID, '')]))).toEqual([])
61+
})
62+
})
63+
64+
describe('Client.usernames', () => {
65+
it('resolves many jids in a single round trip', async () => {
66+
const query = vi.fn(async () => mexReply([namedUser(LID, 'hacelo'), namedUser(LID2, 'hanntylor')]))
67+
const map = await connected(query).usernames([LID, LID2])
68+
expect(query).toHaveBeenCalledTimes(1)
69+
expect(map.get(LID)).toBe('hacelo')
70+
expect(map.get(LID2)).toBe('hanntylor')
71+
})
72+
73+
it('sends the GraphQL document the official client uses', async () => {
74+
const query = vi.fn(async () => mexReply([]))
75+
await connected(query).usernames([LID])
76+
const iq = query.mock.calls[0]![0] as {
77+
attrs: { xmlns: string }
78+
content: Array<{ attrs: { query_id: string }; content: Buffer }>
79+
}
80+
expect(iq.attrs.xmlns).toBe('w:mex')
81+
expect(iq.content[0]!.attrs.query_id).toBe('29829202653362039')
82+
const body = JSON.parse(iq.content[0]!.content.toString())
83+
expect(body.variables.include_username).toBe(true)
84+
expect(body.variables.input.query_input).toEqual([{ jid: LID }])
85+
})
86+
87+
it('omits jids that have no username rather than mapping them to null', async () => {
88+
const query = vi.fn(async () => mexReply([emptyUser(LID)]))
89+
const map = await connected(query).usernames([LID])
90+
expect(map.has(LID)).toBe(false)
91+
expect(map.size).toBe(0)
92+
})
93+
94+
it('skips the round trip for an empty input', async () => {
95+
const query = vi.fn(async () => mexReply([]))
96+
expect((await connected(query).usernames([])).size).toBe(0)
97+
expect(query).not.toHaveBeenCalled()
98+
})
99+
100+
it('returns an empty map when the query throws', async () => {
101+
const query = vi.fn(async () => {
102+
throw new Error('offline')
103+
})
104+
expect((await connected(query).usernames([LID])).size).toBe(0)
105+
})
106+
107+
it('returns an empty map when no socket is attached', async () => {
108+
const c = new Client({ auth: new MemoryAuthStore(), qrTerminal: false, autoConnect: false })
109+
expect((await c.usernames([LID])).size).toBe(0)
110+
})
111+
})
112+
113+
describe('Client.getUsername', () => {
114+
it('returns the single username', async () => {
115+
const query = vi.fn(async () => mexReply([namedUser(LID, 'hacelo')]))
116+
await expect(connected(query).getUsername(LID)).resolves.toBe('hacelo')
117+
})
118+
119+
it('returns null when the account has no username', async () => {
120+
const query = vi.fn(async () => mexReply([emptyUser(LID)]))
121+
await expect(connected(query).getUsername(LID)).resolves.toBeNull()
122+
})
123+
})

0 commit comments

Comments
 (0)