diff --git a/extensions/device-pair/index.test.ts b/extensions/device-pair/index.test.ts index 0c9bfd326d74..1db564d5b7c4 100644 --- a/extensions/device-pair/index.test.ts +++ b/extensions/device-pair/index.test.ts @@ -601,6 +601,33 @@ describe("device-pair /pair qr", () => { expect(text).not.toContain("```"); }); + it.each(["toString", "constructor", "__proto__"])( + "requires QR channel sender %s to be an own entry", + async (channel) => { + const loadAdapter = vi.fn(async () => undefined); + const command = registerPairCommand({ + runtime: { + channel: { outbound: { loadAdapter } }, + } as unknown as OpenClawPluginApi["runtime"], + }); + const result = await command.handler( + createCommandContext({ + channel, + senderId: "prototype-channel", + gatewayClientScopes: INTERNAL_SETUP_SCOPES, + }), + ); + const text = requireText(result); + + expect(pluginApiMocks.writeQrPngTempFile).not.toHaveBeenCalled(); + expect(loadAdapter).not.toHaveBeenCalled(); + expect(pluginApiMocks.revokeDeviceBootstrapToken).not.toHaveBeenCalled(); + expect(pluginApiMocks.issueDeviceBootstrapToken).toHaveBeenCalledTimes(1); + expect(text).toContain("QR image delivery is not available on this channel"); + expect(text).toContain("Setup code:"); + }, + ); + it("supports invalidating unused setup codes", async () => { const command = registerPairCommand(); const result = await command?.handler( diff --git a/extensions/device-pair/index.ts b/extensions/device-pair/index.ts index 418bd1d6d7c5..4ef907a377ed 100644 --- a/extensions/device-pair/index.ts +++ b/extensions/device-pair/index.ts @@ -590,8 +590,9 @@ function formatQrInfoMarkdown(params: { ].join("\n"); } -function canSendQrPngToChannel(channel: string): boolean { - return channel in QR_CHANNEL_SENDERS; +function resolveQrChannelSender(channel: string): QrChannelSender | undefined { + // Prototype names are not supported channel entries and must take the setup-code fallback. + return Object.hasOwn(QR_CHANNEL_SENDERS, channel) ? QR_CHANNEL_SENDERS[channel] : undefined; } function resolveQrReplyTarget(ctx: QrCommandContext): string { @@ -634,16 +635,13 @@ async function issueSetupPayload(url: string, urls?: string[]): Promise { const mediaLocalRoots = [path.dirname(params.qrFilePath)]; const accountId = normalizeOptionalString(params.ctx.accountId) || undefined; - const sender = QR_CHANNEL_SENDERS[params.ctx.channel]; - if (!sender) { - return false; - } const adapter = await params.api.runtime.channel.outbound.loadAdapter(params.ctx.channel); const send = adapter?.sendMedia; if (!send) { @@ -653,7 +651,7 @@ async function sendQrPngToSupportedChannel(params: { cfg: params.api.config, to: params.target, text: params.caption, - ...sender.createOpts({ + ...params.sender.createOpts({ ctx: params.ctx, qrFilePath: params.qrFilePath, mediaLocalRoots, @@ -783,6 +781,7 @@ export default definePluginEntry({ if (action === "qr") { const channel = ctx.channel; + const qrChannelSender = resolveQrChannelSender(channel); const target = resolveQrReplyTarget(ctx); let autoNotifyArmed = false; @@ -807,7 +806,7 @@ export default definePluginEntry({ expiresAtMs: payload.expiresAtMs, }); - if (target && canSendQrPngToChannel(channel)) { + if (target && qrChannelSender) { let qrFilePath: string | undefined; try { const { resolvePreferredOpenClawTmpDir, writeQrPngTempFile } = @@ -822,6 +821,7 @@ export default definePluginEntry({ const sent = await sendQrPngToSupportedChannel({ api, ctx, + sender: qrChannelSender, target, caption: ["Scan this QR code with the OpenClaw iOS app:", "", ...infoLines].join( "\n",