electron/cloud-send-parts.mjs
Rama main · réplica de lectura del repositorio original.
// Send to My Cloud: what each server reports it has (GET /api/cloud-send), and what this computer sends. One direction,
// this computer to the person's own Cloud. Shared by the server (which hashes its own records) and main (which plans).
/** A bot, compared and sent part by part: each part is the fields PATCH /api/bots/:id takes for it. */
export const BOT_PARTS = Object.freeze({
name: ["name"],
title: ["title"],
description: ["description"],
soul: ["soul"],
look: ["color", "mascotExpression", "mascotBody", "avatarCrop", "avatarZoom", "avatarFocusX", "avatarFocusY"],
model: ["modelSelection"],
access: ["approvalMode", "alwaysAllow"],
voice: ["speakReplies", "replyStyle", "voice", "voiceNotes"],
tools: ["browser", "composio", "connectorTools", "connectorScopes", "outbound", "xResearch", "mcpServers", "toolScope"],
memory: ["memoryEnabled", "memoryUpkeep"],
team: ["section"],
habits: ["notifications", "parkDirectMessages", "helperModel", "cacheKeepWarm", "recordBrowserSteps"],
});
/** A bot's parts only a move compares and carries: My Cloud sets them over its own move route, under the same ids. */
export const MOVE_BOT_PARTS = Object.freeze({
archived: ["hidden"],
pinned: ["pinned"],
chief: ["chiefOfStaff", "managedSections"],
helper: ["defaultBot", "sidebarHidden", "welcomed"],
fallback: ["fallback"],
projects: ["projects"],
playbooks: ["playbooks"],
package: ["installedPackage"],
library: ["assignedSkills"],
});
/** Each server stores a picture under its own name, so the picture is compared by its bytes. */
export const PICTURE_PART = "picture";
export const SETTINGS = Object.freeze(["profile", "language"]);
/** What only a move carries, each by its own id: automations go last, after the chats, and arrive switched off. */
export const AUTOMATION_KINDS = Object.freeze(["routine", "call", "webhook", "app-event"]);
export const MOVE_KINDS = Object.freeze([...AUTOMATION_KINDS, "imported-skill", "library-skill"]);
export const KINDS = Object.freeze(["bot", "skill", "login", "1password", "secret", "setting", ...MOVE_KINDS]);
/** A part a server has no value for. */
export const EMPTY = "-";
/** What either page closes the one-time move's own line by (cloud-send:dismiss). */
export const MOVE_LINE = "one-home";
/** What My Cloud's page closes the line of a bot moving into a team by. */
export const TEAM_MOVE_LINE = "team-move";
export const BOT_MOVE_LINE = "bot-move";
/** Logins, 1Password and saved secrets are compared by when they changed, never by their secrets: two sides that both
* have one were set up on their own, and from then on a change here goes. */
const CONTENT_COMPARED = new Set(["bot", "skill", "setting", ...MOVE_KINDS]);
export const itemKey = Object.freeze({
bot: (id) => `bot:${id}`,
skill: (botId, name) => `skill:${botId}:${name}`,
/** A login kept to one exact host is its own account there, so the host is part of its key. */
login: (site, username, exactHost) => `login:${site}|${String(username).toLowerCase()}${exactHost ? `|${exactHost}` : ""}`,
onePassword: () => "1password",
/** A saved Vault secret, by its id here: it keeps that id on My Cloud, so an MCP server's `vault:<id>` moves with it. */
secret: (id) => `secret:${id}`,
setting: (name) => `setting:${name}`,
/** A move's kinds: `routine`, `call`, `webhook`, `app-event` by id, `imported-skill` and `library-skill` by name. */
moved: (kind, id) => `${kind}:${id}`,
});
const record = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
const KEY = /^(?:bot:[\w-]{1,128}|skill:[\w-]{1,128}:[a-z0-9-]{1,64}|login:[^|\s]{1,253}\|[^\n]{0,512}|1password|secret:[\w-]{1,128}|setting:[a-z]{1,40}|(?:routine|call|webhook|app-event):[\w-]{1,128}|(?:imported|library)-skill:[a-z0-9-]{1,64})$/u;
const PART = /^[a-z]{1,20}$/u;
const HASH = /^[\w:.-]{1,80}$/u;
/** A server's answer, kept only in the shape main and the page rely on; anything else is dropped, never guessed. */
export function parseSendSummary(value) {
if (!record(value) || value.v !== 1 || !Array.isArray(value.items)) return null;
const items = [];
for (const item of value.items.slice(0, 200_000)) {
if (!record(item) || typeof item.key !== "string" || !KEY.test(item.key) || !KINDS.includes(item.kind) || !record(item.parts)) continue;
const parts = Object.fromEntries(Object.entries(item.parts).filter(([part, hash]) => PART.test(part) && typeof hash === "string" && HASH.test(hash)));
items.push({
key: item.key, kind: item.kind, label: typeof item.label === "string" ? item.label.slice(0, 200) : "",
...(typeof item.detail === "string" && item.detail ? { detail: item.detail.slice(0, 200) } : {}),
...(typeof item.id === "string" && item.id.length <= 300 ? { id: item.id } : {}),
...(typeof item.bot === "string" && /^[\w-]{1,128}$/u.test(item.bot) ? { bot: item.bot } : {}),
parts,
});
}
return { v: 1, appVersion: typeof value.appVersion === "string" ? value.appVersion.slice(0, 40) : null, items, ...(value.chats === 1 ? { chats: true } : {}), ...(value.vault === 1 ? { vault: true } : {}) };
}
/**
* The one rule, three-way per part. `sent[key][part]` is [this computer's hash, My Cloud's hash] right after the last
* send, or when both were last seen equal. A part goes when it changed here since and is unchanged there, or when My
* Cloud has nothing for it ("-"). Any other difference is My Cloud's to keep ("differs") unless the person chooses
* this computer's (`force`). An item sent before and gone from My Cloud was removed there: it is never brought back,
* and its switch turns off. `off[key]`: the "Also on My Cloud" switch, off ("you") or off after a removal ("removed").
*
* @returns {{ rows: Array<{ key: string, kind: string, label: string, detail?: string, id?: string, bot?: string,
* state: "only-here" | "on-both" | "to-update" | "differs" | "removed" | "off", parts?: string[], differs?: string[] }>,
* cloudOnly: Array<{ key: string, kind: string, label: string, detail?: string, bot?: string }>,
* baseline: Record<string, Record<string, [string, string]>>, removed: string[] }}
*/
export function planSend({ local, cloud, sent = {}, off = {}, force = [] }) {
const forced = new Set(force);
const there = new Map((cloud?.items ?? []).map((item) => [item.key, item]));
const here = new Set();
const rows = [], baseline = {}, removed = [];
const botRow = new Map();
const ordered = [...(local?.items ?? [])].sort((a, b) => (a.kind === "bot" ? 0 : 1) - (b.kind === "bot" ? 0 : 1));
for (const item of ordered) {
here.add(item.key);
const base = { key: item.key, kind: item.kind, label: item.label, ...(item.detail ? { detail: item.detail } : {}), ...(item.id ? { id: item.id } : {}),
...(item.bot ? { bot: item.bot } : {}) };
const parent = item.bot ? botRow.get(itemKey.bot(item.bot)) : null;
const settled = (row) => { rows.push(row); if (item.kind === "bot") botRow.set(item.key, row); };
if (off[item.key] || (parent && (parent.state === "off" || parent.state === "removed"))) {
settled({ ...base, state: off[item.key] === "removed" || parent?.state === "removed" ? "removed" : "off" });
continue;
}
const theirs = there.get(item.key), last = record(sent[item.key]) ? sent[item.key] : null;
if (!theirs) {
if (last && !(parent && parent.state === "only-here")) {
removed.push(item.key);
settled({ ...base, state: "removed" });
} else settled({ ...base, state: "only-here" });
continue;
}
const parts = [], differs = [];
for (const [part, mine] of Object.entries(item.parts)) {
const cloudHash = theirs.parts[part] ?? EMPTY;
const pair = Array.isArray(last?.[part]) ? last[part] : null;
if (mine === cloudHash || (!pair && !CONTENT_COMPARED.has(item.kind) && cloudHash !== EMPTY)) {
if (!pair || pair[0] !== mine || pair[1] !== cloudHash) (baseline[item.key] ??= {})[part] = [mine, cloudHash];
} else if (pair && mine === pair[0]) continue;
else if ((pair && cloudHash === pair[1]) || (cloudHash === EMPTY && mine !== EMPTY) || forced.has(item.key)) parts.push(part);
else differs.push(part);
}
settled({ ...base, state: parts.length ? "to-update" : differs.length ? "differs" : "on-both",
...(parts.length ? { parts } : {}), ...(differs.length ? { differs } : {}) });
}
const cloudOnly = (cloud?.items ?? []).filter((item) => !here.has(item.key))
.map((item) => ({ key: item.key, kind: item.kind, label: item.label, ...(item.detail ? { detail: item.detail } : {}), ...(item.bot ? { bot: item.bot } : {}) }));
return { rows, cloudOnly, baseline, removed };
}
/** What waits to go: rows a send would carry. A skill of a bot that goes whole goes with it. */
export function pendingRows(plan) {
const adding = new Set(plan.rows.filter((row) => row.kind === "bot" && row.state === "only-here").map((row) => row.key));
return plan.rows.filter((row) => (row.state === "only-here" || row.state === "to-update") && !(row.bot && adding.has(itemKey.bot(row.bot))));
}
/** A stable name for one set of differences: the strip on My Cloud hides until it changes. */
export function differenceSignature(plan) {
return pendingRows(plan).map((row) => `${row.key}#${row.state}#${(row.parts ?? []).join(",")}`).sort().join("\n");
}
const FAILURE_REASONS = 3;
/** What a send did not get across, grouped by reason (bots by name, logins only counted, never a site), and why a send
* stopped as a whole; the strip on My Cloud and Settings → MausBot Cloud both say it. */
export function failedSummary(outcome) {
const reasons = new Map();
for (const entry of outcome.result.failed) {
const reason = reasons.get(entry.error) ?? { what: { bots: [], skills: 0, logins: 0, onePassword: false, settings: 0, connections: 0, chats: false }, error: entry.error };
const what = reason.what;
if (entry.kind === "bot") what.bots.push(entry.label);
else if (entry.kind === "connection") what.connections++;
else if (entry.kind === "skill") what.skills++;
else if (entry.kind === "login") what.logins++;
else if (entry.kind === "secret") what.secrets = (what.secrets ?? 0) + 1;
else if (entry.kind === "setting") what.settings++;
else if (entry.kind === "1password") what.onePassword = true;
else if (entry.kind === "chats") what.chats = true;
reasons.set(entry.error, reason);
}
return { ...(reasons.size ? { reasons: [...reasons.values()].slice(0, FAILURE_REASONS) } : {}), ...(outcome.error ? { error: outcome.error } : {}) };
}
/** Close on My Cloud's strip: kept with the send whose result it closed. */
export const resultKey = (last) => `result:${last.at}`;