Skip to content

Commit ff700e5

Browse files
fix(media): compact whatsapp terminal qr
1 parent 44027e7 commit ff700e5

8 files changed

Lines changed: 163 additions & 8 deletions

File tree

extensions/whatsapp/src/login.coverage.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -159,8 +159,8 @@ describe("loginWeb coverage", () => {
159159
);
160160
expect(runtime.log).toHaveBeenCalledWith("terminal:initial-qr");
161161
expect(runtime.log).toHaveBeenCalledWith("terminal:restart-qr");
162-
expect(renderQrTerminalMock).toHaveBeenCalledWith("initial-qr");
163-
expect(renderQrTerminalMock).toHaveBeenCalledWith("restart-qr");
162+
expect(renderQrTerminalMock).toHaveBeenCalledWith("initial-qr", { small: true });
163+
expect(renderQrTerminalMock).toHaveBeenCalledWith("restart-qr", { small: true });
164164
});
165165

166166
it("clears creds and throws when logged out", async () => {

extensions/whatsapp/src/login.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ export async function loginWeb(
2222
const restoredFromBackup = await restoreCredsFromBackupIfNeeded(account.authDir);
2323
const onQr = (qr: string) => {
2424
runtime.log("Open the WhatsApp app, go to Linked Devices, then scan this QR:");
25-
void renderQrTerminal(qr)
25+
void renderQrTerminal(qr, { small: true })
2626
.then((output) => {
2727
runtime.log(output.endsWith("\n") ? output.slice(0, -1) : output);
2828
})

extensions/whatsapp/src/session.test.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ const useMultiFileAuthStateMock = vi.mocked(baileys.useMultiFileAuthState);
4040
let createWaSocket: typeof import("./session.js").createWaSocket;
4141
let formatError: typeof import("./session.js").formatError;
4242
let logWebSelfId: typeof import("./session.js").logWebSelfId;
43+
let renderQrTerminalMock: ReturnType<typeof vi.fn>;
4344
let waitForWaConnection: typeof import("./session.js").waitForWaConnection;
4445
let waitForCredsSaveQueue: typeof import("./session.js").waitForCredsSaveQueue;
4546
let writeCredsJsonAtomically: typeof import("./session.js").writeCredsJsonAtomically;
@@ -224,6 +225,7 @@ describe("web session", () => {
224225
waitForCredsSaveQueue,
225226
writeCredsJsonAtomically,
226227
} = await import("./session.js"));
228+
renderQrTerminalMock = vi.mocked((await import("./qr-terminal.js")).renderQrTerminal);
227229
({ DEFAULT_WHATSAPP_SOCKET_TIMING } = await import("./socket-timing.js"));
228230
});
229231

@@ -271,6 +273,27 @@ describe("web session", () => {
271273
openMock.restore();
272274
});
273275

276+
it("prints compact terminal QR output when requested", async () => {
277+
const authDir = createTempAuthDir("openclaw-wa-terminal-qr");
278+
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
279+
const stdoutSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true);
280+
281+
try {
282+
await createWaSocket(true, false, { authDir });
283+
getLastSocket().ev.emit("connection.update", { qr: "qr-data" });
284+
await flushCredsUpdate();
285+
286+
expect(logSpy).toHaveBeenCalledWith(
287+
"Open the WhatsApp app, go to Linked Devices, then scan this QR:",
288+
);
289+
expect(renderQrTerminalMock).toHaveBeenCalledWith("qr-data", { small: true });
290+
expect(stdoutSpy).toHaveBeenCalledWith("ASCII-QR\n");
291+
} finally {
292+
logSpy.mockRestore();
293+
stdoutSpy.mockRestore();
294+
}
295+
});
296+
274297
it.runIf(process.platform !== "win32")(
275298
"rejects symlinked creds before Baileys auth state reads",
276299
async () => {

extensions/whatsapp/src/session.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -124,7 +124,7 @@ async function safeSaveCreds(
124124
}
125125

126126
async function printTerminalQr(qr: string): Promise<void> {
127-
const output = await renderQrTerminal(qr);
127+
const output = await renderQrTerminal(qr, { small: true });
128128
process.stdout.write(output.endsWith("\n") ? output : `${output}\n`);
129129
}
130130

src/media/qr-terminal.render.test.ts

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,61 @@
1+
import QRCode from "qrcode";
12
import { describe, expect, it } from "vitest";
23
import { renderQrTerminal } from "./qr-terminal.ts";
34

5+
const ansiSgr = new RegExp(`${String.fromCharCode(0x1b)}\\[[0-9;]*m`, "g");
6+
const compactMarginModules = 1;
7+
8+
function visibleLines(output: string): string[] {
9+
return output
10+
.split(/\r?\n/)
11+
.map((line) => line.replace(ansiSgr, ""))
12+
.filter((line) => line.length > 0);
13+
}
14+
15+
function maxVisibleWidth(output: string): number {
16+
return Math.max(...visibleLines(output).map((line) => line.length));
17+
}
18+
19+
function decodeCompactBlock(char: string): [boolean, boolean] {
20+
if (char === "█") {
21+
return [true, true];
22+
}
23+
if (char === "▀") {
24+
return [true, false];
25+
}
26+
if (char === "▄") {
27+
return [false, true];
28+
}
29+
if (char === " ") {
30+
return [false, false];
31+
}
32+
throw new Error(`Unexpected compact QR character: ${char}`);
33+
}
34+
35+
function decodeCompactQr(output: string, size: number): boolean[] {
36+
const decoded = Array.from({ length: size * size }, () => false);
37+
visibleLines(output).forEach((line, lineIndex) => {
38+
Array.from(line).forEach((char, columnIndex) => {
39+
const x = columnIndex - compactMarginModules;
40+
const topY = lineIndex * 2 - compactMarginModules;
41+
const [top, bottom] = decodeCompactBlock(char);
42+
for (const [y, value] of [
43+
[topY, top],
44+
[topY + 1, bottom],
45+
] as const) {
46+
if (x >= 0 && x < size && y >= 0 && y < size) {
47+
decoded[y * size + x] = value;
48+
}
49+
}
50+
});
51+
});
52+
return decoded;
53+
}
54+
455
describe("renderQrTerminal (real qrcode runtime)", () => {
556
it("keeps per-row ANSI sequence counts in line with typical rows", async () => {
657
const sample = "https://wa.me/login/2@SAMPLE-TOKEN-1234567890ABCDEF";
758
const rendered = await renderQrTerminal(sample);
8-
const ansiSgr = new RegExp(`${String.fromCharCode(0x1b)}\\[[0-9;]*m`, "g");
959
const escCounts = rendered
1060
.split(/\r?\n/)
1161
.map((line) => (line.match(ansiSgr) ?? []).length)
@@ -17,4 +67,14 @@ describe("renderQrTerminal (real qrcode runtime)", () => {
1767
expect(median).toBeGreaterThan(0);
1868
expect(max).toBeLessThanOrEqual(median * 6);
1969
});
70+
71+
it("renders compact output from the same QR matrix", async () => {
72+
const sample = "https://wa.me/login/2@SAMPLE-TOKEN-1234567890ABCDEF";
73+
const qr = QRCode.create(sample);
74+
const full = await renderQrTerminal(sample);
75+
const compact = await renderQrTerminal(sample, { small: true });
76+
77+
expect(maxVisibleWidth(compact)).toBeLessThan(maxVisibleWidth(full));
78+
expect(decodeCompactQr(compact, qr.modules.size)).toEqual(Array.from(qr.modules.data, Boolean));
79+
});
2080
});

src/media/qr-terminal.test.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,18 @@
11
import { beforeEach, describe, expect, it, vi } from "vitest";
22

3-
const { toString } = vi.hoisted(() => ({
3+
const { create, toString } = vi.hoisted(() => ({
4+
create: vi.fn(() => ({
5+
modules: {
6+
data: [1, 0, 0, 1],
7+
size: 2,
8+
},
9+
})),
410
toString: vi.fn(async () => "ASCII-QR"),
511
}));
612

713
vi.mock("qrcode", () => ({
814
default: {
15+
create,
916
toString,
1017
},
1118
}));
@@ -14,6 +21,7 @@ import { renderQrTerminal } from "./qr-terminal.ts";
1421

1522
describe("renderQrTerminal", () => {
1623
beforeEach(() => {
24+
create.mockClear();
1725
toString.mockClear();
1826
});
1927

@@ -25,6 +33,13 @@ describe("renderQrTerminal", () => {
2533
});
2634
});
2735

36+
it("renders compact QR output without qrcode terminal small mode", async () => {
37+
const rendered = await renderQrTerminal("openclaw", { small: true });
38+
expect(rendered).toContain("▄");
39+
expect(create).toHaveBeenCalledWith("openclaw");
40+
expect(toString).not.toHaveBeenCalled();
41+
});
42+
2843
it("rejects empty QR text", async () => {
2944
await expect(renderQrTerminal("")).rejects.toThrow("QR text must not be empty.");
3045
expect(toString).not.toHaveBeenCalled();

src/media/qr-terminal.ts

Lines changed: 50 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,60 @@
11
import { loadQrCodeRuntime, normalizeQrText } from "./qr-runtime.ts";
22

3+
type QrTerminalModules = {
4+
data: ArrayLike<boolean | number>;
5+
size: number;
6+
};
7+
8+
const COMPACT_MARGIN_MODULES = 1;
9+
const TERMINAL_BLACK_ON_WHITE = "\x1b[47m\x1b[30m";
10+
const TERMINAL_RESET = "\x1b[0m";
11+
const FULL_BLOCK = "█";
12+
const UPPER_HALF_BLOCK = "▀";
13+
const LOWER_HALF_BLOCK = "▄";
14+
15+
function readModule(modules: QrTerminalModules, x: number, y: number): boolean {
16+
if (x < 0 || y < 0 || x >= modules.size || y >= modules.size) {
17+
return false;
18+
}
19+
return Boolean(modules.data[y * modules.size + x]);
20+
}
21+
22+
function compactBlock(top: boolean, bottom: boolean): string {
23+
if (top && bottom) {
24+
return FULL_BLOCK;
25+
}
26+
if (top) {
27+
return UPPER_HALF_BLOCK;
28+
}
29+
if (bottom) {
30+
return LOWER_HALF_BLOCK;
31+
}
32+
return " ";
33+
}
34+
35+
function renderCompactTerminalQr(modules: QrTerminalModules): string {
36+
const lines: string[] = [];
37+
for (let y = -COMPACT_MARGIN_MODULES; y < modules.size + COMPACT_MARGIN_MODULES; y += 2) {
38+
let line = TERMINAL_BLACK_ON_WHITE;
39+
for (let x = -COMPACT_MARGIN_MODULES; x < modules.size + COMPACT_MARGIN_MODULES; x += 1) {
40+
line += compactBlock(readModule(modules, x, y), readModule(modules, x, y + 1));
41+
}
42+
lines.push(`${line}${TERMINAL_RESET}`);
43+
}
44+
return lines.join("\n");
45+
}
46+
347
export async function renderQrTerminal(
448
input: string,
549
opts: { small?: boolean } = {},
650
): Promise<string> {
51+
const text = normalizeQrText(input);
752
const qrCode = await loadQrCodeRuntime();
8-
return await qrCode.toString(normalizeQrText(input), {
9-
small: opts.small ?? false,
53+
if (opts.small === true) {
54+
return renderCompactTerminalQr(qrCode.create(text).modules);
55+
}
56+
return await qrCode.toString(text, {
57+
small: false,
1058
type: "terminal",
1159
});
1260
}

src/types/qrcode.d.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,14 @@ declare module "qrcode" {
2424
width?: number;
2525
};
2626

27+
export type QrCodeSymbol = {
28+
modules: {
29+
data: ArrayLike<boolean | number>;
30+
size: number;
31+
};
32+
};
33+
34+
export function create(text: string, options?: QrCodeRenderOptions): QrCodeSymbol;
2735
export function toString(text: string, options?: QrCodeRenderOptions): Promise<string>;
2836
export function toDataURL(text: string, options?: QrCodeRenderOptions): Promise<string>;
2937
export function toFile(
@@ -33,6 +41,7 @@ declare module "qrcode" {
3341
): Promise<void>;
3442

3543
const qrcode: {
44+
create: typeof create;
3645
toString: typeof toString;
3746
toDataURL: typeof toDataURL;
3847
toFile: typeof toFile;

0 commit comments

Comments
 (0)