Ctrl K
ComunidadGuías…

server/cli-pairing.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
// What `openmausbot serve` and `openmausbot pair` print for a pairing code. A browser on this computer is no longer the
// owner just by being here, so it signs in once with the link printed for it.
import qrcode from "qrcode-terminal";

import { cliCommand } from "./cli-invocation.ts";
import type { PhoneRelayStatus } from "./phone-relay.ts";

/** A clock time a code works until: "11:22", or "tomorrow 11:22" / a date when it runs past today. */
export function untilText(expiresAt: number, now = Date.now()): string {
  const end = new Date(expiresAt);
  const time = end.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
  const today = new Date(now);
  if (end.toDateString() === today.toDateString()) return time;
  const tomorrow = new Date(now + 86_400_000);
  return end.toDateString() === tomorrow.toDateString() ? `tomorrow ${time}` : `${end.toLocaleDateString()} ${time}`;
}

/**
 * What `serve` and `pair` print for one pairing code. `link`: the address other devices reach this server at, with the
 * code (null when it has no way in); `localLink`: a browser on this machine (or the Docker host, `localPlace`);
 * `inviteUrl`: the phone-app link only Android's scanner needs; `prefix`: how the CLI was started, so commands paste.
 */
export function pairingBlock(input: {
  code: string;
  expiresAt: number;
  chatOnly: boolean;
  link: string | null;
  localLink?: string | null;
  localPlace?: "machine" | "docker";
  inviteUrl?: string | null;
  phone?: "ios" | "android";
  prefix: string;
  now?: number;
}): string {
  const scope = input.chatOnly ? "chat and approvals" : "everything";
  const lines = [`Pairing code   ${input.code}   works once, until ${untilText(input.expiresAt, input.now)} · ${scope}`];
  if (!input.link) {
    lines.push("This server is reachable from this machine only, so there's no link for other devices.");
    if (input.localLink) lines.push(`  ${input.localPlace === "docker" ? "Browser on the Docker host" : "Browser on this machine  "}   ${input.localLink}`);
    lines.push(`  Other devices               pick a way in first: ${input.prefix} serve --help`);
    return lines.join("\n");
  }
  const scanInvite = input.phone === "android" && Boolean(input.inviteUrl);
  lines.push(`Link           ${input.link}`);
  lines.push("");
  lines.push("  Desktop app   + → Connect to a server, paste the link");
  lines.push(scanInvite ? "  Phone         scan this with the MausBot app" : "  Phone         scan with your camera or the MausBot app");
  lines.push("");
  lines.push(qrToString(scanInvite ? input.inviteUrl! : input.link));
  if (!input.chatOnly) lines.push(`For a phone (chat and approvals only)   ${input.prefix} pair --phone`);
  return lines.join("\n");
}

/** The guided start's one sign-in, for the browser it opens on this computer. */
export function browserSignInLines(localUrl: string, code: string, expiresAt: number): string {
  return [
    "Sign in from a browser on this computer with this link (single use, until",
    `${new Date(expiresAt).toLocaleTimeString()}): ${localUrl}`,
    `or type the code ${code} on the sign-in page. Later: ${cliCommand("pair")}`,
  ].join("\n");
}

export function qrToString(text: string): string {
  let out = "";
  qrcode.generate(text, { small: true }, (rendered: string) => {
    out = rendered;
  });
  return out;
}

export function formatSessions(sessions: Array<{ id: string; label: string; scopes: string[]; lastSeenAt: number; expiresAt: number; token?: true }>, now = Date.now()): string {
  const age = (ms: number) => {
    const m = Math.max(0, Math.floor((now - ms) / 60_000));
    return m < 1 ? "just now" : m < 60 ? `${m} min ago` : m < 1440 ? `${Math.round(m / 60)} h ago` : `${Math.round(m / 1440)} d ago`;
  };
  const rows = sessions.map((s) => [s.id, `${s.label || "(unnamed)"}${s.token ? " (token)" : ""}`, s.scopes.includes("admin") ? "admin" : "client", age(s.lastSeenAt), new Date(s.expiresAt).toISOString().slice(0, 10)]);
  const head = ["id", "device", "scope", "last seen", "expires"];
  const widths = head.map((h, i) => Math.max(h.length, ...rows.map((r) => r[i].length)));
  const line = (r: string[]) => r.map((c, i) => c.padEnd(widths[i])).join("  ");
  return [line(head), ...rows.map(line), "", `sign one out with: ${cliCommand("devices revoke <id>")}`].join("\n");
}

