// The server's side of moving one of its bots to another home: from My Cloud into one of its person's teams // (electron/team-move.mjs runs the move), or from this computer to My Cloud (electron/bot-move-main.mjs). While the bot goes // it takes no turns here and its automations are off, and which ones were on is kept, so they come back on if the move // stops. Once it lives there the bot stays here archived, as the line that opens it there. Its routines are gone from // here when it went into a team; a bot that went to My Cloud keeps them here switched off, and comes back as it was if // My Cloud ends. import type { BotMovedTo } from "../shared/wire.ts"; import type { AppTriggerManager } from "./app-triggers.ts"; import type { RoutineManager } from "./routines.ts"; import type { BotRecord, GroupRecord } from "./store.ts"; import type { WaitManager } from "./waits.ts"; import type { WebhookManager } from "./webhooks.ts"; /** The "team" a bot moving from this computer to My Cloud goes to. */ export const MY_CLOUD_TEAM = "my-cloud"; export interface BotMovesDeps { store: { bot(id: string): BotRecord | null | undefined; patchBot(id: string, patch: Partial): BotRecord | null; groups?: ReadonlyArray>; patchGroup?(id: string, patch: { memberIds: string[] }): unknown }; routines(): Pick | null; webhooks(): Pick | null; appEvents(): Pick | null; waits(): Pick | null; /** A turn or a routine run of this bot is going. */ busy(botId: string): boolean; now?: () => number; } type Team = BotMovedTo["team"]; /** What the move switched off here, by key ("routine:"), to switch back on if it stops. */ type Held = BotMovedTo & { held?: string[] }; /** Why this bot takes no turn here, or null. */ export function botMoveRefusal(bot: Pick & { movedTo?: BotMovedTo } | null | undefined): string | null { const moved = bot?.movedTo; if (!moved) return null; return moved.state === "moved" ? `${bot!.name} lives in ${moved.team.name} now.` : `${bot!.name} is moving to ${moved.team.name}. It's ready there in a moment.`; } /** A turn of this bot starts here only while it is not moving or moved. */ export function assertBotHere(bot: Pick & { movedTo?: BotMovedTo } | null | undefined): void { const said = botMoveRefusal(bot); if (said) throw Object.assign(new Error(said), { status: 409, code: "moved" }); } export function createBotMoves(deps: BotMovesDeps) { const now = deps.now ?? Date.now; const automations = (botId: string) => [ ...(deps.routines()?.listRoutines() ?? []).filter((routine) => routine.botId === botId).map((routine) => ({ key: `routine:${routine.id}`, on: routine.enabled })), ...(deps.webhooks()?.list() ?? []).filter((hook) => hook.botId === botId).map((hook) => ({ key: `webhook:${hook.id}`, on: hook.enabled })), ...(deps.appEvents()?.list() ?? []).filter((trigger) => trigger.botId === botId).map((trigger) => ({ key: `app-event:${trigger.id}`, on: trigger.enabled })), ]; async function switchTo(key: string, on: boolean): Promise { const [kind, id] = key.split(":") as [string, string]; if (kind === "routine") deps.routines()?.update(id, { enabled: on }); else if (kind === "webhook") deps.webhooks()?.update(id, { enabled: on }); else if (kind === "app-event") await deps.appEvents()?.update(id, { enabled: on }); } const movedOf = (bot: BotRecord | null | undefined): Held | undefined => (bot as (BotRecord & { movedTo?: Held }) | null | undefined)?.movedTo; const cancelWaits = (botId: string) => { const waits = deps.waits(); for (const wait of waits?.all() ?? []) if (wait.botId === botId) waits!.cancel(wait.id, botId); }; /** A room some of whose bots stay goes on here without this one; one all of whose bots left is kept as it was. */ const leaveRooms = (botId: string) => { for (const room of deps.store.groups ?? []) { if (room.dm || !room.memberIds.includes(botId)) continue; if (room.memberIds.every((id) => id === botId || deps.store.bot(id)?.movedTo || deps.store.bot(id)?.defaultBot)) continue; deps.store.patchGroup?.(room.id, { memberIds: room.memberIds.filter((id) => id !== botId) }); } }; return { /** The bot stops here for the move: no turns, its automations off and its waits cancelled. Busy while it answers; a * bot already moved stays moved. Holding it again keeps what the first hold switched off. */ async hold(botId: string, team: Team): Promise<{ movedTo: Held } | { busy: string } | { refused: string }> { const bot = deps.store.bot(botId); if (!bot) return { refused: "This bot is no longer here." }; if (bot.defaultBot) return { refused: `${bot.name} stays here: everyone on a team gets their own.` }; const held = movedOf(bot); if (held?.state === "moved") return held.team.id === team.id ? { movedTo: held } : { refused: botMoveRefusal(bot)! }; if (held && held.team.id !== team.id) return { refused: botMoveRefusal(bot)! }; if (!held && deps.busy(botId)) return { busy: bot.name }; const off = held?.held ?? []; const next: Held = { team, state: "moving", at: held?.at ?? now(), held: off }; deps.store.patchBot(botId, { movedTo: next } as Partial); for (const automation of automations(botId)) { if (!automation.on) continue; // Kept before it is switched off, so a stop half-way still switches it back on. if (!off.includes(automation.key)) off.push(automation.key); deps.store.patchBot(botId, { movedTo: { ...next, held: [...off] } } as Partial); await switchTo(automation.key, false); } cancelWaits(botId); return { movedTo: movedOf(deps.store.bot(botId))! }; }, /** The move stopped before the bot arrived: it is here again as it was, its automations back on. */ async release(botId: string): Promise<{ released: true } | { refused: string }> { const bot = deps.store.bot(botId); const held = movedOf(bot); if (!bot || !held) return { released: true }; if (held.state === "moved") return { refused: botMoveRefusal(bot)! }; for (const key of held.held ?? []) await switchTo(key, true); deps.store.patchBot(botId, { movedTo: undefined } as Partial); return { released: true }; }, /** The bot lives in the team now: archived here as the line that opens it there, its routines and waits gone from here. */ moved(botId: string, team: Team): { movedTo: Held } | { refused: string } { const bot = deps.store.bot(botId); const held = movedOf(bot); if (!bot || !held || held.team.id !== team.id) return { refused: "This bot is not moving to that team." }; if (held.state !== "moved") { if (team.id === MY_CLOUD_TEAM) leaveRooms(botId); else deps.routines()?.removeForBot(botId); cancelWaits(botId); deps.store.patchBot(botId, { movedTo: { ...held, state: "moved", at: now() }, hidden: true } as Partial); } return { movedTo: movedOf(deps.store.bot(botId))! }; }, /** My Cloud ended: a bot that moved there is here again as it was when it went, its automations left off. */ back(botId: string): { released: true } | { refused: string } { const bot = deps.store.bot(botId); const held = movedOf(bot); if (!bot || !held) return { released: true }; if (held.team.id !== MY_CLOUD_TEAM) return { refused: botMoveRefusal(bot)! }; deps.store.patchBot(botId, { movedTo: undefined, hidden: false } as Partial); return { released: true }; }, }; } export type BotMoves = ReturnType;