diff --git a/apps/simplex-support-bot/README.md b/apps/simplex-support-bot/README.md index 009772fc66..c5f21ff97b 100644 --- a/apps/simplex-support-bot/README.md +++ b/apps/simplex-support-bot/README.md @@ -70,6 +70,8 @@ Run `npm start -- --help` for the auto-generated reference. Summary: | `--complete-hours` | both | no | `3` | auto-complete chats after N hours idle (`0` disables) | | `--card-flush-seconds` | both | no | `300` | debounce card state writes | | `--context-file` | both | required with `GROK_API_KEY` | | text file with Grok system context | +| `--dry-run` | both | no | | check config, database and state, then exit | +| `--allow-migrations` | both | no | | with `--dry-run`: apply pending migrations instead of reporting them | | `-h` / `--help` | both | no | | show usage and exit | ## Broadcasts @@ -113,6 +115,32 @@ SIMPLEX_LIBS_DIR=$libs npm install --no-save \ `SIMPLEX_LIBS_DIR` is needed in the last step too: npm re-runs the linked library's preinstall, which downloads the released libs without it. `rm -rf node_modules && npm ci` reverts. +## Dry run + +`--dry-run` opens the database, checks what the next start would do, and exits — it never calls `startChat`, so nothing reaches the network, and migrations are reported rather than applied. + +```bash +npm start -- --dry-run --team-group "Support Team" --pg-conn "postgres://user:pass@host/db" \ + --broadcasters 3:alice --context-file ./data/grok-context.yaml +``` + +``` +ok database postgres opened, schema up to date +ok active user: 1:Ask SimpleX Team +ok team group 1: Support Team +FAIL broadcaster 3:bob has display name "alice", the bot would exit +``` + +A pending migration stops it, since the checks need an open database and opening one can only upgrade or refuse: + +``` +FAIL database: 21 migration(s) pending, a real start would apply them: 20260507_relay_inactive_at, … +``` + +`--allow-migrations` lets it apply them first (`applied 21 migration(s): …`) and then run the remaining checks — which makes it a migration rehearsal, so point it at a restored copy unless you mean to migrate for real. The bot must be stopped either way; migrating under a running instance is what breaks things, and the upgrade is one-way. + +Exit code is 0 when every line is `ok`, 1 otherwise. It verifies the database connection, that no migration is pending, that the persisted team group and Grok user still exist (so neither would be recreated), that every `--broadcasters` / `--auto-add-team-members` id resolves to a contact with the expected display name, and that `--context-file` parses. + ## Troubleshooting - **`--pg-conn is required when backend is postgres`** — the postgres backend is installed but you didn't pass a connection string. diff --git a/apps/simplex-support-bot/bot.test.ts b/apps/simplex-support-bot/bot.test.ts index ff7f90cf44..fb8c2ee977 100644 --- a/apps/simplex-support-bot/bot.test.ts +++ b/apps/simplex-support-bot/bot.test.ts @@ -8,6 +8,7 @@ import {CardManager} from "./src/cards.js" import {parseConfig} from "./src/config.js" import {GrokApiClient} from "./src/grok.js" import {loadGrokContext} from "./src/context.js" +import {dryRun} from "./src/dryRun.js" import {welcomeMessage, queueMessage, grokActivatedMessage, teamLockedMessage, teamAlreadyInvitedMessage} from "./src/messages.js" // Silence console output during tests @@ -2546,6 +2547,15 @@ describe("parseConfig Validation", () => { expect(cfg.db).toEqual({type: "sqlite", filePrefix: "./data/simplex", encryptionKey: "secret"}) }) + test("--dry-run and --allow-migrations default to false and parse when given", () => { + const base = ["--team-group", "Team"] + expect(parseConfig(base).dryRun).toBe(false) + expect(parseConfig(base).allowMigrations).toBe(false) + const cfg = parseConfig([...base, "--dry-run", "--allow-migrations"]) + expect(cfg.dryRun).toBe(true) + expect(cfg.allowMigrations).toBe(true) + }) + test("--broadcasters → parsed as ID:name pairs, empty when absent", () => { expect(parseConfig(baseArgs).broadcasters).toEqual([]) const cfg = parseConfig([...baseArgs, "--broadcasters", "3:Carol,4:Dave"]) @@ -2801,3 +2811,83 @@ describe("loadGrokContext", () => { expect(() => loadGrokContext(join(dir, "does-not-exist.yaml"))).toThrow() }) }) + +describe("Dry Run", () => { + const directChat = (contactId: number, displayName: string) => ({ + chatInfo: {type: "direct", contact: {contactId, profile: {displayName}}}, + }) + const groupChat = (groupId: number, displayName: string) => ({ + chatInfo: {type: "group", groupInfo: {groupId, groupProfile: {displayName}}}, + }) + + function mkChat(chats: unknown[], users: number[] = [1]) { + return { + apiGetActiveUser: async () => ({userId: 1, profile: {displayName: "Ask SimpleX Team"}}), + apiListUsers: async () => users.map(userId => ({user: {userId, profile: {displayName: `user${userId}`}}})), + apiGetChats: async (_userId: number, _pagination: unknown, query?: {type: string; search?: string}) => + query?.type === "search" + ? chats.filter((c: any) => + (c.chatInfo.contact?.profile.displayName ?? c.chatInfo.groupInfo?.groupProfile.displayName) === query.search) + : chats, + } as any + } + + const config = (over: object = {}) => + ({ + teamGroup: {id: 0, name: "Support Team"}, + teamMembers: [], + broadcasters: [], + contextFile: null, + ...over, + }) as any + + test("all ids resolve → passes", async () => { + const chat = mkChat([groupChat(1, "Support Team"), directChat(3, "alice")]) + const ok = await dryRun(chat, config({broadcasters: [{id: 3, name: "alice"}]}), {teamGroupId: 1}) + expect(ok).toBe(true) + }) + + test("missing team group → reports it would be created, still passes", async () => { + const chat = mkChat([]) + expect(await dryRun(chat, config(), {})).toBe(true) + }) + + test("team group id not in database → fails", async () => { + const chat = mkChat([groupChat(7, "Support Team")]) + expect(await dryRun(chat, config(), {teamGroupId: 1})).toBe(false) + }) + + test("broadcaster id missing → fails", async () => { + const chat = mkChat([groupChat(1, "Support Team")]) + expect(await dryRun(chat, config({broadcasters: [{id: 3, name: "alice"}]}), {teamGroupId: 1})).toBe(false) + }) + + test("broadcaster name mismatch → fails even though the id exists", async () => { + const chat = mkChat([groupChat(1, "Support Team"), directChat(3, "alice")]) + expect(await dryRun(chat, config({broadcasters: [{id: 3, name: "bob"}]}), {teamGroupId: 1})).toBe(false) + }) + + test("persisted Grok user missing → fails", async () => { + const chat = mkChat([groupChat(1, "Support Team")], [1]) + expect(await dryRun(chat, config(), {teamGroupId: 1, grokUserId: 2})).toBe(false) + }) + + test("persisted Grok user present → passes", async () => { + const chat = mkChat([groupChat(1, "Support Team")], [1, 2]) + expect(await dryRun(chat, config(), {teamGroupId: 1, grokUserId: 2})).toBe(true) + }) + + test("malformed context file → fails", async () => { + const dir = mkdtempSync(join(tmpdir(), "support-bot-dryrun-")) + const path = join(dir, "ctx.yaml") + writeFileSync(path, "- role: user\n message: [unclosed\n") + const chat = mkChat([groupChat(1, "Support Team")]) + expect(await dryRun(chat, config({contextFile: path}), {teamGroupId: 1})).toBe(false) + }) + + test("missing context file → warning only, passes", async () => { + const chat = mkChat([groupChat(1, "Support Team")]) + const cfg = config({contextFile: join(tmpdir(), "no-such-context.txt")}) + expect(await dryRun(chat, cfg, {teamGroupId: 1})).toBe(true) + }) +}) diff --git a/apps/simplex-support-bot/src/config.ts b/apps/simplex-support-bot/src/config.ts index 95f14f9677..46b45bb145 100644 --- a/apps/simplex-support-bot/src/config.ts +++ b/apps/simplex-support-bot/src/config.ts @@ -19,6 +19,8 @@ export interface Config { completeHours: number cardFlushSeconds: number contextFile: string | null + dryRun: boolean + allowMigrations: boolean grokApiKey: string | null } @@ -72,6 +74,8 @@ function buildCommand(): Command { .option("--complete-hours ", "auto-complete chats after N hours idle (0 disables)", parseNonNegativeInt("--complete-hours"), 3) .option("--card-flush-seconds ", "debounce card state writes", parseNonNegativeInt("--card-flush-seconds"), 300) .option("--context-file ", "text file with Grok system context (required if GROK_API_KEY set)") + .option("--dry-run", "check config, database and state, then exit without starting chat") + .option("--allow-migrations", "with --dry-run: apply pending migrations instead of reporting them") .addHelpText("after", "\nEnvironment:\n GROK_API_KEY xAI API key — enables Grok replies\n SIMPLEX_BACKEND sqlite | postgres — alternative to .npmrc for backend selection\n") } @@ -88,6 +92,8 @@ interface RawOpts { completeHours: number cardFlushSeconds: number contextFile?: string + dryRun?: boolean + allowMigrations?: boolean } export function parseConfig(args: string[]): Config { @@ -145,6 +151,8 @@ export function parseConfig(args: string[]): Config { completeHours: opts.completeHours, cardFlushSeconds: opts.cardFlushSeconds, contextFile, + dryRun: opts.dryRun ?? false, + allowMigrations: opts.allowMigrations ?? false, grokApiKey, } } diff --git a/apps/simplex-support-bot/src/dryRun.ts b/apps/simplex-support-bot/src/dryRun.ts new file mode 100644 index 0000000000..28c90fafee --- /dev/null +++ b/apps/simplex-support-bot/src/dryRun.ts @@ -0,0 +1,119 @@ +import {api} from "simplex-chat" +import {T} from "@simplex-chat/types" +import {Config, IdName} from "./config.js" +import {loadGrokContext} from "./context.js" + +// Ids the bot persists; a miss means it would create the entity again. +export interface DryRunState { + teamGroupId?: number + grokContactId?: number + grokUserId?: number +} + +// Names are searched rather than listing every chat: a bot with thousands of +// customer chats would otherwise be paged through to find a handful of ids. +const SEARCH_LIMIT = 100 + +// Only for the failure path, where the id has to be reported with the name it +// actually has: a renamed contact does not match the search above. +const LISTING_LIMIT = 1000 + +const GROK_PROFILE_NAME = "Grok" + +export async function dryRun(chat: api.ChatApi, config: Config, state: DryRunState): Promise { + const results: boolean[] = [] + const check = (ok: boolean, msg: string): void => { + results.push(ok) + console.log(`${ok ? "ok " : "FAIL"} ${msg}`) + } + + if (config.contextFile) { + try { + const context = loadGrokContext(config.contextFile) + check(true, `Grok context: ${context.length} message(s) from ${config.contextFile}`) + } catch (err) { + const e = err as NodeJS.ErrnoException + const missing = e.code === "ENOENT" + check(missing, `Grok context ${config.contextFile}: ${missing ? "not found, would start without it" : e.message}`) + } + } + + const user = await chat.apiGetActiveUser() + if (!user) { + check(false, "no active user in database, the bot would create one") + return false + } + check(true, `active user: ${user.userId}:${user.profile.displayName}`) + + if (state.grokUserId !== undefined) { + const users = await chat.apiListUsers() + const found = users.find(u => u.user.userId === state.grokUserId) + check(!!found, `Grok user ${state.grokUserId}: ${found ? found.user.profile.displayName : "missing, the bot would exit"}`) + } + + if (state.teamGroupId !== undefined) { + const group = await findGroup(chat, user.userId, config.teamGroup.name, state.teamGroupId) + check(!!group, `team group ${state.teamGroupId}: ${group ? group.groupProfile.displayName : "not found, would be created"}`) + if (group && group.groupProfile.displayName !== config.teamGroup.name) { + check(true, `team group name differs from --team-group, profile would be updated to "${config.teamGroup.name}"`) + } + } else { + check(true, `no team group in state, "${config.teamGroup.name}" would be created`) + } + + if (state.grokContactId !== undefined) { + const contact = await findContact(chat, user.userId, GROK_PROFILE_NAME, state.grokContactId) + check(!!contact, `Grok contact ${state.grokContactId}: ${contact ? contact.profile.displayName : "not found, would reconnect"}`) + } + + await checkContacts(chat, user.userId, "team member", config.teamMembers, check) + await checkContacts(chat, user.userId, "broadcaster", config.broadcasters, check) + + return results.every(Boolean) +} + +async function checkContacts( + chat: api.ChatApi, + userId: number, + role: string, + contacts: IdName[], + check: (ok: boolean, msg: string) => void +): Promise { + for (const {id, name} of contacts) { + const contact = await findContact(chat, userId, name, id) + if (!contact) { + check(false, `${role} ${id}:${name}: not found, the bot would exit`) + } else { + const match = contact.profile.displayName === name + check(match, `${role} ${id}:${name}${match ? "" : ` has display name "${contact.profile.displayName}", the bot would exit`}`) + } + } +} + +async function findGroup(chat: api.ChatApi, userId: number, search: string, groupId: number): Promise { + const group = (chats: T.AChat[]): T.GroupInfo | undefined => { + for (const {chatInfo} of chats) { + if (chatInfo.type === "group" && chatInfo.groupInfo.groupId === groupId) return chatInfo.groupInfo + } + return undefined + } + return group(await searchChats(chat, userId, search)) ?? group(await recentChats(chat, userId)) +} + +async function findContact(chat: api.ChatApi, userId: number, search: string, contactId: number): Promise { + const contact = (chats: T.AChat[]): T.Contact | undefined => { + for (const {chatInfo} of chats) { + if (chatInfo.type === "direct" && chatInfo.contact.contactId === contactId) return chatInfo.contact + } + return undefined + } + return contact(await searchChats(chat, userId, search)) ?? contact(await recentChats(chat, userId)) +} + +function searchChats(chat: api.ChatApi, userId: number, search: string): Promise { + return chat.apiGetChats(userId, {type: "last", count: SEARCH_LIMIT}, {type: "search", search}) +} + +function recentChats(chat: api.ChatApi, userId: number): Promise { + return chat.apiGetChats(userId, {type: "last", count: LISTING_LIMIT}) +} diff --git a/apps/simplex-support-bot/src/index.ts b/apps/simplex-support-bot/src/index.ts index c059ae8daf..3345682959 100644 --- a/apps/simplex-support-bot/src/index.ts +++ b/apps/simplex-support-bot/src/index.ts @@ -1,8 +1,9 @@ import {readFileSync, writeFileSync, existsSync} from "fs" -import {api, bot, util} from "simplex-chat" +import {api, bot, core, util} from "simplex-chat" import {T} from "@simplex-chat/types" -import {IdName, parseConfig} from "./config.js" +import {Config, IdName, parseConfig} from "./config.js" import {SupportBot} from "./bot.js" +import {dryRun} from "./dryRun.js" import {GrokApiClient, GrokMessage} from "./grok.js" import {loadGrokContext} from "./context.js" import {welcomeMessage} from "./messages.js" @@ -41,6 +42,10 @@ async function main(): Promise { const stateFilePath = config.stateFile const state = readState(stateFilePath) + if (config.dryRun) { + process.exit((await runDryRun(config, state)) ? 0 : 1) + } + // Forward-reference for event handlers during init let supportBot: SupportBot | undefined @@ -377,6 +382,78 @@ async function main(): Promise { process.on("SIGTERM", () => shutdown("SIGTERM")) } +// Opens the database without starting chat, so nothing reaches the network. +// MigrationConfirmation.Error makes a pending migration a report instead of an +// upgrade — a dry run must not change the database it is inspecting. +async function runDryRun(config: Config, state: BotState): Promise { + const probe = await probeDatabase(config.db) + if (probe.type === "failed") { + console.log(`FAIL database: ${probe.text}`) + return false + } + const pending = probe.type === "pending" ? probe.names : [] + if (pending.length > 0 && !config.allowMigrations) { + console.log(`FAIL database: ${pending.length} migration(s) pending, a real start would apply them: ${pending.join(", ")}`) + return false + } + const migrating = pending.length > 0 + let chat: api.ChatApi + try { + chat = await api.ChatApi.init( + config.db, + migrating ? core.MigrationConfirmation.YesUp : core.MigrationConfirmation.Error + ) + } catch (err) { + console.log(`FAIL database: ${migrationErrorText(err)}`) + return false + } + const schemaState = migrating ? `applied ${pending.length} migration(s): ${pending.join(", ")}` : "schema up to date" + console.log(`ok database ${config.db.type} opened, ${schemaState}`) + try { + return await dryRun(chat, config, state) + } finally { + await chat.close() + } +} + +// Opening with Error never changes the database, so this reports what a real +// start would migrate. Anything other than pending up-migrations is fatal: a +// wrong key, an unreachable server or a newer database cannot be migrated away. +type DbProbe = {type: "current"} | {type: "pending"; names: string[]} | {type: "failed"; text: string} + +async function probeDatabase(db: api.DbConfig): Promise { + try { + const chat = await api.ChatApi.init(db, core.MigrationConfirmation.Error) + await chat.close() + return {type: "current"} + } catch (err) { + const dbErr = (err as core.ChatInitError).dbMigrationError + if (dbErr?.type === "errorMigration" && dbErr.migrationError.type === "upgrade") { + return {type: "pending", names: dbErr.migrationError.upMigrations.map(m => m.upName)} + } + return {type: "failed", text: migrationErrorText(err)} + } +} + +// The library reports a pending migration as one opaque message; the names are +// in the error payload, and they are what an operator needs before upgrading. +function migrationErrorText(err: unknown): string { + const e = err as core.ChatInitError + const dbErr = e.dbMigrationError + if (dbErr?.type === "errorMigration") { + const me = dbErr.migrationError + switch (me.type) { + case "upgrade": + return `${me.upMigrations.length} migration(s) pending: ${me.upMigrations.map(m => m.upName).join(", ")}` + case "downgrade": + return `database is newer than this build, it would refuse to start; migrations to roll back: ${me.downMigrations.join(", ")}` + default: + return `migration state error: ${JSON.stringify(me)}` + } + } + return e.message +} + main().catch(err => { logError("Fatal error", err) process.exit(1) diff --git a/packages/simplex-chat-nodejs/src/core.ts b/packages/simplex-chat-nodejs/src/core.ts index 949c2356af..abdd839779 100644 --- a/packages/simplex-chat-nodejs/src/core.ts +++ b/packages/simplex-chat-nodejs/src/core.ts @@ -168,7 +168,7 @@ export namespace MigrationError { export interface MEUpgrade extends Interface { type: "upgrade" - upMigrations: UpMigration + upMigrations: UpMigration[] } export interface MEDowngrade extends Interface { @@ -200,7 +200,7 @@ export namespace MTRError { export interface MTRENoDown extends Interface { type: "noDown" - upMigrations: UpMigration + dbMigrations: string[] } export interface MTREDifferent extends Interface {