Skip to content

Commit 23d5569

Browse files
authored
Merge c217d0e into c14a0c6
2 parents c14a0c6 + c217d0e commit 23d5569

21 files changed

Lines changed: 643 additions & 52 deletions

AGENTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,7 @@ Skills own workflows; root owns hard policy and routing.
109109
- `ship` that fixes an issue: after push, comment proof + commit link, then close the issue.
110110
- GH comments with backticks, `$`, or shell snippets: use heredoc/body file, not inline double-quoted `--body`.
111111
- PR create: real body required. Include Summary + Verification; mention refs, behavior, and proof.
112-
- Real behavior proof section is parsed. Use exact `field: value` labels: `Behavior addressed`, `Real environment tested`, `Exact steps or command run after this patch`, `Evidence after fix`, `Observed result after fix`, `What was not tested`.
112+
- External/contributor PRs must include a `## Real behavior proof` section in the PR body. The `pull_request_target` proof check parses it and fails immediately when it is missing. Use exact `field: value` labels: `Behavior addressed`, `Real environment tested`, `Exact steps or command run after this patch`, `Evidence after fix`, `Observed result after fix`, `What was not tested`.
113113
- PR artifacts/screenshots: attach to PR/comment/external artifact store. Do not commit `.github/pr-assets`.
114114
- CI polling: exact SHA, relevant checks only, minimal fields. Skip routine noise (`Auto response`, `Labeler`, docs agents, performance/stale). Logs only after failure/completion or concrete need.
115115
- Maintainers: may skip/ignore `Real behavior proof` when local tests or Crabbox verified behavior; record proof in PR verification.

docs/channels/whatsapp.md

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ fallback. Pin an exact version only when you need a reproducible install.
6060

6161
</Step>
6262

63-
<Step title="Link WhatsApp (QR)">
63+
<Step title="Link WhatsApp">
6464

