support bot: add --dry-run startup check

This commit is contained in:
shum
2026-09-12 14:58:56 +00:00
parent 2d45b62589
commit 7df004f48c
6 changed files with 326 additions and 4 deletions
+28
View File
@@ -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.
+90
View File
@@ -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)
})
})
+8
View File
@@ -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 <n>", "auto-complete chats after N hours idle (0 disables)", parseNonNegativeInt("--complete-hours"), 3)
.option("--card-flush-seconds <n>", "debounce card state writes", parseNonNegativeInt("--card-flush-seconds"), 300)
.option("--context-file <path>", "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,
}
}
+119
View File
@@ -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<boolean> {
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<void> {
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<T.GroupInfo | undefined> {
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<T.Contact | undefined> {
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<T.AChat[]> {
return chat.apiGetChats(userId, {type: "last", count: SEARCH_LIMIT}, {type: "search", search})
}
function recentChats(chat: api.ChatApi, userId: number): Promise<T.AChat[]> {
return chat.apiGetChats(userId, {type: "last", count: LISTING_LIMIT})
}
+79 -2
View File
@@ -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<void> {
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<void> {
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<boolean> {
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<DbProbe> {
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)
+2 -2
View File
@@ -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 {