Device pair: fix QR review follow-ups
This commit is contained in:
parent
a0c340292c
commit
64950f492f
@ -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";
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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 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:",
|
||||
|
||||
54
extensions/device-pair/qr-image.ts
Normal file
54
extensions/device-pair/qr-image.ts
Normal 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");
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user