6565
```bash
6666
openclaw channels login --channel whatsapp
@@ -74,6 +74,12 @@ openclaw channels login --channel whatsapp
7474

7575
```bash
7676
openclaw channels login --channel whatsapp --account work
77+
```
78+
79+
If the phone cannot scan a QR code, request a phone-code login instead. Use the full phone number with country code; punctuation is accepted and normalized before OpenClaw asks WhatsApp for the code. Omit optional national trunk prefixes such as `(0)`.
80+
81+
```bash
82+
openclaw channels login --channel whatsapp --account work --phone-number 15551234567
7783
```
7884

7985
To attach an existing/custom WhatsApp Web auth directory before login:
@@ -590,18 +596,31 @@ Behavior notes:
590596
## Troubleshooting
591597

592598
<AccordionGroup>
593-
<Accordion title="Not linked (QR required)">
599+
<Accordion title="Not linked (QR or phone code required)">
594600
Symptom: channel status reports not linked.
595601

596602
Fix:
597603

598604
```bash
599605
openclaw channels login --channel whatsapp
606+
# or, when QR scanning is unavailable:
607+
openclaw channels login --channel whatsapp --phone-number 15551234567
600608
openclaw channels status
601609
```
602610

603611
</Accordion>
604612

613+
<Accordion title="QR scanner unavailable">
614+
Use WhatsApp's phone-code linked-device flow:
615+
616+
```bash
617+
openclaw channels login --channel whatsapp --phone-number 15551234567
618+
```
619+
620+
Open WhatsApp on the phone, go to _Linked Devices_, choose _Link with phone number_, then enter the code printed by OpenClaw. After the phone accepts it, OpenClaw saves the same WhatsApp Web credentials used by QR login. Enter the international number only, without optional trunk-prefix notation such as `(0)`.
621+
622+
</Accordion>
623+
605624
<Accordion title="Linked but disconnected / reconnect loop">
606625
Symptom: linked account with repeated disconnects or reconnect attempts.
607626

docs/cli/channels.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,10 +100,12 @@ If your config was already in a mixed state (named accounts present and top-leve
100100

101101
```bash
102102
openclaw channels login --channel whatsapp
103+
openclaw channels login --channel whatsapp --phone-number 15551234567
103104
openclaw channels logout --channel whatsapp
104105
```
105106

106107
- `channels login` supports `--verbose`.
108+
- Channels that support phone-code login can also consume `--phone-number <number>`. WhatsApp normalizes punctuation and sends digits with country code to WhatsApp's linked-device code flow. Omit optional national trunk prefixes such as `(0)`.
107109
- `channels login` and `logout` can infer the channel when only one supported login target is configured.
108110
- `channels logout` prefers the live Gateway path when reachable, so logout stops any active listener before clearing channel auth state. If a local Gateway is not reachable, it falls back to local auth cleanup.
109111
- Run `channels login` from a terminal on the gateway host. Agent `exec` blocks this interactive login flow; channel-native agent login tools, such as `whatsapp_login`, should be used from chat when available.

docs/platforms/mac/remote.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,7 @@ the selected transport when it starts.
9696
## WhatsApp login flow (remote)
9797

9898
- Run `openclaw channels login --verbose` **on the remote host**. Scan the QR with WhatsApp on your phone.
99+
- If QR scanning is unavailable, run `openclaw channels login --verbose --phone-number 15551234567` on the remote host, then enter the printed code in WhatsApp under _Linked Devices__Link with phone number_.
99100
- Re-run login on that host if auth expires. Health check will surface link problems.
100101

101102
## Troubleshooting

extensions/whatsapp/src/auth-store.test.ts

Lines changed: 120 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import fs from "node:fs/promises";
33
import path from "node:path";
44
import { beforeEach, describe, expect, it, vi } from "vitest";
55
import {
6+
clearStalePhoneCodePairingAuthIfNeeded,
67
getWebAuthAgeMs,
78
hasWebCredsSync,
89
logoutWeb,
@@ -150,11 +151,18 @@ describe("auth-store", () => {
150151

151152
it("reports linked auth state and snapshot from the shared read helper", async () => {
152153
const authDir = createTempAuthDir("openclaw-wa-auth-linked");
154+
const credsPath = path.join(authDir, "creds.json");
153155
fsSync.writeFileSync(
154-
path.join(authDir, "creds.json"),
155-
JSON.stringify({ me: { id: "15551234567@s.whatsapp.net" } }),
156+
credsPath,
157+
JSON.stringify({
158+
account: { details: "present" },
159+
me: { id: "15551234567@s.whatsapp.net" },
160+
platform: "chrome",
161+
}),
156162
"utf-8",
157163
);
164+
const stablePast = new Date(Date.now() - 1000);
165+
fsSync.utimesSync(credsPath, stablePast, stablePast);
158166

159167
await expect(readWebAuthState(authDir)).resolves.toBe("linked");
160168
const snapshot = await readWebAuthSnapshot(authDir);
@@ -229,11 +237,120 @@ describe("auth-store", () => {
229237
},
230238
);
231239

240+
it("does not report phone-code pairing attempts as linked auth", async () => {
241+
const authDir = createTempAuthDir("openclaw-wa-auth-pairing-partial");
242+
fsSync.writeFileSync(
243+
path.join(authDir, "creds.json"),
244+
JSON.stringify({
245+
me: { id: "15551234567@s.whatsapp.net" },
246+
pairingCode: "ABCDEFGH",
247+
registered: false,
248+
}),
249+
"utf-8",
250+
);
251+
252+
await expect(webAuthExists(authDir)).resolves.toBe(false);
253+
await expect(readWebAuthState(authDir)).resolves.toBe("not-linked");
254+
expect(hasWebCredsSync(authDir)).toBe(false);
255+
});
256+
257+
it("clears stale partial phone-code pairing credentials before login", async () => {
258+
await withOwnedOAuthAuthDir("openclaw-wa-auth-clear-pairing-partial", async (authDir) => {
259+
fsSync.writeFileSync(
260+
path.join(authDir, "creds.json"),
261+
JSON.stringify({
262+
me: { id: "15551234567@s.whatsapp.net" },
263+
pairingCode: "ABCDEFGH",
264+
registered: false,
265+
}),
266+
"utf-8",
267+
);
268+
fsSync.writeFileSync(path.join(authDir, "pre-key-1.json"), "{}", "utf-8");
269+
const runtime = {
270+
log: vi.fn(),
271+
error: vi.fn(),
272+
exit: vi.fn(),
273+
};
274+
275+
await expect(
276+
clearStalePhoneCodePairingAuthIfNeeded({ authDir, runtime: runtime as never }),
277+
).resolves.toBe(true);
278+
279+
expect(fsSync.existsSync(authDir)).toBe(false);
280+
expect(runtime.log).toHaveBeenCalledWith(
281+
expect.stringContaining("Cleared stale partial WhatsApp phone-code pairing credentials"),
282+
);
283+
});
284+
});
285+
286+
it("restores valid backup before clearing partial phone-code pairing credentials", async () => {
287+
await withOwnedOAuthAuthDir("openclaw-wa-auth-clear-pairing-with-backup", async (authDir) => {
288+
const credsPath = path.join(authDir, "creds.json");
289+
const backupPath = path.join(authDir, "creds.json.bak");
290+
const backupCreds = {
291+
account: { details: "present" },
292+
me: { id: "15551234567@s.whatsapp.net" },
293+
platform: "chrome",
294+
registered: false,
295+
};
296+
fsSync.writeFileSync(
297+
credsPath,
298+
JSON.stringify({
299+
me: { id: "15551234567@s.whatsapp.net" },
300+
pairingCode: "ABCDEFGH",
301+
registered: false,
302+
}),
303+
"utf-8",
304+
);
305+
fsSync.writeFileSync(backupPath, JSON.stringify(backupCreds), "utf-8");
306+
const runtime = {
307+
log: vi.fn(),
308+
error: vi.fn(),
309+
exit: vi.fn(),
310+
};
311+
312+
await expect(
313+
clearStalePhoneCodePairingAuthIfNeeded({ authDir, runtime: runtime as never }),
314+
).resolves.toBe(true);
315+
316+
expect(fsSync.existsSync(authDir)).toBe(true);
317+
expect(JSON.parse(fsSync.readFileSync(credsPath, "utf-8"))).toEqual(backupCreds);
318+
expect(JSON.parse(fsSync.readFileSync(backupPath, "utf-8"))).toEqual(backupCreds);
319+
expect(hasWebCredsSync(authDir)).toBe(true);
320+
expect(runtime.log).toHaveBeenCalledWith(
321+
expect.stringContaining("Restored WhatsApp Web credentials from backup"),
322+
);
323+
});
324+
});
325+
326+
it("keeps fully linked credentials during partial-pairing cleanup", async () => {
327+
await withOwnedOAuthAuthDir("openclaw-wa-auth-keep-linked", async (authDir) => {
328+
fsSync.writeFileSync(
329+
path.join(authDir, "creds.json"),
330+
JSON.stringify({
331+
account: { details: "present" },
332+
me: { id: "15551234567@s.whatsapp.net" },
333+
platform: "chrome",
334+
registered: false,
335+
}),
336+
"utf-8",
337+
);
338+
339+
await expect(clearStalePhoneCodePairingAuthIfNeeded({ authDir })).resolves.toBe(false);
340+
expect(fsSync.existsSync(path.join(authDir, "creds.json"))).toBe(true);
341+
expect(hasWebCredsSync(authDir)).toBe(true);
342+
});
343+
});
344+
232345
it("reports unstable auth state when the shared barrier read times out", async () => {
233346
const authDir = createTempAuthDir("openclaw-wa-auth-unstable-state");
234347
fsSync.writeFileSync(
235348
path.join(authDir, "creds.json"),
236-
JSON.stringify({ me: { id: "15551234567@s.whatsapp.net" } }),
349+
JSON.stringify({
350+
account: { details: "present" },
351+
me: { id: "15551234567@s.whatsapp.net" },
352+
platform: "chrome",
353+
}),
237354
"utf-8",
238355
);
239356
hoisted.waitForCredsSaveQueueWithTimeout

0 commit comments

Comments
 (0)