From a0c340292c3897c70c05ae139bea48ec054375b2 Mon Sep 17 00:00:00 2001 From: ImLukeF <92253590+ImLukeF@users.noreply.github.com> Date: Sat, 21 Mar 2026 11:57:03 +1100 Subject: [PATCH] Device pair: dedupe QR pairing flow --- .../OpenClawChatUI/ChatViewModel.swift | 31 +- .../OpenClawKitTests/ChatViewModelTests.swift | 70 ++--- extensions/device-pair/index.test.ts | 129 ++++---- extensions/device-pair/index.ts | 294 ++++++++++-------- src/infra/device-bootstrap.test.ts | 164 +++------- 5 files changed, 308 insertions(+), 380 deletions(-) diff --git a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel.swift index 6e7e9891e87..983f140fe88 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel.swift @@ -289,6 +289,17 @@ public final class OpenClawChatViewModel { stopReason: message.stopReason) } + private static func messageContentFingerprint(for message: OpenClawChatMessage) -> String { + message.content.map { item in + let type = (item.type ?? "text").trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + let text = (item.text ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + let id = (item.id ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + let name = (item.name ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + let fileName = (item.fileName ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + return [type, text, id, name, fileName].joined(separator: "\\u{001F}") + }.joined(separator: "\\u{001E}") + } + private static func messageIdentityKey(for message: OpenClawChatMessage) -> String? { let role = message.role.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() guard !role.isEmpty else { return nil } @@ -298,15 +309,7 @@ public final class OpenClawChatViewModel { return String(format: "%.3f", value) }() - let contentFingerprint = message.content.map { item in - let type = (item.type ?? "text").trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - let text = (item.text ?? "").trimmingCharacters(in: .whitespacesAndNewlines) - let id = (item.id ?? "").trimmingCharacters(in: .whitespacesAndNewlines) - let name = (item.name ?? "").trimmingCharacters(in: .whitespacesAndNewlines) - let fileName = (item.fileName ?? "").trimmingCharacters(in: .whitespacesAndNewlines) - return [type, text, id, name, fileName].joined(separator: "\\u{001F}") - }.joined(separator: "\\u{001E}") - + let contentFingerprint = Self.messageContentFingerprint(for: message) let toolCallId = (message.toolCallId ?? "").trimmingCharacters(in: .whitespacesAndNewlines) let toolName = (message.toolName ?? "").trimmingCharacters(in: .whitespacesAndNewlines) if timestamp.isEmpty, contentFingerprint.isEmpty, toolCallId.isEmpty, toolName.isEmpty { @@ -319,15 +322,7 @@ public final class OpenClawChatViewModel { let role = message.role.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() guard role == "user" else { return nil } - let contentFingerprint = message.content.map { item in - let type = (item.type ?? "text").trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - let text = (item.text ?? "").trimmingCharacters(in: .whitespacesAndNewlines) - let id = (item.id ?? "").trimmingCharacters(in: .whitespacesAndNewlines) - let name = (item.name ?? "").trimmingCharacters(in: .whitespacesAndNewlines) - let fileName = (item.fileName ?? "").trimmingCharacters(in: .whitespacesAndNewlines) - return [type, text, id, name, fileName].joined(separator: "\\u{001F}") - }.joined(separator: "\\u{001E}") - + let contentFingerprint = Self.messageContentFingerprint(for: message) let toolCallId = (message.toolCallId ?? "").trimmingCharacters(in: .whitespacesAndNewlines) let toolName = (message.toolName ?? "").trimmingCharacters(in: .whitespacesAndNewlines) if contentFingerprint.isEmpty, toolCallId.isEmpty, toolName.isEmpty { diff --git a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatViewModelTests.swift b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatViewModelTests.swift index 3dfad726ec4..43ad630cf34 100644 --- a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatViewModelTests.swift +++ b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatViewModelTests.swift @@ -126,6 +126,28 @@ private func sendUserMessage(_ vm: OpenClawChatViewModel, text: String = "hi") a } } +@discardableResult +private func sendMessageAndEmitFinal( + transport: TestChatTransport, + vm: OpenClawChatViewModel, + text: String, + sessionKey: String = "main") async throws -> String +{ + await sendUserMessage(vm, text: text) + try await waitUntil("pending run starts") { await MainActor.run { vm.pendingRunCount == 1 } } + + let runId = try #require(await transport.lastSentRunId()) + transport.emit( + .chat( + OpenClawChatEventPayload( + runId: runId, + sessionKey: sessionKey, + state: "final", + message: nil, + errorMessage: nil))) + return runId +} + private func emitAssistantText( transport: TestChatTransport, runId: String, @@ -454,18 +476,10 @@ extension TestChatTransportState { let (transport, vm) = await makeViewModel(historyResponses: [history1, history2]) try await loadAndWaitBootstrap(vm: vm, sessionId: sessionId) - await sendUserMessage(vm, text: "hello from mac webchat") - try await waitUntil("pending run starts") { await MainActor.run { vm.pendingRunCount == 1 } } - - let runId = try #require(await transport.lastSentRunId()) - transport.emit( - .chat( - OpenClawChatEventPayload( - runId: runId, - sessionKey: "main", - state: "final", - message: nil, - errorMessage: nil))) + try await sendMessageAndEmitFinal( + transport: transport, + vm: vm, + text: "hello from mac webchat") try await waitUntil("assistant history refreshes without dropping user message") { await MainActor.run { @@ -485,18 +499,10 @@ extension TestChatTransportState { let (transport, vm) = await makeViewModel(historyResponses: [history1, history2]) try await loadAndWaitBootstrap(vm: vm, sessionId: sessionId) - await sendUserMessage(vm, text: "hello from mac webchat") - try await waitUntil("pending run starts") { await MainActor.run { vm.pendingRunCount == 1 } } - - let runId = try #require(await transport.lastSentRunId()) - transport.emit( - .chat( - OpenClawChatEventPayload( - runId: runId, - sessionKey: "main", - state: "final", - message: nil, - errorMessage: nil))) + try await sendMessageAndEmitFinal( + transport: transport, + vm: vm, + text: "hello from mac webchat") try await waitUntil("empty refresh does not clear optimistic user message") { await MainActor.run { @@ -527,18 +533,10 @@ extension TestChatTransportState { let (transport, vm) = await makeViewModel(historyResponses: [history1, history2]) try await loadAndWaitBootstrap(vm: vm, sessionId: sessionId) - await sendUserMessage(vm, text: "hello from mac webchat") - try await waitUntil("pending run starts") { await MainActor.run { vm.pendingRunCount == 1 } } - - let runId = try #require(await transport.lastSentRunId()) - transport.emit( - .chat( - OpenClawChatEventPayload( - runId: runId, - sessionKey: "main", - state: "final", - message: nil, - errorMessage: nil))) + try await sendMessageAndEmitFinal( + transport: transport, + vm: vm, + text: "hello from mac webchat") try await waitUntil("canonical refresh keeps one user message") { await MainActor.run { diff --git a/extensions/device-pair/index.test.ts b/extensions/device-pair/index.test.ts index fc579d2fc6b..cfd2f389b8b 100644 --- a/extensions/device-pair/index.test.ts +++ b/extensions/device-pair/index.test.ts @@ -71,6 +71,37 @@ function createApi(params?: { }) as OpenClawPluginApi; } +function registerPairCommand(params?: { + runtime?: OpenClawPluginApi["runtime"]; + pluginConfig?: Record; +}): OpenClawPluginCommandDefinition { + let command: OpenClawPluginCommandDefinition | undefined; + registerDevicePair.register( + createApi({ + ...params, + registerCommand: (nextCommand) => { + command = nextCommand; + }, + }), + ); + expect(command).toBeTruthy(); + return command!; +} + +function createChannelRuntime( + runtimeKey: string, + sendKey: string, + sendMessage: (...args: unknown[]) => Promise, +): OpenClawPluginApi["runtime"] { + return { + channel: { + [runtimeKey]: { + [sendKey]: sendMessage, + }, + }, + } as unknown as OpenClawPluginApi["runtime"]; +} + function createCommandContext(params?: Partial): PluginCommandContext { return { channel: "webchat", @@ -103,15 +134,7 @@ describe("device-pair /pair qr", () => { }); it("returns an inline QR image for webchat surfaces", async () => { - let command: OpenClawPluginCommandDefinition | undefined; - registerDevicePair.register( - createApi({ - registerCommand: (nextCommand) => { - command = nextCommand; - }, - }), - ); - + const command = registerPairCommand(); const result = await command?.handler(createCommandContext({ channel: "webchat" })); expect(pluginApiMocks.renderQrPngBase64).toHaveBeenCalledTimes(1); @@ -135,9 +158,9 @@ describe("device-pair /pair qr", () => { messageThreadId: 271, }, expectedTarget: "123", - assertOpts: (opts: Record) => { - expect(opts.accountId).toBe("default"); - expect(opts.messageThreadId).toBe(271); + expectedOpts: { + accountId: "default", + messageThreadId: 271, }, }, { @@ -150,8 +173,8 @@ describe("device-pair /pair qr", () => { accountId: "default", }, expectedTarget: "user:123", - assertOpts: (opts: Record) => { - expect(opts.accountId).toBe("default"); + expectedOpts: { + accountId: "default", }, }, { @@ -165,9 +188,9 @@ describe("device-pair /pair qr", () => { messageThreadId: "1234567890.000001", }, expectedTarget: "user:U123", - assertOpts: (opts: Record) => { - expect(opts.accountId).toBe("default"); - expect(opts.threadTs).toBe("1234567890.000001"); + expectedOpts: { + accountId: "default", + threadTs: "1234567890.000001", }, }, { @@ -180,8 +203,8 @@ describe("device-pair /pair qr", () => { accountId: "default", }, expectedTarget: "signal:+15551234567", - assertOpts: (opts: Record) => { - expect(opts.accountId).toBe("default"); + expectedOpts: { + accountId: "default", }, }, { @@ -194,8 +217,8 @@ describe("device-pair /pair qr", () => { accountId: "default", }, expectedTarget: "+15551234567", - assertOpts: (opts: Record) => { - expect(opts.accountId).toBe("default"); + expectedOpts: { + accountId: "default", }, }, { @@ -208,9 +231,9 @@ describe("device-pair /pair qr", () => { accountId: "default", }, expectedTarget: "+15551234567", - assertOpts: (opts: Record) => { - expect(opts.accountId).toBe("default"); - expect(opts.verbose).toBe(false); + expectedOpts: { + accountId: "default", + verbose: false, }, }, ])("sends $label a real QR image attachment", async (testCase) => { @@ -221,21 +244,9 @@ describe("device-pair /pair qr", () => { } return { messageId: "1" }; }); - let command: OpenClawPluginCommandDefinition | undefined; - registerDevicePair.register( - createApi({ - runtime: { - channel: { - [testCase.runtimeKey]: { - [testCase.sendKey]: sendMessage, - }, - }, - } as unknown as OpenClawPluginApi["runtime"], - registerCommand: (nextCommand) => { - command = nextCommand; - }, - }), - ); + const command = registerPairCommand({ + runtime: createChannelRuntime(testCase.runtimeKey, testCase.sendKey, sendMessage), + }); const result = await command?.handler(createCommandContext(testCase.ctx)); @@ -255,7 +266,7 @@ describe("device-pair /pair qr", () => { expect(caption).toContain("If this QR code leaks, run /pair cleanup immediately."); expect(opts.mediaUrl).toMatch(/pair-qr\.png$/); expect(opts.mediaLocalRoots).toEqual([path.dirname(opts.mediaUrl!)]); - testCase.assertOpts(opts); + expect(opts).toMatchObject(testCase.expectedOpts); expect(sentPng).toBe("fakepng"); await expect(fs.access(opts.mediaUrl!)).rejects.toBeTruthy(); expect(result?.text).toContain("QR code sent above."); @@ -274,21 +285,9 @@ describe("device-pair /pair qr", () => { }); const sendMessage = vi.fn().mockRejectedValue(new Error("upload failed")); - let command: OpenClawPluginCommandDefinition | undefined; - registerDevicePair.register( - createApi({ - runtime: { - channel: { - discord: { - sendMessageDiscord: sendMessage, - }, - }, - } as unknown as OpenClawPluginApi["runtime"], - registerCommand: (nextCommand) => { - command = nextCommand; - }, - }), - ); + const command = registerPairCommand({ + runtime: createChannelRuntime("discord", "sendMessageDiscord", sendMessage), + }); const result = await command?.handler( createCommandContext({ @@ -306,15 +305,7 @@ describe("device-pair /pair qr", () => { }); it("falls back to the setup code instead of ASCII when the channel cannot send media", async () => { - let command: OpenClawPluginCommandDefinition | undefined; - registerDevicePair.register( - createApi({ - registerCommand: (nextCommand) => { - command = nextCommand; - }, - }), - ); - + const command = registerPairCommand(); const result = await command?.handler( createCommandContext({ channel: "msteams", @@ -329,15 +320,7 @@ describe("device-pair /pair qr", () => { }); it("supports invalidating unused setup codes", async () => { - let command: OpenClawPluginCommandDefinition | undefined; - registerDevicePair.register( - createApi({ - registerCommand: (nextCommand) => { - command = nextCommand; - }, - }), - ); - + const command = registerPairCommand(); const result = await command?.handler( createCommandContext({ args: "cleanup", diff --git a/extensions/device-pair/index.ts b/extensions/device-pair/index.ts index 06b3df1fa6c..86b4dbcc16f 100644 --- a/extensions/device-pair/index.ts +++ b/extensions/device-pair/index.ts @@ -74,6 +74,76 @@ type QrCommandContext = { messageThreadId?: string | number; }; +type QrChannelSender = { + resolveSend: (api: OpenClawPluginApi) => ((...args: unknown[]) => Promise) | undefined; + createOpts: (params: { + ctx: QrCommandContext; + qrFilePath: string; + mediaLocalRoots: string[]; + accountId?: string; + }) => Record; +}; + +type QrSendFn = (to: string, text: string, opts: Record) => Promise; + +function coerceQrSend(send: unknown): QrSendFn | undefined { + return typeof send === "function" ? (send as QrSendFn) : undefined; +} + +const QR_CHANNEL_SENDERS: Record = { + telegram: { + resolveSend: (api) => coerceQrSend(api.runtime?.channel?.telegram?.sendMessageTelegram), + createOpts: ({ ctx, qrFilePath, mediaLocalRoots, accountId }) => ({ + mediaUrl: qrFilePath, + mediaLocalRoots, + ...(typeof ctx.messageThreadId === "number" ? { messageThreadId: ctx.messageThreadId } : {}), + ...(accountId ? { accountId } : {}), + }), + }, + discord: { + resolveSend: (api) => coerceQrSend(api.runtime?.channel?.discord?.sendMessageDiscord), + createOpts: ({ qrFilePath, mediaLocalRoots, accountId }) => ({ + mediaUrl: qrFilePath, + mediaLocalRoots, + ...(accountId ? { accountId } : {}), + }), + }, + slack: { + resolveSend: (api) => coerceQrSend(api.runtime?.channel?.slack?.sendMessageSlack), + createOpts: ({ ctx, qrFilePath, mediaLocalRoots, accountId }) => ({ + mediaUrl: qrFilePath, + mediaLocalRoots, + ...(ctx.messageThreadId != null ? { threadTs: String(ctx.messageThreadId) } : {}), + ...(accountId ? { accountId } : {}), + }), + }, + signal: { + resolveSend: (api) => coerceQrSend(api.runtime?.channel?.signal?.sendMessageSignal), + createOpts: ({ qrFilePath, mediaLocalRoots, accountId }) => ({ + mediaUrl: qrFilePath, + mediaLocalRoots, + ...(accountId ? { accountId } : {}), + }), + }, + imessage: { + resolveSend: (api) => coerceQrSend(api.runtime?.channel?.imessage?.sendMessageIMessage), + createOpts: ({ qrFilePath, mediaLocalRoots, accountId }) => ({ + mediaUrl: qrFilePath, + mediaLocalRoots, + ...(accountId ? { accountId } : {}), + }), + }, + whatsapp: { + resolveSend: (api) => coerceQrSend(api.runtime?.channel?.whatsapp?.sendMessageWhatsApp), + createOpts: ({ qrFilePath, mediaLocalRoots, accountId }) => ({ + verbose: false, + mediaUrl: qrFilePath, + mediaLocalRoots, + ...(accountId ? { accountId } : {}), + }), + }, +}; + function normalizeUrl(raw: string, schemeFallback: "ws" | "wss"): string | null { const candidate = raw.trim(); if (!candidate) { @@ -326,26 +396,60 @@ function encodeSetupCode(payload: SetupPayload): string { return base64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, ""); } +function buildPairingFlowLines(stepTwo: string): string[] { + return [ + "1) Open the iOS app → Settings → Gateway", + `2) ${stepTwo}`, + "3) Back here, run /pair approve", + "4) If this code leaks or you are done, run /pair cleanup", + ]; +} + +function buildSecurityNoticeLines(params: { + kind: "setup code" | "QR code"; + expiresAtMs: number; + markdown?: boolean; +}): string[] { + const cleanupCommand = params.markdown ? "`/pair cleanup`" : "/pair cleanup"; + const securityPrefix = params.markdown ? "- " : ""; + const importantLine = params.markdown + ? `**Important:** Run ${cleanupCommand} after pairing finishes.` + : `IMPORTANT: After pairing finishes, run ${cleanupCommand}.`; + return [ + `${securityPrefix}Security: single-use bootstrap token`, + `${securityPrefix}Expires: ${formatDurationMinutes(params.expiresAtMs)}`, + "", + importantLine, + `If this ${params.kind} leaks, run ${cleanupCommand} immediately.`, + ]; +} + +function buildQrFollowUpLines(autoNotifyArmed: boolean): string[] { + return autoNotifyArmed + ? [ + "After scanning, wait here for the pairing request ping.", + "I’ll auto-ping here when the pairing request arrives, then auto-disable.", + "If the ping does not arrive, run `/pair approve latest` manually.", + ] + : ["After scanning, run `/pair approve` to complete pairing."]; +} + function formatSetupReply(payload: SetupPayload, authLabel: string): string { const setupCode = encodeSetupCode(payload); return [ "Pairing setup code generated.", "", - "1) Open the iOS app → Settings → Gateway", - "2) Paste the setup code below and tap Connect", - "3) Back here, run /pair approve", - "4) If this code leaks or you are done, run /pair cleanup", + ...buildPairingFlowLines("Paste the setup code below and tap Connect"), "", "Setup code:", setupCode, "", `Gateway: ${payload.url}`, `Auth: ${authLabel}`, - "Security: single-use bootstrap token", - `Expires: ${formatDurationMinutes(payload.expiresAtMs)}`, - "", - "IMPORTANT: After pairing finishes, run /pair cleanup.", - "If this setup code leaks, run /pair cleanup immediately.", + ...buildSecurityNoticeLines({ + kind: "setup code", + expiresAtMs: payload.expiresAtMs, + }), ].join("\n"); } @@ -353,43 +457,30 @@ function formatSetupInstructions(expiresAtMs: number): string { return [ "Pairing setup code generated.", "", - "1) Open the iOS app → Settings → Gateway", - "2) Paste the setup code from my next message and tap Connect", - "3) Back here, run /pair approve", - "4) If this code leaks or you are done, run /pair cleanup", + ...buildPairingFlowLines("Paste the setup code from my next message and tap Connect"), "", - "Security: single-use bootstrap token", - `Expires: ${formatDurationMinutes(expiresAtMs)}`, - "", - "IMPORTANT: After pairing finishes, run /pair cleanup.", - "If this setup code leaks, run /pair cleanup immediately.", + ...buildSecurityNoticeLines({ + kind: "setup code", + expiresAtMs, + }), ].join("\n"); } -function formatQrInfoLines(params: { +function buildQrInfoLines(params: { payload: SetupPayload; authLabel: string; autoNotifyArmed: boolean; expiresAtMs: number; -}) { +}): string[] { return [ `Gateway: ${params.payload.url}`, `Auth: ${params.authLabel}`, - "Security: single-use bootstrap token", - `Expires: ${formatDurationMinutes(params.expiresAtMs)}`, + ...buildSecurityNoticeLines({ + kind: "QR code", + expiresAtMs: params.expiresAtMs, + }), "", - "IMPORTANT: After pairing finishes, run /pair cleanup.", - "If this QR code leaks, run /pair cleanup immediately.", - "", - params.autoNotifyArmed - ? "After scanning, wait here for the pairing request ping." - : "After scanning, run `/pair approve` to complete pairing.", - ...(params.autoNotifyArmed - ? [ - "I’ll auto-ping here when the pairing request arrives, then auto-disable.", - "If the ping does not arrive, run `/pair approve latest` manually.", - ] - : []), + ...buildQrFollowUpLines(params.autoNotifyArmed), "", "If your camera still won’t lock on, run `/pair` for a pasteable setup code.", ]; @@ -401,30 +492,24 @@ function formatQrInfoMarkdown(params: { autoNotifyArmed: boolean; expiresAtMs: number; }): string { - const guidance = params.autoNotifyArmed - ? [ - "After scanning, wait here for the pairing request ping.", - "I’ll auto-ping here when the pairing request arrives, then auto-disable.", - "If the ping does not arrive, run `/pair approve latest` manually.", - ] - : ["After scanning, run `/pair approve` to complete pairing."]; + const [gatewayLine, authLine, ...rest] = buildQrInfoLines(params); return [ - `- Gateway: ${params.payload.url}`, - `- Auth: ${params.authLabel}`, - "- Security: single-use bootstrap token", - `- Expires: ${formatDurationMinutes(params.expiresAtMs)}`, - "", - "**Important:** Run `/pair cleanup` after pairing finishes.", - "If this QR code leaks, run `/pair cleanup` immediately.", - "", - ...guidance, - "", - "If your camera still won’t lock on, run `/pair` for a pasteable setup code.", + `- ${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, + ), ].join("\n"); } function canSendQrPngToChannel(channel: string): boolean { - return ["telegram", "discord", "slack", "signal", "imessage", "whatsapp"].includes(channel); + return channel in QR_CHANNEL_SENDERS; } function resolveQrReplyTarget(ctx: QrCommandContext): string { @@ -457,90 +542,25 @@ async function sendQrPngToSupportedChannel(params: { }): Promise { const mediaLocalRoots = [path.dirname(params.qrFilePath)]; const accountId = params.ctx.accountId?.trim() || undefined; - - switch (params.ctx.channel) { - case "telegram": { - const send = params.api.runtime?.channel?.telegram?.sendMessageTelegram; - if (!send) { - return false; - } - await send(params.target, params.caption, { - mediaUrl: params.qrFilePath, - mediaLocalRoots, - ...(typeof params.ctx.messageThreadId === "number" - ? { messageThreadId: params.ctx.messageThreadId } - : {}), - ...(accountId ? { accountId } : {}), - }); - return true; - } - case "discord": { - const send = params.api.runtime?.channel?.discord?.sendMessageDiscord; - if (!send) { - return false; - } - await send(params.target, params.caption, { - mediaUrl: params.qrFilePath, - mediaLocalRoots, - ...(accountId ? { accountId } : {}), - }); - return true; - } - case "slack": { - const send = params.api.runtime?.channel?.slack?.sendMessageSlack; - if (!send) { - return false; - } - await send(params.target, params.caption, { - mediaUrl: params.qrFilePath, - mediaLocalRoots, - ...(params.ctx.messageThreadId != null - ? { threadTs: String(params.ctx.messageThreadId) } - : {}), - ...(accountId ? { accountId } : {}), - }); - return true; - } - case "signal": { - const send = params.api.runtime?.channel?.signal?.sendMessageSignal; - if (!send) { - return false; - } - await send(params.target, params.caption, { - mediaUrl: params.qrFilePath, - mediaLocalRoots, - ...(accountId ? { accountId } : {}), - }); - return true; - } - case "imessage": { - const send = params.api.runtime?.channel?.imessage?.sendMessageIMessage; - if (!send) { - return false; - } - await send(params.target, params.caption, { - mediaUrl: params.qrFilePath, - mediaLocalRoots, - ...(accountId ? { accountId } : {}), - }); - return true; - } - case "whatsapp": { - const send = params.api.runtime?.channel?.whatsapp?.sendMessageWhatsApp; - if (!send) { - return false; - } - await send(params.target, params.caption, { - verbose: false, - mediaUrl: params.qrFilePath, - mediaLocalRoots, - ...(accountId ? { accountId } : {}), - }); - return true; - } - default: - return false; + const sender = QR_CHANNEL_SENDERS[params.ctx.channel]; + if (!sender) { + return false; } + const send = sender.resolveSend(params.api); + if (!send) { + return false; + } + await send( + params.target, + params.caption, + sender.createOpts({ + ctx: params.ctx, + qrFilePath: params.qrFilePath, + mediaLocalRoots, + accountId, + }), + ); + return true; } export default definePluginEntry({ @@ -658,7 +678,7 @@ export default definePluginEntry({ let payload = await issueSetupPayload(urlResult.url); let setupCode = encodeSetupCode(payload); - const infoLines = formatQrInfoLines({ + const infoLines = buildQrInfoLines({ payload, authLabel, autoNotifyArmed, diff --git a/src/infra/device-bootstrap.test.ts b/src/infra/device-bootstrap.test.ts index fbd50382901..6136074d4b2 100644 --- a/src/infra/device-bootstrap.test.ts +++ b/src/infra/device-bootstrap.test.ts @@ -17,6 +17,22 @@ function resolveBootstrapPath(baseDir: string): string { return path.join(baseDir, "devices", "bootstrap.json"); } +async function verifyBootstrapToken( + baseDir: string, + token: string, + overrides: Partial[0]> = {}, +) { + return await verifyDeviceBootstrapToken({ + token, + deviceId: "device-123", + publicKey: "public-key-123", + role: "operator.admin", + scopes: ["operator.admin"], + baseDir, + ...overrides, + }); +} + afterEach(async () => { vi.useRealTimers(); await tempDirs.cleanup(); @@ -49,27 +65,12 @@ describe("device bootstrap tokens", () => { const baseDir = await createTempDir(); const issued = await issueDeviceBootstrapToken({ baseDir }); - await expect( - verifyDeviceBootstrapToken({ - token: issued.token, - deviceId: "device-123", - publicKey: "public-key-123", - role: "operator.admin", - scopes: ["operator.admin"], - baseDir, - }), - ).resolves.toEqual({ ok: true }); + await expect(verifyBootstrapToken(baseDir, issued.token)).resolves.toEqual({ ok: true }); - await expect( - verifyDeviceBootstrapToken({ - token: issued.token, - deviceId: "device-123", - publicKey: "public-key-123", - role: "operator.admin", - scopes: ["operator.admin"], - baseDir, - }), - ).resolves.toEqual({ ok: false, reason: "bootstrap_token_invalid" }); + await expect(verifyBootstrapToken(baseDir, issued.token)).resolves.toEqual({ + ok: false, + reason: "bootstrap_token_invalid", + }); await expect(fs.readFile(resolveBootstrapPath(baseDir), "utf8")).resolves.toBe("{}"); }); @@ -82,27 +83,15 @@ describe("device bootstrap tokens", () => { await expect(clearDeviceBootstrapTokens({ baseDir })).resolves.toEqual({ removed: 2 }); await expect(fs.readFile(resolveBootstrapPath(baseDir), "utf8")).resolves.toBe("{}"); - await expect( - verifyDeviceBootstrapToken({ - token: first.token, - deviceId: "device-123", - publicKey: "public-key-123", - role: "operator.admin", - scopes: ["operator.admin"], - baseDir, - }), - ).resolves.toEqual({ ok: false, reason: "bootstrap_token_invalid" }); + await expect(verifyBootstrapToken(baseDir, first.token)).resolves.toEqual({ + ok: false, + reason: "bootstrap_token_invalid", + }); - await expect( - verifyDeviceBootstrapToken({ - token: second.token, - deviceId: "device-123", - publicKey: "public-key-123", - role: "operator.admin", - scopes: ["operator.admin"], - baseDir, - }), - ).resolves.toEqual({ ok: false, reason: "bootstrap_token_invalid" }); + await expect(verifyBootstrapToken(baseDir, second.token)).resolves.toEqual({ + ok: false, + reason: "bootstrap_token_invalid", + }); }); it("revokes a specific bootstrap token", async () => { @@ -114,27 +103,12 @@ describe("device bootstrap tokens", () => { removed: true, }); - await expect( - verifyDeviceBootstrapToken({ - token: first.token, - deviceId: "device-123", - publicKey: "public-key-123", - role: "operator.admin", - scopes: ["operator.admin"], - baseDir, - }), - ).resolves.toEqual({ ok: false, reason: "bootstrap_token_invalid" }); + await expect(verifyBootstrapToken(baseDir, first.token)).resolves.toEqual({ + ok: false, + reason: "bootstrap_token_invalid", + }); - await expect( - verifyDeviceBootstrapToken({ - token: second.token, - deviceId: "device-123", - publicKey: "public-key-123", - role: "operator.admin", - scopes: ["operator.admin"], - baseDir, - }), - ).resolves.toEqual({ ok: true }); + await expect(verifyBootstrapToken(baseDir, second.token)).resolves.toEqual({ ok: true }); }); it("consumes bootstrap tokens by the persisted map key", async () => { @@ -158,16 +132,7 @@ describe("device bootstrap tokens", () => { "utf8", ); - await expect( - verifyDeviceBootstrapToken({ - token: issued.token, - deviceId: "device-123", - publicKey: "public-key-123", - role: "operator.admin", - scopes: ["operator.admin"], - baseDir, - }), - ).resolves.toEqual({ ok: true }); + await expect(verifyBootstrapToken(baseDir, issued.token)).resolves.toEqual({ ok: true }); await expect(fs.readFile(bootstrapPath, "utf8")).resolves.toBe("{}"); }); @@ -177,13 +142,8 @@ describe("device bootstrap tokens", () => { const issued = await issueDeviceBootstrapToken({ baseDir }); await expect( - verifyDeviceBootstrapToken({ - token: issued.token, - deviceId: "device-123", - publicKey: "public-key-123", + verifyBootstrapToken(baseDir, issued.token, { role: " ", - scopes: ["operator.admin"], - baseDir, }), ).resolves.toEqual({ ok: false, reason: "bootstrap_token_invalid" }); @@ -195,16 +155,9 @@ describe("device bootstrap tokens", () => { const baseDir = await createTempDir(); const issued = await issueDeviceBootstrapToken({ baseDir }); - await expect( - verifyDeviceBootstrapToken({ - token: ` ${issued.token} `, - deviceId: "device-123", - publicKey: "public-key-123", - role: "operator.admin", - scopes: ["operator.admin"], - baseDir, - }), - ).resolves.toEqual({ ok: true }); + await expect(verifyBootstrapToken(baseDir, ` ${issued.token} `)).resolves.toEqual({ + ok: true, + }); await expect(fs.readFile(resolveBootstrapPath(baseDir), "utf8")).resolves.toBe("{}"); }); @@ -213,16 +166,10 @@ describe("device bootstrap tokens", () => { const baseDir = await createTempDir(); await issueDeviceBootstrapToken({ baseDir }); - await expect( - verifyDeviceBootstrapToken({ - token: " ", - deviceId: "device-123", - publicKey: "public-key-123", - role: "operator.admin", - scopes: ["operator.admin"], - baseDir, - }), - ).resolves.toEqual({ ok: false, reason: "bootstrap_token_invalid" }); + await expect(verifyBootstrapToken(baseDir, " ")).resolves.toEqual({ + ok: false, + reason: "bootstrap_token_invalid", + }); await expect( verifyDeviceBootstrapToken({ @@ -279,26 +226,11 @@ describe("device bootstrap tokens", () => { "utf8", ); - await expect( - verifyDeviceBootstrapToken({ - token: "legacyToken", - deviceId: "device-123", - publicKey: "public-key-123", - role: "operator.admin", - scopes: ["operator.admin"], - baseDir, - }), - ).resolves.toEqual({ ok: true }); + await expect(verifyBootstrapToken(baseDir, "legacyToken")).resolves.toEqual({ ok: true }); - await expect( - verifyDeviceBootstrapToken({ - token: "expiredToken", - deviceId: "device-123", - publicKey: "public-key-123", - role: "operator.admin", - scopes: ["operator.admin"], - baseDir, - }), - ).resolves.toEqual({ ok: false, reason: "bootstrap_token_invalid" }); + await expect(verifyBootstrapToken(baseDir, "expiredToken")).resolves.toEqual({ + ok: false, + reason: "bootstrap_token_invalid", + }); }); });