Device pair: fix QR review follow-ups

This commit is contained in:
ImLukeF 2026-03-21 12:18:06 +11:00
parent a0c340292c
commit 64950f492f
No known key found for this signature in database
4 changed files with 109 additions and 15 deletions

View File

@ -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";

View File

@ -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",

View File

@ -75,7 +75,7 @@ type QrCommandContext = {
};
type QrChannelSender = {
resolveSend: (api: OpenClawPluginApi) => ((...args: unknown[]) => Promise<unknown>) | 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 wont 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:",

View File

@ -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<string> {
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");
}