Device pair: dedupe QR pairing flow
This commit is contained in:
parent
7647951dd3
commit
a0c340292c
@ -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 {
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -71,6 +71,37 @@ function createApi(params?: {
|
||||
}) as OpenClawPluginApi;
|
||||
}
|
||||
|
||||
function registerPairCommand(params?: {
|
||||
runtime?: OpenClawPluginApi["runtime"];
|
||||
pluginConfig?: Record<string, unknown>;
|
||||
}): 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<unknown>,
|
||||
): OpenClawPluginApi["runtime"] {
|
||||
return {
|
||||
channel: {
|
||||
[runtimeKey]: {
|
||||
[sendKey]: sendMessage,
|
||||
},
|
||||
},
|
||||
} as unknown as OpenClawPluginApi["runtime"];
|
||||
}
|
||||
|
||||
function createCommandContext(params?: Partial<PluginCommandContext>): 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<string, unknown>) => {
|
||||
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<string, unknown>) => {
|
||||
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<string, unknown>) => {
|
||||
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<string, unknown>) => {
|
||||
expect(opts.accountId).toBe("default");
|
||||
expectedOpts: {
|
||||
accountId: "default",
|
||||
},
|
||||
},
|
||||
{
|
||||
@ -194,8 +217,8 @@ describe("device-pair /pair qr", () => {
|
||||
accountId: "default",
|
||||
},
|
||||
expectedTarget: "+15551234567",
|
||||
assertOpts: (opts: Record<string, unknown>) => {
|
||||
expect(opts.accountId).toBe("default");
|
||||
expectedOpts: {
|
||||
accountId: "default",
|
||||
},
|
||||
},
|
||||
{
|
||||
@ -208,9 +231,9 @@ describe("device-pair /pair qr", () => {
|
||||
accountId: "default",
|
||||
},
|
||||
expectedTarget: "+15551234567",
|
||||
assertOpts: (opts: Record<string, unknown>) => {
|
||||
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",
|
||||
|
||||
@ -74,6 +74,76 @@ type QrCommandContext = {
|
||||
messageThreadId?: string | number;
|
||||
};
|
||||
|
||||
type QrChannelSender = {
|
||||
resolveSend: (api: OpenClawPluginApi) => ((...args: unknown[]) => Promise<unknown>) | undefined;
|
||||
createOpts: (params: {
|
||||
ctx: QrCommandContext;
|
||||
qrFilePath: string;
|
||||
mediaLocalRoots: string[];
|
||||
accountId?: string;
|
||||
}) => Record<string, unknown>;
|
||||
};
|
||||
|
||||
type QrSendFn = (to: string, text: string, opts: Record<string, unknown>) => Promise<unknown>;
|
||||
|
||||
function coerceQrSend(send: unknown): QrSendFn | undefined {
|
||||
return typeof send === "function" ? (send as QrSendFn) : undefined;
|
||||
}
|
||||
|
||||
const QR_CHANNEL_SENDERS: Record<string, QrChannelSender> = {
|
||||
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<boolean> {
|
||||
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,
|
||||
|
||||
@ -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<Parameters<typeof verifyDeviceBootstrapToken>[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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user