From 64950f492f658cb7ee4ca2681260eb465f06017e Mon Sep 17 00:00:00 2001 From: ImLukeF <92253590+ImLukeF@users.noreply.github.com> Date: Sat, 21 Mar 2026 12:18:06 +1100 Subject: [PATCH] Device pair: fix QR review follow-ups --- extensions/device-pair/api.ts | 2 +- extensions/device-pair/index.test.ts | 25 +++++++++++++ extensions/device-pair/index.ts | 43 ++++++++++++++-------- extensions/device-pair/qr-image.ts | 54 ++++++++++++++++++++++++++++ 4 files changed, 109 insertions(+), 15 deletions(-) create mode 100644 extensions/device-pair/qr-image.ts diff --git a/extensions/device-pair/api.ts b/extensions/device-pair/api.ts index 4c747939171..e528b6a3a42 100644 --- a/extensions/device-pair/api.ts +++ b/extensions/device-pair/api.ts @@ -11,4 +11,4 @@ export { resolvePreferredOpenClawTmpDir, runPluginCommandWithTimeout, } from "openclaw/plugin-sdk/sandbox"; -export { renderQrPngBase64 } from "../whatsapp/src/qr-image.js"; +export { renderQrPngBase64 } from "./qr-image.js"; diff --git a/extensions/device-pair/index.test.ts b/extensions/device-pair/index.test.ts index cfd2f389b8b..5172bd7ad4b 100644 --- a/extensions/device-pair/index.test.ts +++ b/extensions/device-pair/index.test.ts @@ -146,6 +146,31 @@ describe("device-pair /pair qr", () => { expect(result?.text).not.toContain("```"); }); + it("reissues the bootstrap token if webchat QR rendering fails before falling back", async () => { + pluginApiMocks.issueDeviceBootstrapToken + .mockResolvedValueOnce({ + token: "first-token", + expiresAtMs: Date.now() + 10 * 60_000, + }) + .mockResolvedValueOnce({ + token: "second-token", + expiresAtMs: Date.now() + 10 * 60_000, + }); + pluginApiMocks.renderQrPngBase64.mockRejectedValueOnce(new Error("render failed")); + + const command = registerPairCommand(); + const result = await command?.handler(createCommandContext({ channel: "webchat" })); + + expect(pluginApiMocks.revokeDeviceBootstrapToken).toHaveBeenCalledWith({ + token: "first-token", + }); + expect(pluginApiMocks.issueDeviceBootstrapToken).toHaveBeenCalledTimes(2); + expect(result?.text).toContain( + "QR image delivery is not available on this channel right now, so I generated a pasteable setup code instead.", + ); + expect(result?.text).toContain("Pairing setup code generated."); + }); + it.each([ { label: "Telegram", diff --git a/extensions/device-pair/index.ts b/extensions/device-pair/index.ts index 86b4dbcc16f..7a416b2b56e 100644 --- a/extensions/device-pair/index.ts +++ b/extensions/device-pair/index.ts @@ -75,7 +75,7 @@ type QrCommandContext = { }; type QrChannelSender = { - resolveSend: (api: OpenClawPluginApi) => ((...args: unknown[]) => Promise) | undefined; + resolveSend: (api: OpenClawPluginApi) => QrSendFn | undefined; createOpts: (params: { ctx: QrCommandContext; qrFilePath: string; @@ -492,19 +492,18 @@ function formatQrInfoMarkdown(params: { autoNotifyArmed: boolean; expiresAtMs: number; }): string { - const [gatewayLine, authLine, ...rest] = buildQrInfoLines(params); return [ - `- ${gatewayLine}`, - `- ${authLine}`, - ...rest.map((line) => - line.startsWith("Security:") || line.startsWith("Expires:") - ? `- ${line}` - : line === "IMPORTANT: After pairing finishes, run /pair cleanup." - ? "**Important:** Run `/pair cleanup` after pairing finishes." - : line === "If this QR code leaks, run /pair cleanup immediately." - ? "If this QR code leaks, run `/pair cleanup` immediately." - : line, - ), + `- Gateway: ${params.payload.url}`, + `- Auth: ${params.authLabel}`, + ...buildSecurityNoticeLines({ + kind: "QR code", + expiresAtMs: params.expiresAtMs, + markdown: true, + }), + "", + ...buildQrFollowUpLines(params.autoNotifyArmed), + "", + "If your camera still won’t lock on, run `/pair` for a pasteable setup code.", ].join("\n"); } @@ -726,7 +725,23 @@ export default definePluginEntry({ api.logger.info?.(`device-pair: QR fallback channel=${channel} target=${target}`); if (channel === "webchat") { - const qrDataUrl = await renderQrDataUrl(setupCode); + let qrDataUrl: string; + try { + qrDataUrl = await renderQrDataUrl(setupCode); + } catch (err) { + api.logger.warn?.( + `device-pair: webchat QR render failed, falling back (${String( + (err as Error)?.message ?? err, + )})`, + ); + await revokeDeviceBootstrapToken({ token: payload.bootstrapToken }).catch(() => {}); + payload = await issueSetupPayload(urlResult.url); + return { + text: + "QR image delivery is not available on this channel right now, so I generated a pasteable setup code instead.\n\n" + + formatSetupReply(payload, authLabel), + }; + } return { text: [ "Scan this QR code with the OpenClaw iOS app:", diff --git a/extensions/device-pair/qr-image.ts b/extensions/device-pair/qr-image.ts new file mode 100644 index 00000000000..be6b10f5b0e --- /dev/null +++ b/extensions/device-pair/qr-image.ts @@ -0,0 +1,54 @@ +import { encodePngRgba, fillPixel } from "openclaw/plugin-sdk/media-runtime"; +import QRCodeModule from "qrcode-terminal/vendor/QRCode/index.js"; +import QRErrorCorrectLevelModule from "qrcode-terminal/vendor/QRCode/QRErrorCorrectLevel.js"; + +type QRCodeConstructor = new ( + typeNumber: number, + errorCorrectLevel: unknown, +) => { + addData: (data: string) => void; + make: () => void; + getModuleCount: () => number; + isDark: (row: number, col: number) => boolean; +}; + +const QRCode = QRCodeModule as QRCodeConstructor; +const QRErrorCorrectLevel = QRErrorCorrectLevelModule; + +function createQrMatrix(input: string) { + const qr = new QRCode(-1, QRErrorCorrectLevel.L); + qr.addData(input); + qr.make(); + return qr; +} + +export async function renderQrPngBase64( + input: string, + opts: { scale?: number; marginModules?: number } = {}, +): Promise { + const { scale = 6, marginModules = 4 } = opts; + const qr = createQrMatrix(input); + const modules = qr.getModuleCount(); + const size = (modules + marginModules * 2) * scale; + + const buf = Buffer.alloc(size * size * 4, 255); + for (let row = 0; row < modules; row += 1) { + for (let col = 0; col < modules; col += 1) { + if (!qr.isDark(row, col)) { + continue; + } + const startX = (col + marginModules) * scale; + const startY = (row + marginModules) * scale; + for (let y = 0; y < scale; y += 1) { + const pixelY = startY + y; + for (let x = 0; x < scale; x += 1) { + const pixelX = startX + x; + fillPixel(buf, pixelX, pixelY, size, 0, 0, 0, 255); + } + } + } + } + + const png = encodePngRgba(buf, size, size); + return png.toString("base64"); +}