const isStatus = (value: unknown): value is PhoneRelayStatus =>
  typeof value === "object" && value !== null && typeof (value as PhoneRelayStatus).grant === "string" && typeof (value as PhoneRelayStatus).relay === "string";

/** Until the control plane tells a server from a desktop, a machine that takes a relay token gives back its tunnel. */
export const RELAY_NOT_WITH_TUNNEL = "--relay cannot be used on a machine whose saved choice is a tunnel address: a relay token would make the tunnel go for good. Start with --local, or use the relay on a machine signed in with `openmausbot login --relay`.";

/** Why phones cannot pair through the relay yet, in words for the person at the terminal; empty when they can. */
export function relayProblemLines(status: PhoneRelayStatus | null, prefix: string): string[] {
  if (!status) return [`MausBot Relay is off on this server. Start it with: ${prefix} serve --relay`];
  if (status.grant === "tunnel") return [`MausBot Relay stays off here: ${status.reason ?? "this machine holds a tunnel address"}. A relay token would make that tunnel go for good; use the relay on a machine signed in with ${prefix} login --relay.`];
  if (status.grant === "signed-out") return [`MausBot Relay needs this machine signed in to a MausBot account: ${prefix} login --relay (then leave the server running; it connects within a minute).`];
  if (status.grant === "unavailable") return ["MausBot Relay is not available for this machine right now. The server keeps working here; it asks again every half hour."];
  if (status.grant === "failed") return ["The MausBot account service did not answer. The server keeps working here and keeps trying."];
  if (status.grant === "waiting") return ["Still asking the MausBot account service for a relay pass…"];
  if (status.relay !== "connected") return ["Can't reach MausBot Relay yet. The server keeps working here and keeps trying; phones pair once it connects."];
  return [];
}

/** The relay block for a phone code, or null when the server did not answer with a relay link. */
export function relayPairingBlock(body: Record<string, unknown>, chatOnly: boolean, prefix: string, now = Date.now()): string | null {
  const invite = typeof body.inviteUrl === "string" && body.inviteUrl.includes("&e2e=") ? body.inviteUrl : null;
  if (!chatOnly || !isStatus(body.relay) || !invite || typeof body.expiresAt !== "number") return null;
  const lines = [
    `Phone pairing   works once, until ${untilText(body.expiresAt, now)} · chat and approvals`,
    "  Scan with the MausBot app. It connects through MausBot Relay, end-to-end encrypted: no address, open port or VPN needed.",
    "",
    qrToString(invite),
    ...relayProblemLines(body.relay, prefix),
    `Paired phones: ${body.relay.phones} of ${body.relay.maxPhones}. Remove one: ${prefix} devices revoke ID`,
  ];
  return lines.join("\n");
}

/** Waits for the relay to connect, then prints a phone code; otherwise says why not. True when it printed a code. A
 * service's output lands in its log on every start, so the one-time credential is printed only at a terminal. */
export async function showRelayPairing(input: {
  tty: boolean;
  status: () => Promise<unknown>;
  mint: () => Promise<string>;
  log: (line: string) => void;
  prefix: string;
  waitMs?: number;
}): Promise<boolean> {
  if (!input.tty) {
    input.log(`Pair a phone: ${input.prefix} pair --phone`);
    return false;
  }
  const deadline = Date.now() + (input.waitMs ?? 20_000);
  let status: PhoneRelayStatus | null = null;
  for (;;) {
    const read = await input.status().catch(() => null);
    status = isStatus(read) ? read : null;
    if (!status || (status.grant === "ok" && status.relay === "connected") || Date.now() > deadline) break;
    await new Promise((resolve) => setTimeout(resolve, 500));
  }
  if (status?.grant === "ok") {
    input.log(await input.mint());
    return true;
  }
  for (const line of relayProblemLines(status, input.prefix)) input.log(line);
  if (status?.grant !== "tunnel") input.log(`Then: ${input.prefix} pair --phone`);
  return false;
}