mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-09-30 00:08:44 +00:00
support bot: add --dry-run startup check
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user