diff --git a/apps/simplex-support-bot/README.md b/apps/simplex-support-bot/README.md new file mode 100644 index 0000000000..2744cddd84 --- /dev/null +++ b/apps/simplex-support-bot/README.md @@ -0,0 +1,88 @@ +# SimpleX Support Bot + +A business-address bot that triages incoming support chats, optionally runs them through Grok, and routes handoffs to a team group. + +## Prerequisites + +- Node.js (v18+; v24 tested) +- For PostgreSQL backend only: `libpq5` on the host (`apt install libpq5` on Debian/Ubuntu) and Linux x86_64 +- `GROK_API_KEY` env var (xAI key) — only required when you want Grok replies; the bot runs without it + +## 1. Build + +```bash +cd apps/simplex-support-bot +./build.sh +``` + +This builds `@simplex-chat/types`, `simplex-chat` (native lib), and the bot. SQLite is the default. + +To build with **PostgreSQL** instead, set the env var before running `./build.sh`: + +```bash +SIMPLEX_BACKEND=postgres ./build.sh +``` + +Or put `simplex_backend=postgres` in `.npmrc` at the repo root (picked up by all nested `npm install`s). + +## 2. Run — SQLite (default) + +```bash +export GROK_API_KEY=xai-... # optional, only if you want Grok +./start.sh --team-group "Support Team" +``` + +Default locations: +- DB files: `./data/simplex_chat.db`, `./data/simplex_agent.db` +- State JSON: `./data/state.json` + +Override any of those with flags — see [Flags](#flags). + +## 3. Run — PostgreSQL + +```bash +export GROK_API_KEY=xai-... # optional +./start.sh \ + --team-group "Support Team" \ + --pg-conn "postgres://simplex:pass@localhost/simplex_bot" +``` + +The bot must have been built with `SIMPLEX_BACKEND=postgres` (step 1). Schema prefix defaults to `simplex_v1` — pass `--pg-schema ` to namespace a different prefix. + +## Flags + +All flags are parsed via `node:util.parseArgs` (strict mode — unknown flags are rejected). + +| Flag | Backend | Required | Default | Description | +|---|---|---|---|---| +| `--team-group` | | yes | — | team group display name | +| `--state-file` | both | no | `./data/state.json` | path to bot state JSON | +| `--sqlite-file-prefix` | sqlite | no | `./data/simplex` | DB file prefix (creates `_chat.db`, `_agent.db`) | +| `--sqlite-key` | sqlite | no | (unencrypted) | SQLCipher encryption key | +| `--pg-conn` | postgres | yes | — | PostgreSQL connection string | +| `--pg-schema` | postgres | no | `simplex_v1` | schema prefix used for bot tables | +| `-a` / `--auto-add-team-members` | | no | | comma-separated `ID:name` pairs (e.g. `1:Alice,2:Bob`) | +| `--timezone` | | no | `UTC` | IANA zone for weekend detection | +| `--complete-hours` | | no | `3` | auto-complete chats after N hours idle (`0` disables) | +| `--card-flush-seconds` | | no | `300` | debounce card state writes | +| `--context-file` | | required with `GROK_API_KEY` | | text file with Grok system context | + +Flag values containing leading dashes must use `--flag=value` form (e.g. `--sqlite-key=-abc`). + +## Environment variables + +| Var | Purpose | +|---|---| +| `GROK_API_KEY` | xAI API key; enables Grok replies | +| `SIMPLEX_BACKEND` | install-time selector for the native lib backend (`sqlite` or `postgres`); `npm_config_simplex_backend` from `.npmrc` works the same way | + +## State file + +`--state-file` holds persisted IDs between runs (team group, Grok contact). It's a local JSON regardless of DB backend. Parent directory must exist. + +## Troubleshooting + +- **"Option '--X' argument is ambiguous"** — use `--X=value` form when the value starts with `-`. +- **Postgres: connect errors** — check that `libpq5` is installed and the connection string is correct. The native lib errors will surface at `ChatApi.init(...)` on startup. +- **Sqlite: DB files missing** — check the directory pointed to by `--sqlite-file-prefix` exists. +- **"--pg-conn is required when backend is postgres"** — you built with the postgres backend (see `installed.txt` in `packages/simplex-chat-nodejs/libs/`) but didn't pass `--pg-conn`. diff --git a/apps/simplex-support-bot/bot.test.ts b/apps/simplex-support-bot/bot.test.ts index 2142add718..0a3edd5ff3 100644 --- a/apps/simplex-support-bot/bot.test.ts +++ b/apps/simplex-support-bot/bot.test.ts @@ -228,7 +228,8 @@ function makeCustomerMember(status = GroupMemberStatus.Connected) { function makeConfig(overrides: Partial = {}) { return { - dbPrefix: "./test-data/simplex", + stateFile: "./test-data/state.json", + db: {type: "sqlite", filePrefix: "./test-data/simplex"}, teamGroup: {id: TEAM_GROUP_ID, name: "SupportTeam"}, teamMembers: [ {id: TEAM_MEMBER_1_ID, name: "Alice"}, @@ -2290,8 +2291,86 @@ describe("parseConfig Validation", () => { .toThrow(/--complete-hours must be a non-negative integer, got "abc"/) }) + test("postgres backend without --pg-conn → throws", () => { + const prev = process.env.SIMPLEX_BACKEND + process.env.SIMPLEX_BACKEND = "postgres" + try { + expect(() => parseConfig(baseArgs)) + .toThrow(/--pg-conn is required when backend is postgres/) + } finally { + if (prev === undefined) delete process.env.SIMPLEX_BACKEND + else process.env.SIMPLEX_BACKEND = prev + } + }) + + test("postgres backend with --pg-conn → db is postgres DbConfig", () => { + const prev = process.env.SIMPLEX_BACKEND + process.env.SIMPLEX_BACKEND = "postgres" + try { + const cfg = parseConfig([...baseArgs, "--pg-conn", "postgres://user:pass@localhost/db"]) + expect(cfg.db).toEqual({type: "postgres", connectionString: "postgres://user:pass@localhost/db"}) + } finally { + if (prev === undefined) delete process.env.SIMPLEX_BACKEND + else process.env.SIMPLEX_BACKEND = prev + } + }) + + test("postgres backend with --pg-schema → DbConfig carries schemaPrefix", () => { + const prev = process.env.SIMPLEX_BACKEND + process.env.SIMPLEX_BACKEND = "postgres" + try { + const cfg = parseConfig([...baseArgs, "--pg-conn", "postgres://localhost/db", "--pg-schema", "bot"]) + expect(cfg.db).toEqual({type: "postgres", connectionString: "postgres://localhost/db", schemaPrefix: "bot"}) + } finally { + if (prev === undefined) delete process.env.SIMPLEX_BACKEND + else process.env.SIMPLEX_BACKEND = prev + } + }) + + test("sqlite backend (default) → db is sqlite DbConfig with default filePrefix", () => { + const prevBackend = process.env.SIMPLEX_BACKEND + const prevNpm = process.env.npm_config_simplex_backend + delete process.env.SIMPLEX_BACKEND + delete process.env.npm_config_simplex_backend + try { + const cfg = parseConfig(baseArgs) + expect(cfg.db).toEqual({type: "sqlite", filePrefix: "./data/simplex"}) + } finally { + if (prevBackend !== undefined) process.env.SIMPLEX_BACKEND = prevBackend + if (prevNpm !== undefined) process.env.npm_config_simplex_backend = prevNpm + } + }) + + test("sqlite backend with --sqlite-key → DbConfig carries encryptionKey", () => { + const cfg = parseConfig([...baseArgs, "--sqlite-key", "secret"]) + expect(cfg.db).toEqual({type: "sqlite", filePrefix: "./data/simplex", encryptionKey: "secret"}) + }) + + test("unknown flag → parseArgs throws", () => { + expect(() => parseConfig([...baseArgs, "--team-gropu", "typo"])) + .toThrow() + }) + + test("missing --team-group → throws", () => { + expect(() => parseConfig([])) + .toThrow(/Missing required argument: --team-group/) + }) + + test("invalid SIMPLEX_BACKEND → throws", () => { + const prev = process.env.SIMPLEX_BACKEND + process.env.SIMPLEX_BACKEND = "mysql" + try { + expect(() => parseConfig(baseArgs)) + .toThrow(/Invalid SIMPLEX_BACKEND: "mysql"/) + } finally { + if (prev === undefined) delete process.env.SIMPLEX_BACKEND + else process.env.SIMPLEX_BACKEND = prev + } + }) + test("--complete-hours negative → throws", () => { - expect(() => parseConfig([...baseArgs, "--complete-hours", "-1"])) + // parseArgs refuses "-1" as a bare arg (ambiguous with a short flag), so use `=` form + expect(() => parseConfig([...baseArgs, "--complete-hours=-1"])) .toThrow(/--complete-hours must be a non-negative integer, got "-1"/) }) diff --git a/apps/simplex-support-bot/package-lock.json b/apps/simplex-support-bot/package-lock.json index f238a4e17f..94dbd5171a 100644 --- a/apps/simplex-support-bot/package-lock.json +++ b/apps/simplex-support-bot/package-lock.json @@ -19,6 +19,35 @@ "vitest": "^1.6.1" } }, + "../../packages/simplex-chat-client/types/typescript": { + "name": "@simplex-chat/types", + "version": "0.5.0", + "license": "AGPL-3.0", + "dependencies": { + "typescript": "^5.9.2" + } + }, + "../../packages/simplex-chat-nodejs": { + "name": "simplex-chat", + "version": "6.5.0-beta.10", + "hasInstallScript": true, + "license": "AGPL-3.0", + "dependencies": { + "@simplex-chat/types": "^0.5.0", + "extract-zip": "^2.0.1", + "fast-deep-equal": "^3.1.3", + "node-addon-api": "^8.5.0" + }, + "devDependencies": { + "@types/jest": "^30.0.0", + "@types/node": "^25.0.5", + "jest": "^30.2.0", + "ts-jest": "^29.4.6", + "typedoc": "^0.28.15", + "typedoc-plugin-markdown": "^4.9.0", + "typescript": "^5.9.3" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", @@ -731,12 +760,8 @@ ] }, "node_modules/@simplex-chat/types": { - "version": "0.3.0", - "resolved": "file:../../packages/simplex-chat-client/types/typescript", - "license": "AGPL-3.0", - "dependencies": { - "typescript": "^5.9.2" - } + "resolved": "../../packages/simplex-chat-client/types/typescript", + "link": true }, "node_modules/@sinclair/typebox": { "version": "0.27.10", @@ -754,20 +779,11 @@ "version": "22.19.17", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.17.tgz", "integrity": "sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==", - "devOptional": true, + "dev": true, "dependencies": { "undici-types": "~6.21.0" } }, - "node_modules/@types/yauzl": { - "version": "2.10.3", - "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz", - "integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==", - "optional": true, - "dependencies": { - "@types/node": "*" - } - }, "node_modules/@vitest/expect": { "version": "1.6.1", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-1.6.1.tgz", @@ -890,14 +906,6 @@ "tslib": "^2.4.0" } }, - "node_modules/buffer-crc32": { - "version": "0.2.13", - "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", - "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", - "engines": { - "node": "*" - } - }, "node_modules/cac": { "version": "6.7.14", "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", @@ -961,6 +969,7 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, "dependencies": { "ms": "^2.1.3" }, @@ -994,14 +1003,6 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/end-of-stream": { - "version": "1.4.5", - "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", - "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", - "dependencies": { - "once": "^1.4.0" - } - }, "node_modules/esbuild": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", @@ -1084,38 +1085,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/extract-zip": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", - "integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==", - "dependencies": { - "debug": "^4.1.1", - "get-stream": "^5.1.0", - "yauzl": "^2.10.0" - }, - "bin": { - "extract-zip": "cli.js" - }, - "engines": { - "node": ">= 10.17.0" - }, - "optionalDependencies": { - "@types/yauzl": "^2.9.1" - } - }, - "node_modules/fast-deep-equal": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" - }, - "node_modules/fd-slicer": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", - "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", - "dependencies": { - "pend": "~1.2.0" - } - }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -1139,20 +1108,6 @@ "node": "*" } }, - "node_modules/get-stream": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", - "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", - "dependencies": { - "pump": "^3.0.0" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/human-signals": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-5.0.0.tgz", @@ -1259,7 +1214,8 @@ "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true }, "node_modules/nanoid": { "version": "3.3.11", @@ -1279,14 +1235,6 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, - "node_modules/node-addon-api": { - "version": "8.7.0", - "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.7.0.tgz", - "integrity": "sha512-9MdFxmkKaOYVTV+XVRG8ArDwwQ77XIgIPyKASB1k3JPq3M8fGQQQE3YpMOrKm6g//Ktx8ivZr8xo1Qmtqub+GA==", - "engines": { - "node": "^18 || ^20 || >= 21" - } - }, "node_modules/npm-run-path": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-5.3.0.tgz", @@ -1314,14 +1262,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/once": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "dependencies": { - "wrappy": "1" - } - }, "node_modules/onetime": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/onetime/-/onetime-6.0.0.tgz", @@ -1376,11 +1316,6 @@ "node": "*" } }, - "node_modules/pend": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", - "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==" - }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -1446,15 +1381,6 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/pump": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", - "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", - "dependencies": { - "end-of-stream": "^1.1.0", - "once": "^1.3.1" - } - }, "node_modules/react-is": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", @@ -1545,16 +1471,8 @@ } }, "node_modules/simplex-chat": { - "version": "6.5.0-beta.4.4", - "resolved": "file:../../packages/simplex-chat-nodejs", - "hasInstallScript": true, - "license": "AGPL-3.0", - "dependencies": { - "@simplex-chat/types": "^0.3.0", - "extract-zip": "^2.0.1", - "fast-deep-equal": "^3.1.3", - "node-addon-api": "^8.5.0" - } + "resolved": "../../packages/simplex-chat-nodejs", + "link": true }, "node_modules/source-map-js": { "version": "1.2.1", @@ -1643,6 +1561,7 @@ "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -1661,7 +1580,7 @@ "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "devOptional": true + "dev": true }, "node_modules/vite": { "version": "5.4.21", @@ -1840,20 +1759,6 @@ "node": ">=8" } }, - "node_modules/wrappy": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" - }, - "node_modules/yauzl": { - "version": "2.10.0", - "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", - "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", - "dependencies": { - "buffer-crc32": "~0.2.3", - "fd-slicer": "~1.1.0" - } - }, "node_modules/yocto-queue": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.2.tgz", diff --git a/apps/simplex-support-bot/src/config.ts b/apps/simplex-support-bot/src/config.ts index e4c845644e..c0ec6fbae0 100644 --- a/apps/simplex-support-bot/src/config.ts +++ b/apps/simplex-support-bot/src/config.ts @@ -1,10 +1,16 @@ +import {parseArgs} from "node:util" +import {api} from "simplex-chat" + export interface IdName { id: number name: string } +export type Backend = "sqlite" | "postgres" + export interface Config { - dbPrefix: string + stateFile: string // local path to the bot's state JSON + db: api.DbConfig // passed to ChatApi.init / bot.run teamGroup: IdName // name from CLI, id resolved at startup from state file teamMembers: IdName[] // optional, empty if not provided grokContactId: number | null // resolved at startup @@ -15,6 +21,18 @@ export interface Config { grokApiKey: string | null } +// Mirrors packages/simplex-chat-nodejs/src/download-libs.js so runtime detection +// matches what was used at install time. Works whether the user installed via +// SIMPLEX_BACKEND env var, .npmrc (→ npm_config_simplex_backend), or the +// --simplex_backend=postgres CLI flag (also surfaced as npm_config_*). +export function detectBackend(): Backend { + const raw = (process.env.SIMPLEX_BACKEND || process.env.npm_config_simplex_backend || "sqlite").toLowerCase() + if (raw !== "sqlite" && raw !== "postgres") { + throw new Error(`Invalid SIMPLEX_BACKEND: "${raw}". Must be "sqlite" or "postgres".`) + } + return raw +} + export function parseIdName(s: string): IdName { const i = s.indexOf(":") if (i < 1) throw new Error(`Invalid ID:name format: "${s}"`) @@ -23,18 +41,6 @@ export function parseIdName(s: string): IdName { return {id, name: s.slice(i + 1)} } -function requiredArg(args: string[], flag: string): string { - const i = args.indexOf(flag) - if (i < 0 || i + 1 >= args.length) throw new Error(`Missing required argument: ${flag}`) - return args[i + 1] -} - -function optionalArg(args: string[], flag: string, defaultValue: string): string { - const i = args.indexOf(flag) - if (i < 0 || i + 1 >= args.length) return defaultValue - return args[i + 1] -} - function parseNonNegativeInt(raw: string, flag: string): number { const n = parseInt(raw, 10) if (!Number.isFinite(n) || n < 0) { @@ -44,35 +50,73 @@ function parseNonNegativeInt(raw: string, flag: string): number { } export function parseConfig(args: string[]): Config { + const {values} = parseArgs({ + args, + strict: true, + options: { + "team-group": {type: "string"}, + "state-file": {type: "string", default: "./data/state.json"}, + "sqlite-file-prefix": {type: "string", default: "./data/simplex"}, + "sqlite-key": {type: "string"}, + "pg-conn": {type: "string"}, + "pg-schema": {type: "string"}, + "auto-add-team-members": {type: "string", short: "a"}, + "timezone": {type: "string", default: "UTC"}, + "complete-hours": {type: "string", default: "3"}, + "card-flush-seconds": {type: "string", default: "300"}, + "context-file": {type: "string"}, + }, + }) + // Treat empty string as absent so `GROK_API_KEY=` behaves like unset const grokApiKey = process.env.GROK_API_KEY || null - const dbPrefix = optionalArg(args, "--db-prefix", "./data/simplex") - const teamGroupName = requiredArg(args, "--team-group") + const backend = detectBackend() + let db: api.DbConfig + if (backend === "sqlite") { + // default guarantees non-undefined + const filePrefix = values["sqlite-file-prefix"]! + const encryptionKey = values["sqlite-key"] + db = encryptionKey + ? {type: "sqlite", filePrefix, encryptionKey} + : {type: "sqlite", filePrefix} + } else { + const connectionString = values["pg-conn"] + if (!connectionString) { + throw new Error("--pg-conn is required when backend is postgres (PostgreSQL connection string)") + } + const schemaPrefix = values["pg-schema"] + db = schemaPrefix + ? {type: "postgres", connectionString, schemaPrefix} + : {type: "postgres", connectionString} + } + + const teamGroupName = values["team-group"] + if (!teamGroupName) throw new Error("Missing required argument: --team-group") const teamGroup: IdName = {id: 0, name: teamGroupName} - const teamMembersRaw = optionalArg(args, "--auto-add-team-members", "") || optionalArg(args, "-a", "") + const teamMembersRaw = values["auto-add-team-members"] ?? "" const teamMembers = teamMembersRaw ? teamMembersRaw.split(",").map(parseIdName) : [] - const timezone = optionalArg(args, "--timezone", "UTC") + const timezone = values["timezone"]! try { new Intl.DateTimeFormat("en-US", {timeZone: timezone, weekday: "short"}) } catch (err) { throw new Error(`--timezone "${timezone}" is not a valid IANA time zone: ${(err as Error).message}`) } - const completeHours = parseNonNegativeInt(optionalArg(args, "--complete-hours", "3"), "--complete-hours") - const cardFlushSeconds = parseNonNegativeInt(optionalArg(args, "--card-flush-seconds", "300"), "--card-flush-seconds") - const contextFileRaw = optionalArg(args, "--context-file", "") - const contextFile = contextFileRaw || null + const completeHours = parseNonNegativeInt(values["complete-hours"]!, "--complete-hours") + const cardFlushSeconds = parseNonNegativeInt(values["card-flush-seconds"]!, "--card-flush-seconds") + const contextFile = values["context-file"] || null if (grokApiKey && !contextFile) { throw new Error("GROK_API_KEY is set but --context-file is not provided. Grok requires a context file.") } return { - dbPrefix, + stateFile: values["state-file"]!, + db, teamGroup, teamMembers, grokContactId: null, diff --git a/apps/simplex-support-bot/src/index.ts b/apps/simplex-support-bot/src/index.ts index f40d4e8ba7..0d0c667750 100644 --- a/apps/simplex-support-bot/src/index.ts +++ b/apps/simplex-support-bot/src/index.ts @@ -24,8 +24,10 @@ function writeState(path: string, state: BotState): void { async function main(): Promise { const config = parseConfig(process.argv.slice(2)) + // Do not log config.db.connectionString — typically contains credentials. log("Config parsed", { - dbPrefix: config.dbPrefix, + stateFile: config.stateFile, + backend: config.db.type, teamGroup: config.teamGroup, teamMembers: config.teamMembers, timezone: config.timezone, @@ -34,7 +36,7 @@ async function main(): Promise { const grokEnabled = config.grokApiKey !== null if (!grokEnabled) log("No GROK_API_KEY provided, disabling Grok support") - const stateFilePath = `${config.dbPrefix}_state.json` + const stateFilePath = config.stateFile const state = readState(stateFilePath) // Forward-reference for event handlers during init @@ -47,7 +49,7 @@ async function main(): Promise { // userId persisted in state.json on first resolution — comparing by // profile name is fragile to renames. if (state.grokUserId !== undefined) { - const preChat = await api.ChatApi.init({type: "sqlite", filePrefix: config.dbPrefix}) + const preChat = await api.ChatApi.init(config.db) try { const activeUser = await preChat.apiGetActiveUser() if (activeUser && activeUser.userId === state.grokUserId) { @@ -88,7 +90,7 @@ async function main(): Promise { log("Initializing main bot...") const [chat, mainUser, mainAddress] = await bot.run({ profile: {displayName: "Ask SimpleX Team", fullName: "", image: supportImage}, - dbOpts: {type: "sqlite", filePrefix: config.dbPrefix}, + dbOpts: config.db, options: { addressSettings: { businessAddress: true, diff --git a/apps/simplex-support-bot/start.sh b/apps/simplex-support-bot/start.sh index a04e4cbb8d..d127b30249 100755 --- a/apps/simplex-support-bot/start.sh +++ b/apps/simplex-support-bot/start.sh @@ -5,15 +5,22 @@ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" cd "$SCRIPT_DIR" # --- Required --- -# GROK_API_KEY xAI API key (env var) -# --team-group Team group display name +# GROK_API_KEY xAI API key (env var) +# --team-group Team group display name +# --pg-conn PostgreSQL connection string. Required only when +# simplex-chat-nodejs was installed with the postgres +# backend (SIMPLEX_BACKEND=postgres, .npmrc, or +# --simplex_backend=postgres at install time). # --- Optional --- -# --db-prefix Database file prefix (default: ./data/simplex) +# --state-file Path to the bot's state JSON (default: ./data/state.json) +# --sqlite-file-prefix SQLite DB file prefix (default: ./data/simplex) +# --sqlite-key SQLCipher encryption key (default: unencrypted) +# --pg-schema PostgreSQL schema prefix (default: simplex_v1) # --auto-add-team-members (-a) Comma-separated ID:name pairs (e.g. 1:Alice,2:Bob) -# --group-links Public group link(s) shown in welcome message -# --timezone IANA timezone for weekend detection (default: UTC) -# --complete-hours Hours of inactivity before auto-complete (default: 3) +# --group-links Public group link(s) shown in welcome message +# --timezone IANA timezone for weekend detection (default: UTC) +# --complete-hours Hours of inactivity before auto-complete (default: 3) if [ -z "${GROK_API_KEY:-}" ]; then echo "Error: GROK_API_KEY environment variable is required" >&2 @@ -25,4 +32,6 @@ if [ ! -f dist/index.js ]; then exit 1 fi -exec node dist/index.js "$@" +# Run via `npm start` so npm exposes `.npmrc` values (e.g. simplex_backend=postgres) +# as `npm_config_*` env vars to the bot. +exec npm --silent start -- "$@"