From 5a3dfdd2b440382868cf70e56057d18848c51e4c Mon Sep 17 00:00:00 2001 From: Narasimha-sc <166327228+Narasimha-sc@users.noreply.github.com> Date: Mon, 27 Apr 2026 08:12:42 +0000 Subject: [PATCH] SimpleX support bot (#6625) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * plans: 20260207-support-bot.md * Update 20260207-support-bot.md * plans: 20260207-support-bot-implementation.md * plans: Update 20260207-support-bot-implementation.md * Relocate plans * apps: support bot code & tests * apps: support bot relocate * support-bot: Fix basic functionality * apps: support-bot /add command & fixes * apps: simplex-support-bot: Change Grok logo * Further usability improvements * simplex-support-bot: Update support plan to reflect current flow * simplex-support-bot: update product design plan * support-bot: update plan * support-bot: review and refine product spec * support-bot: update product spec — complete state, /join team-only, card debouncing - Group preferences applied once at creation, not on every startup - /join restricted to team group only - Team/Grok reply or reaction auto-completes conversation (✅) - Customer message reverts to incomplete - Card updates debounced globally with 15-minute batch flush * support-bot: update implementation plan * support-bot: implement stateless bot with cards, Grok, team flow, hardening Complete rewrite of the support bot to stateless architecture: - State derived from group composition + chat history (survives restarts) - Card dashboard in team group with live status, preview, /join commands - Two-profile architecture (main + Grok) with profileMutex serialization - Grok join race condition fix via bufferedGrokInvitations - Card preview: newest-first truncation, newline sanitization, sender prefixes - Best-effort startup (invite link, group profile update) - Team group preferences: directMessages, fullDelete, commands - 122 tests across 27 suites * support-bot: use apiCreateMemberContact and apiSendMemberContactInvitation instead of raw commands Replace sendChatCmd("/_create member contact ...") and sendChatCmd("/_invite member contact ...") with the typed API methods added in simplex-chat-nodejs. Update plans and build script accordingly. * plans: 20260207-support-bot.md * Update 20260207-support-bot.md * plans: 20260207-support-bot-implementation.md * plans: Update 20260207-support-bot-implementation.md * Relocate plans * apps: support bot code & tests * apps: support bot relocate * support-bot: Fix basic functionality * apps: support-bot /add command & fixes * apps: simplex-support-bot: Change Grok logo * Further usability improvements * simplex-support-bot: Update support plan to reflect current flow * simplex-support-bot: update product design plan * support-bot: update plan * support-bot: review and refine product spec * support-bot: update product spec — complete state, /join team-only, card debouncing - Group preferences applied once at creation, not on every startup - /join restricted to team group only - Team/Grok reply or reaction auto-completes conversation (✅) - Customer message reverts to incomplete - Card updates debounced globally with 15-minute batch flush * support-bot: update implementation plan * support-bot: implement stateless bot with cards, Grok, team flow, hardening Complete rewrite of the support bot to stateless architecture: - State derived from group composition + chat history (survives restarts) - Card dashboard in team group with live status, preview, /join commands - Two-profile architecture (main + Grok) with profileMutex serialization - Grok join race condition fix via bufferedGrokInvitations - Card preview: newest-first truncation, newline sanitization, sender prefixes - Best-effort startup (invite link, group profile update) - Team group preferences: directMessages, fullDelete, commands - 122 tests across 27 suites * support-bot: use apiCreateMemberContact and apiSendMemberContactInvitation instead of raw commands Replace sendChatCmd("/_create member contact ...") and sendChatCmd("/_invite member contact ...") with the typed API methods added in simplex-chat-nodejs. Update plans and build script accordingly. * support-bot: more improvemets * support-bot: add tests for Grok batch dedup and initial response gating 7 new tests covering the duplicate Grok reply fix: - batch dedup: only last customer message per group triggers API call - batch dedup: multi-group batches handled independently - batch dedup: non-customer messages filtered from batch - initial response gating: per-message responses suppressed during activateGrok - gating clears: per-message responses resume after activation completes Update implementation plan test catalog (122 → 129 tests). * support-bot: load context from context file * Rename Grok AI -> Grok * Remove unused strings.ts * support-bot: change messages * cardFlushMinutes 15 -> cardFlushSeconds 300 * support-bot: /team message when grok present * support-bot: correct messages * support-bot: update plans to reflect latest changes * Update plan for state derivation * support-bot: Update state machine plans * support-bot: implement customData state * Fix Grok revertStateOnFail race condition * support-bot: plans adversarial review * support-bot: /join ID part of card in plan * support-bot: implement /join ID inside card * support-bot: plans use params instead of regex in /join * support-bot: Implement adversarial review changes * support-bot: no re-invite if already invited * support-bot: /team should give owner to invited member * Don't change username for existing database * support-bot: update bot commands before sending commands * support-bot: adversarial review fixes * support-bot: implement postgresql (#6876) * support-bot: sqlite/postgres backend via typed DbConfig and parseArgs flags * support-bot: add README with setup and flags reference * support-bot: use published simplex-chat, drop build.sh/start.sh * support-bot: switch CLI to commander, add --help * support-bot: update README --------- Co-authored-by: shum Co-authored-by: sh <37271604+shumvgolove@users.noreply.github.com> --- apps/simplex-support-bot/README.md | 101 + apps/simplex-support-bot/bot.test.ts | 2481 +++++++++++++++++ apps/simplex-support-bot/package-lock.json | 2061 ++++++++++++++ apps/simplex-support-bot/package.json | 23 + .../20260207-support-bot-implementation.md | 1471 ++++++++++ .../plans/20260207-support-bot.md | 513 ++++ apps/simplex-support-bot/src/bot.ts | 920 ++++++ apps/simplex-support-bot/src/cards.ts | 473 ++++ apps/simplex-support-bot/src/config.ts | 144 + apps/simplex-support-bot/src/grok.ts | 55 + apps/simplex-support-bot/src/index.ts | 372 +++ apps/simplex-support-bot/src/messages.ts | 43 + apps/simplex-support-bot/src/util.ts | 22 + .../test/__mocks__/simplex-chat-types.js | 12 + .../test/__mocks__/simplex-chat.js | 26 + apps/simplex-support-bot/tsconfig.json | 23 + apps/simplex-support-bot/vitest.config.ts | 22 + 17 files changed, 8762 insertions(+) create mode 100644 apps/simplex-support-bot/README.md create mode 100644 apps/simplex-support-bot/bot.test.ts create mode 100644 apps/simplex-support-bot/package-lock.json create mode 100644 apps/simplex-support-bot/package.json create mode 100644 apps/simplex-support-bot/plans/20260207-support-bot-implementation.md create mode 100644 apps/simplex-support-bot/plans/20260207-support-bot.md create mode 100644 apps/simplex-support-bot/src/bot.ts create mode 100644 apps/simplex-support-bot/src/cards.ts create mode 100644 apps/simplex-support-bot/src/config.ts create mode 100644 apps/simplex-support-bot/src/grok.ts create mode 100644 apps/simplex-support-bot/src/index.ts create mode 100644 apps/simplex-support-bot/src/messages.ts create mode 100644 apps/simplex-support-bot/src/util.ts create mode 100644 apps/simplex-support-bot/test/__mocks__/simplex-chat-types.js create mode 100644 apps/simplex-support-bot/test/__mocks__/simplex-chat.js create mode 100644 apps/simplex-support-bot/tsconfig.json create mode 100644 apps/simplex-support-bot/vitest.config.ts diff --git a/apps/simplex-support-bot/README.md b/apps/simplex-support-bot/README.md new file mode 100644 index 0000000000..19b9ab8bd6 --- /dev/null +++ b/apps/simplex-support-bot/README.md @@ -0,0 +1,101 @@ +# 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 or newer (v24 tested) +- `GROK_API_KEY` env var (xAI) — optional; the bot runs without it +- For the PostgreSQL backend: Linux x86_64, `libpq5` installed on the host, and a reachable PostgreSQL server + +## Install & build + +```bash +cd apps/simplex-support-bot +npm install # downloads native libs + transitive deps +npm run build # tsc +``` + +By default this installs the **SQLite** backend. + +To use **PostgreSQL** instead, drop a `.npmrc` next to `package.json` *before* `npm install`: + +```bash +echo 'simplex_backend=postgres' > .npmrc +npm install # now pulls postgres-flavored native libs +npm run build +``` + +`.npmrc` lives next to the package — npm reads it natively, no extra setup. + +### Switching backends + +`npm install` is a no-op for already-installed deps, so editing `.npmrc` and re-running `npm install` will *not* re-trigger `simplex-chat`'s preinstall. To switch backends, force a clean install: + +```bash +rm -rf node_modules +npm install # download-libs.js re-runs and pulls the right native lib +``` + +## Run + +```bash +mkdir -p data # state file lives here by default + +# SQLite (default) +npm start -- --team-group "Support Team" + +# PostgreSQL +npm start -- --team-group "Support Team" \ + --pg-conn "postgres://user:pass@host/db" +``` + +The bot runs via `npm start` so npm can expose `.npmrc` settings to the process — `detectBackend()` reads `npm_config_simplex_backend` to know which backend was installed. + +## Flags + +Run `npm start -- --help` for the auto-generated reference. Summary: + +| Flag | Backend | Required | Default | Description | +|---|---|---|---|---| +| `--team-group` | both | 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` | both | no | | comma-separated `ID:name` pairs (e.g. `1:Alice,2:Bob`) | +| `--timezone` | both | no | `UTC` | IANA zone for weekend detection | +| `--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 | +| `-h` / `--help` | both | no | | show usage and exit | + +## Environment variables + +| Var | Purpose | +|---|---| +| `GROK_API_KEY` | xAI API key; enables Grok replies | +| `SIMPLEX_BACKEND` | alternative to `.npmrc` for selecting the install backend (`sqlite` or `postgres`) | + +## Local development against unreleased lib changes + +This package depends on `simplex-chat` from npm. To test against an in-tree version: + +```bash +# In packages/simplex-chat-nodejs +npm link + +# In apps/simplex-support-bot +npm link simplex-chat +``` + +`npm unlink simplex-chat && npm install` reverts to the registry version. + +## Troubleshooting + +- **`--pg-conn is required when backend is postgres`** — the postgres backend is installed but you didn't pass a connection string. +- **`libpq5` errors at startup** — install `libpq5` on the host (`apt install libpq5` on Debian/Ubuntu). +- **`ENOENT: no such file or directory, open './data/state.json'`** — the parent directory of `--state-file` must exist; `mkdir -p data` before starting. +- **Wrong backend installed** — check `node_modules/simplex-chat/libs/installed.txt`. Edit `.npmrc`, then `rm -rf node_modules && npm install` to switch (`npm install` alone won't re-run the dep's preinstall). +- **`libpq` connection error** at startup with sqlite-flavored config (or vice versa) — `.npmrc` was changed but libs weren't reinstalled. See "Switching backends" above. diff --git a/apps/simplex-support-bot/bot.test.ts b/apps/simplex-support-bot/bot.test.ts new file mode 100644 index 0000000000..d390835f46 --- /dev/null +++ b/apps/simplex-support-bot/bot.test.ts @@ -0,0 +1,2481 @@ +import {describe, test, expect, beforeEach, vi} from "vitest" +import {SupportBot} from "./src/bot.js" +import {CardManager} from "./src/cards.js" +import {parseConfig} from "./src/config.js" +import {GrokApiClient} from "./src/grok.js" +import {welcomeMessage, queueMessage, grokActivatedMessage, teamLockedMessage} from "./src/messages.js" + +// Silence console output during tests +vi.spyOn(console, "log").mockImplementation(() => {}) +vi.spyOn(console, "error").mockImplementation(() => {}) + +// ─── Type stubs ─── + +const ChatType = {Direct: "direct" as const, Group: "group" as const, Local: "local" as const} +const GroupMemberRole = {Member: "member" as const, Owner: "owner" as const, Admin: "admin" as const} +const GroupMemberStatus = {Connected: "connected" as const, Complete: "complete" as const, Announced: "announced" as const, Left: "left" as const} +const GroupFeatureEnabled = {On: "on" as const, Off: "off" as const} +const CIDeleteMode = {Broadcast: "broadcast" as const} + +// ─── Mock infrastructure ─── + +let nextItemId = 1000 + +class MockChatApi { + sent: {chat: [string, number]; text: string}[] = [] + added: {groupId: number; contactId: number; role: string}[] = [] + removed: {groupId: number; memberIds: number[]}[] = [] + joined: number[] = [] + deleted: {chatType: string; chatId: number; itemIds: number[]; mode: string}[] = [] + customData = new Map() + roleChanges: {groupId: number; memberIds: number[]; role: string}[] = [] + profileUpdates: {groupId: number; profile: any}[] = [] + + members = new Map() + chatItems = new Map() + groups = new Map() + activeUserId = 1 + + private _addMemberFails = false + private _addMemberError: any = null + private _deleteChatItemsFails = false + + apiAddMemberWillFail(err?: any) { this._addMemberFails = true; this._addMemberError = err } + apiDeleteChatItemsWillFail() { this._deleteChatItemsFails = true } + + async apiSetActiveUser(userId: number) { this.activeUserId = userId; return {userId, profile: {displayName: "test"}} } + async apiSendMessages(chatRef: any, messages: any[]) { + // Normalize chat ref: accept both [type, id] tuples and {chatType, chatId} objects + const chat: [string, number] = Array.isArray(chatRef) + ? chatRef + : [chatRef.chatType, chatRef.chatId] + return messages.map(msg => { + const text = msg.msgContent?.text || "" + this.sent.push({chat, text}) + const itemId = nextItemId++ + return {chatItem: {meta: {itemId}, chatDir: {type: "groupSnd"}, content: {type: "sndMsgContent", msgContent: {type: "text", text}}}} + }) + } + async apiSendTextMessage(chat: [string, number], text: string) { + return this.apiSendMessages(chat, [{msgContent: {type: "text", text}, mentions: {}}]) + } + async apiAddMember(groupId: number, contactId: number, role: string) { + if (this._addMemberFails) { + this._addMemberFails = false + throw this._addMemberError || new Error("apiAddMember failed") + } + this.added.push({groupId, contactId, role}) + const memberId = `member-${contactId}` + const groupMemberId = 5000 + contactId + return {memberId, groupMemberId, memberContactId: contactId, memberStatus: GroupMemberStatus.Connected, memberProfile: {displayName: `Contact${contactId}`}} + } + async apiRemoveMembers(groupId: number, memberIds: number[]) { + this.removed.push({groupId, memberIds}) + return memberIds.map(id => ({groupMemberId: id})) + } + async apiJoinGroup(groupId: number) { + this.joined.push(groupId) + return {groupId} + } + async apiSetMembersRole(groupId: number, memberIds: number[], role: string) { + this.roleChanges.push({groupId, memberIds, role}) + } + async apiListMembers(groupId: number) { + return this.members.get(groupId) || [] + } + async apiGetChat(_chatType: string, chatId: number, _count: number) { + const items = this.chatItems.get(chatId) || [] + const groupInfo = this.groups.get(chatId) + return { + chatInfo: {type: "group", groupInfo: groupInfo || makeGroupInfo(chatId)}, + chatItems: items, + chatStats: {unreadCount: 0, unreadMentions: 0, reportsCount: 0, minUnreadItemId: 0, unreadChat: false}, + } + } + async apiListGroups(_userId: number) { + return [...this.groups.values()].map(g => ({...g, customData: this.customData.get(g.groupId)})) + } + async apiSetGroupCustomData(groupId: number, data?: any) { + if (data === undefined) this.customData.delete(groupId) + else this.customData.set(groupId, data) + } + async apiDeleteChatItems(chatType: string, chatId: number, itemIds: number[], mode: string) { + if (this._deleteChatItemsFails) { + this._deleteChatItemsFails = false + throw new Error("apiDeleteChatItems failed") + } + this.deleted.push({chatType, chatId, itemIds, mode}) + return [] + } + async apiUpdateGroupProfile(groupId: number, profile: any) { + this.profileUpdates.push({groupId, profile}) + return this.groups.get(groupId) || makeGroupInfo(groupId) + } + + memberContacts: {groupId: number; groupMemberId: number; contactId: number}[] = [] + memberContactInvitations: {contactId: number; text: string}[] = [] + + async apiCreateMemberContact(groupId: number, groupMemberId: number): Promise { + const contactId = nextItemId++ + this.memberContacts.push({groupId, groupMemberId, contactId}) + return {contactId, profile: {displayName: "member"}} + } + async apiSendMemberContactInvitation(contactId: number, message?: any): Promise { + const text = typeof message === "string" ? message : (message?.text ?? "") + this.memberContactInvitations.push({contactId, text}) + this.sent.push({chat: [ChatType.Direct, contactId], text}) + return {contactId, profile: {displayName: "member"}} + } + + rawCmds: string[] = [] + async sendChatCmd(cmd: string) { + this.rawCmds.push(cmd) + return {type: "cmdOk"} + } + + sentTo(groupId: number): string[] { + return this.sent.filter(s => s.chat[0] === ChatType.Group && s.chat[1] === groupId).map(s => s.text) + } + lastSentTo(groupId: number): string | undefined { + const msgs = this.sentTo(groupId) + return msgs[msgs.length - 1] + } + sentDirect(contactId: number): string[] { + return this.sent.filter(s => s.chat[0] === ChatType.Direct && s.chat[1] === contactId).map(s => s.text) + } +} + +class MockGrokApi { + calls: {history: any[]; message: string}[] = [] + private _response = "Grok answer" + private _willFail = false + private _gate: {promise: Promise; release: () => void} | null = null + + willRespond(text: string) { this._response = text; this._willFail = false } + willFail() { this._willFail = true } + + // Block every subsequent chat() call until releaseChat() is invoked. Used to + // observe in-flight concurrency without relying on wall-clock timing. + blockChat() { + let release!: () => void + const promise = new Promise(r => { release = r }) + this._gate = {promise, release} + } + releaseChat() { + this._gate?.release() + this._gate = null + } + + async chat(history: any[], userMessage: string): Promise { + this.calls.push({history, message: userMessage}) + if (this._gate) await this._gate.promise + if (this._willFail) { this._willFail = false; throw new Error("Grok API error") } + return this._response + } +} + +// ─── Factory helpers ─── + +const MAIN_USER_ID = 1 +const GROK_USER_ID = 2 +const TEAM_GROUP_ID = 50 +const CUSTOMER_GROUP_ID = 100 +const GROK_CONTACT_ID = 10 +const TEAM_MEMBER_1_ID = 20 +const TEAM_MEMBER_2_ID = 21 +const GROK_LOCAL_GROUP_ID = 200 +const CUSTOMER_ID = "customer-1" + +// Commands passed into SupportBot; matches what index.ts constructs when +// Grok is enabled. Tests that disable grokApi still pass the full list +// because the ctor doesn't care; the value is pushed to a group's +// groupPreferences on the first sendToGroup() call. +const DESIRED_COMMANDS = [ + {type: "command" as const, keyword: "grok", label: "Ask Grok"}, + {type: "command" as const, keyword: "team", label: "Switch to team"}, +] + +// ─── Member factories ─── + +function makeTeamMember(contactId: number, name = `Contact${contactId}`, groupMemberId?: number) { + return { + memberId: `team-${contactId}`, + groupMemberId: groupMemberId ?? 5000 + contactId, + memberContactId: contactId, + memberStatus: GroupMemberStatus.Connected, + memberProfile: {displayName: name}, + } +} + +function makeGrokMember(groupMemberId = 7777) { + return { + memberId: "grok-member", + groupMemberId, + memberContactId: GROK_CONTACT_ID, + memberStatus: GroupMemberStatus.Connected, + memberProfile: {displayName: "Grok"}, + } +} + +function makeCustomerMember(status = GroupMemberStatus.Connected) { + return { + memberId: CUSTOMER_ID, + groupMemberId: 3000, + memberStatus: status, + memberProfile: {displayName: "Customer"}, + } +} + +function makeConfig(overrides: Partial = {}) { + return { + 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"}, + {id: TEAM_MEMBER_2_ID, name: "Bob"}, + ], + groupLinks: "", + timezone: "UTC", + completeHours: 3, + cardFlushSeconds: 300, + grokApiKey: "test-key", + grokContactId: GROK_CONTACT_ID as number | null, + ...overrides, + } +} + +function makeGroupInfo(groupId: number, opts: Partial = {}): any { + return { + groupId, + groupProfile: {displayName: opts.displayName || `Group${groupId}`, fullName: ""}, + businessChat: opts.businessChat !== undefined ? opts.businessChat : { + chatType: "business", + businessId: "bot-1", + customerId: opts.customerId || CUSTOMER_ID, + }, + membership: {memberId: "bot-member"}, + customData: opts.customData, + chatSettings: {enableNtfs: "all", favorite: false}, + fullGroupPreferences: {}, + localDisplayName: `group-${groupId}`, + localAlias: "", + useRelays: false, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + chatTags: [], + groupSummary: {}, + membersRequireAttention: 0, + } +} + +function makeUser(userId: number) { + return {userId, profile: {displayName: userId === MAIN_USER_ID ? "Ask SimpleX Team" : "Grok"}} +} + +function makeChatItem(opts: { + dir: "groupSnd" | "groupRcv" | "directRcv" + text?: string + memberId?: string + memberContactId?: number + memberDisplayName?: string + msgType?: string + groupId?: number +}): any { + const itemId = nextItemId++ + const now = new Date().toISOString() + const msgContent = opts.msgType + ? {type: opts.msgType, text: opts.text || ""} + : {type: "text", text: opts.text || ""} + + let chatDir: any + if (opts.dir === "groupSnd") { + chatDir = {type: "groupSnd"} + } else if (opts.dir === "groupRcv") { + chatDir = { + type: "groupRcv", + groupMember: { + memberId: opts.memberId || CUSTOMER_ID, + groupMemberId: 3000, + memberContactId: opts.memberContactId, + memberStatus: GroupMemberStatus.Connected, + memberProfile: {displayName: opts.memberDisplayName || "Customer"}, + }, + } + } else { + chatDir = {type: "directRcv"} + } + + return { + chatDir, + meta: {itemId, itemTs: now, createdAt: now, itemText: opts.text || "", itemStatus: {type: "sndSent"}, itemEdited: false}, + content: {type: opts.dir === "groupSnd" ? "sndMsgContent" : "rcvMsgContent", msgContent}, + mentions: {}, + reactions: [], + } +} + +function makeAChatItem(chatItem: any, groupId = CUSTOMER_GROUP_ID): any { + return { + chatInfo: {type: "group", groupInfo: makeGroupInfo(groupId)}, + chatItem, + } +} + +function makeDirectAChatItem(chatItem: any, contactId: number): any { + return { + chatInfo: {type: "direct", contact: {contactId, profile: {displayName: "Someone"}}}, + chatItem, + } +} + +// ─── Shared test state ─── + +let chat: MockChatApi +let grokApi: MockGrokApi +let config: ReturnType +let bot: InstanceType +let cards: InstanceType + +// ─── Setup and helpers ─── + +function setup(configOverrides: Partial = {}) { + nextItemId = 1000 + chat = new MockChatApi() + grokApi = new MockGrokApi() + config = makeConfig(configOverrides) + + // Register team group and customer group in mock + const teamGroupInfo = makeGroupInfo(TEAM_GROUP_ID, {businessChat: null, displayName: "SupportTeam"}) + chat.groups.set(TEAM_GROUP_ID, teamGroupInfo) + chat.groups.set(CUSTOMER_GROUP_ID, makeGroupInfo(CUSTOMER_GROUP_ID)) + + cards = new CardManager(chat as any, config as any, MAIN_USER_ID, 999999999) + bot = new SupportBot(chat as any, grokApi as any, config as any, MAIN_USER_ID, GROK_USER_ID, DESIRED_COMMANDS) + // Replace cards with our constructed one that has a long flush interval + bot.cards = cards +} + +function customerMessage(text: string, groupId = CUSTOMER_GROUP_ID): any { + const ci = makeChatItem({dir: "groupRcv", text, memberId: CUSTOMER_ID}) + return { + type: "newChatItems" as const, + user: makeUser(MAIN_USER_ID), + chatItems: [makeAChatItem(ci, groupId)], + } +} + +function customerNonTextMessage(groupId = CUSTOMER_GROUP_ID): any { + const ci = makeChatItem({dir: "groupRcv", text: "", memberId: CUSTOMER_ID, msgType: "image"}) + return { + type: "newChatItems" as const, + user: makeUser(MAIN_USER_ID), + chatItems: [makeAChatItem(ci, groupId)], + } +} + +function teamMemberMessage(text: string, contactId = TEAM_MEMBER_1_ID, groupId = CUSTOMER_GROUP_ID): any { + const ci = makeChatItem({dir: "groupRcv", text, memberId: `team-${contactId}`, memberContactId: contactId, memberDisplayName: "Alice"}) + return { + type: "newChatItems" as const, + user: makeUser(MAIN_USER_ID), + chatItems: [makeAChatItem(ci, groupId)], + } +} + +function grokResponseMessage(text: string, groupId = CUSTOMER_GROUP_ID): any { + const ci = makeChatItem({dir: "groupRcv", text, memberId: "grok-member", memberContactId: GROK_CONTACT_ID, memberDisplayName: "Grok"}) + return { + type: "newChatItems" as const, + user: makeUser(MAIN_USER_ID), + chatItems: [makeAChatItem(ci, groupId)], + } +} + +function directMessage(text: string, contactId: number): any { + const ci = makeChatItem({dir: "directRcv", text}) + return { + type: "newChatItems" as const, + user: makeUser(MAIN_USER_ID), + chatItems: [makeDirectAChatItem(ci, contactId)], + } +} + +function teamGroupMessage(text: string, senderContactId = TEAM_MEMBER_1_ID): any { + const ci = makeChatItem({dir: "groupRcv", text, memberId: `team-${senderContactId}`, memberContactId: senderContactId, memberDisplayName: "Alice"}) + return { + type: "newChatItems" as const, + user: makeUser(MAIN_USER_ID), + chatItems: [{chatInfo: {type: "group", groupInfo: makeGroupInfo(TEAM_GROUP_ID, {businessChat: null})}, chatItem: ci}], + } +} + +// Simulate bot sending a message to the customer group (adds it to chatItems history) +function addBotMessage(text: string, groupId = CUSTOMER_GROUP_ID) { + const ci = makeChatItem({dir: "groupSnd", text}) + const items = chat.chatItems.get(groupId) || [] + items.push(ci) + chat.chatItems.set(groupId, items) +} + +function addCustomerMessageToHistory(text: string, groupId = CUSTOMER_GROUP_ID) { + const ci = makeChatItem({dir: "groupRcv", text, memberId: CUSTOMER_ID}) + const items = chat.chatItems.get(groupId) || [] + items.push(ci) + chat.chatItems.set(groupId, items) +} + +function addTeamMemberMessageToHistory(text: string, contactId = TEAM_MEMBER_1_ID, groupId = CUSTOMER_GROUP_ID) { + const ci = makeChatItem({dir: "groupRcv", text, memberId: `team-${contactId}`, memberContactId: contactId}) + const items = chat.chatItems.get(groupId) || [] + items.push(ci) + chat.chatItems.set(groupId, items) +} + +function addGrokMessageToHistory(text: string, groupId = CUSTOMER_GROUP_ID) { + const ci = makeChatItem({dir: "groupRcv", text, memberId: "grok-member", memberContactId: GROK_CONTACT_ID}) + const items = chat.chatItems.get(groupId) || [] + items.push(ci) + chat.chatItems.set(groupId, items) +} + +// State helpers — reach specific states +async function reachQueue(groupId = CUSTOMER_GROUP_ID) { + await bot.onNewChatItems(customerMessage("Hello, I need help", groupId)) + // This should have sent queue message + created card +} + +async function reachGrok(groupId = CUSTOMER_GROUP_ID) { + await reachQueue(groupId) + // Add the queue message to history so state derivation sees it + addBotMessage("The team will reply to your message", groupId) + + // Send /grok command. This triggers activateGrok which needs the join flow. + // We need to simulate Grok join success. + const grokJoinPromise = simulateGrokJoinSuccess(groupId) + await bot.onNewChatItems(customerMessage("/grok", groupId)) + await grokJoinPromise +} + +async function simulateGrokJoinSuccess(mainGroupId = CUSTOMER_GROUP_ID) { + // Wait for apiAddMember to be called, then simulate Grok invitation + join + await new Promise(r => setTimeout(r, 10)) + // Find the pending grok join via the added members + const addedGrok = chat.added.find(a => a.contactId === GROK_CONTACT_ID && a.groupId === mainGroupId) + if (!addedGrok) return + + // Simulate Grok receivedGroupInvitation + const memberId = `member-${GROK_CONTACT_ID}` + await bot.onGrokGroupInvitation({ + type: "receivedGroupInvitation", + user: makeUser(GROK_USER_ID), + groupInfo: {...makeGroupInfo(GROK_LOCAL_GROUP_ID), membership: {memberId}}, + contact: {contactId: 99}, + fromMemberRole: GroupMemberRole.Admin, + memberRole: GroupMemberRole.Member, + }) + + // Simulate Grok connectedToGroupMember + await bot.onGrokMemberConnected({ + type: "connectedToGroupMember", + user: makeUser(GROK_USER_ID), + groupInfo: makeGroupInfo(GROK_LOCAL_GROUP_ID), + member: {memberId: "bot-in-grok-view", groupMemberId: 9999, memberContactId: undefined}, + }) +} + +async function reachTeamPending(groupId = CUSTOMER_GROUP_ID) { + await reachQueue(groupId) + addBotMessage("The team will reply to your message", groupId) + await bot.onNewChatItems(customerMessage("/team", groupId)) +} + +async function reachTeam(groupId = CUSTOMER_GROUP_ID) { + await reachTeamPending(groupId) + addBotMessage("We will reply within 24 hours.", groupId) + chat.members.set(groupId, [makeTeamMember(TEAM_MEMBER_1_ID, "Alice")]) + // Team member sends a text message (triggers one-way gate) + addTeamMemberMessageToHistory("Hi, how can I help?", TEAM_MEMBER_1_ID, groupId) + await bot.onNewChatItems(teamMemberMessage("Hi, how can I help?", TEAM_MEMBER_1_ID, groupId)) +} + +// ─── Assertion helpers ─── + +function expectSentToGroup(groupId: number, substring: string) { + const msgs = chat.sentTo(groupId) + expect(msgs.some(m => m.includes(substring)), + `Expected message containing "${substring}" sent to group ${groupId}, got:\n${msgs.join("\n")}` + ).toBe(true) +} + +function expectNotSentToGroup(groupId: number, substring: string) { + expect(chat.sentTo(groupId).every(m => !m.includes(substring))).toBe(true) +} + +function expectDmSent(contactId: number, substring: string) { + expect(chat.sentDirect(contactId).some(m => m.includes(substring))).toBe(true) +} + +function expectAnySent(substring: string) { + expect(chat.sent.some(s => s.text.includes(substring))).toBe(true) +} + +function expectMemberAdded(groupId: number, contactId: number) { + expect(chat.added.some(a => a.groupId === groupId && a.contactId === contactId)).toBe(true) +} + +function expectCardDeleted(cardItemId: number) { + expect(chat.deleted.some(d => d.itemIds.includes(cardItemId))).toBe(true) +} + +// ─── Event factories ─── + +function connectedEvent(groupId: number, member: any, memberContact?: any) { + return { + type: "connectedToGroupMember" as const, + user: makeUser(MAIN_USER_ID), + groupInfo: makeGroupInfo(groupId, groupId === TEAM_GROUP_ID ? {businessChat: null} : {}), + member, + ...(memberContact !== undefined ? {memberContact} : {}), + } +} + +function leftEvent(groupId: number, member: any) { + return { + type: "leftMember" as const, + user: makeUser(MAIN_USER_ID), + groupInfo: makeGroupInfo(groupId, groupId === TEAM_GROUP_ID ? {businessChat: null} : {}), + member: {...member, memberStatus: GroupMemberStatus.Left}, + } +} + +function updatedEvent(groupId: number, chatItem: any, userId = MAIN_USER_ID) { + return { + type: "chatItemUpdated" as const, + user: makeUser(userId), + chatItem: { + chatInfo: {type: "group", groupInfo: makeGroupInfo(groupId, groupId === TEAM_GROUP_ID ? {businessChat: null} : {})}, + chatItem, + }, + } +} + +function reactionEvent(groupId: number, added: boolean) { + return { + type: "chatItemReaction" as const, + user: makeUser(MAIN_USER_ID), + added, + reaction: { + chatInfo: {type: "group", groupInfo: makeGroupInfo(groupId)}, + chatReaction: {reaction: {type: "emoji", emoji: "👍"}}, + }, + } +} + +function joinedEvent(groupId: number, member: any, userId = MAIN_USER_ID) { + return { + type: "joinedGroupMember" as const, + user: makeUser(userId), + groupInfo: makeGroupInfo(groupId, groupId === TEAM_GROUP_ID ? {businessChat: null} : {}), + member, + } +} + +function grokViewCustomerMessage(text: string, msgType?: string) { + chat.groups.set(GROK_LOCAL_GROUP_ID, makeGroupInfo(GROK_LOCAL_GROUP_ID)) + const ci = makeChatItem({dir: "groupRcv", text, memberId: CUSTOMER_ID, ...(msgType ? {msgType} : {})}) + return { + type: "newChatItems" as const, + user: makeUser(GROK_USER_ID), + chatItems: [{chatInfo: {type: "group", groupInfo: makeGroupInfo(GROK_LOCAL_GROUP_ID)}, chatItem: ci}], + } +} + +// ═══════════════════════════════════════════════════════════ +// Tests +// ═══════════════════════════════════════════════════════════ + +describe("Welcome & First Message", () => { + beforeEach(() => setup()) + + test("first message → queue reply sent, card created in team group", async () => { + await bot.onNewChatItems(customerMessage("Hello")) + expectSentToGroup(CUSTOMER_GROUP_ID, "The team will reply to your message") + const teamMsgs = chat.sentTo(TEAM_GROUP_ID) + expect(teamMsgs.length).toBeGreaterThan(0) + expect(teamMsgs[teamMsgs.length - 1]).toContain(`/'join ${CUSTOMER_GROUP_ID}'`) + }) + + test("non-text first message → no queue reply, no card", async () => { + await bot.onNewChatItems(customerNonTextMessage()) + expectNotSentToGroup(CUSTOMER_GROUP_ID, "The team will reply to your message") + expect(chat.sentTo(TEAM_GROUP_ID).length).toBe(0) + }) + + test("second message → no duplicate queue reply", async () => { + await bot.onNewChatItems(customerMessage("Hello")) + addBotMessage("The team will reply to your message") + const countBefore = chat.sentTo(CUSTOMER_GROUP_ID).filter(m => m.includes("The team will reply to your message")).length + await bot.onNewChatItems(customerMessage("Second message")) + const countAfter = chat.sentTo(CUSTOMER_GROUP_ID).filter(m => m.includes("The team will reply to your message")).length + expect(countAfter).toBe(countBefore) + }) + + test("unrecognized /command → treated as normal message", async () => { + await bot.onNewChatItems(customerMessage("/unknown")) + expectSentToGroup(CUSTOMER_GROUP_ID, "The team will reply to your message") + }) +}) + +describe("/grok Activation", () => { + beforeEach(() => setup()) + + test("/grok from QUEUE → Grok invited, grokActivatedMessage sent", async () => { + await reachQueue() + addBotMessage("The team will reply to your message") + const joinPromise = simulateGrokJoinSuccess() + await bot.onNewChatItems(customerMessage("/grok")) + await joinPromise + await bot.flush() + expectMemberAdded(CUSTOMER_GROUP_ID, GROK_CONTACT_ID) + expectSentToGroup(CUSTOMER_GROUP_ID, "You are chatting with Grok") + }) + + test("/grok as first message → WELCOME→GROK directly, no queue message", async () => { + const joinPromise = simulateGrokJoinSuccess() + await bot.onNewChatItems(customerMessage("/grok")) + await joinPromise + await bot.flush() + expectSentToGroup(CUSTOMER_GROUP_ID, "You are chatting with Grok") + expectNotSentToGroup(CUSTOMER_GROUP_ID, "The team will reply to your message") + expect(chat.sentTo(TEAM_GROUP_ID).length).toBeGreaterThan(0) + }) + + test("/grok in TEAM → rejected with teamLockedMessage", async () => { + await reachTeam() + await bot.onNewChatItems(customerMessage("/grok")) + expectSentToGroup(CUSTOMER_GROUP_ID, "team mode") + }) + + test("/grok when grokContactId is null → grokUnavailableMessage", async () => { + setup({grokContactId: null}) + await reachQueue() + addBotMessage("The team will reply to your message") + await bot.onNewChatItems(customerMessage("/grok")) + await bot.flush() + expectSentToGroup(CUSTOMER_GROUP_ID, "temporarily unavailable") + }) + + test("/grok as first message + Grok join fails → queue message sent as fallback", async () => { + chat.apiAddMemberWillFail() + await bot.onNewChatItems(customerMessage("/grok")) + await bot.flush() + expectSentToGroup(CUSTOMER_GROUP_ID, "temporarily unavailable") + expectSentToGroup(CUSTOMER_GROUP_ID, "The team will reply to your message") + }) +}) + +describe("Grok Conversation", () => { + beforeEach(() => setup()) + + test("Grok per-message: reads history, calls API, sends response", async () => { + addCustomerMessageToHistory("How do I create a group?", GROK_LOCAL_GROUP_ID) + grokApi.willRespond("To create a group, tap +, then New Group.") + await bot.onGrokNewChatItems(grokViewCustomerMessage("How do I create a group?")) + + expect(grokApi.calls.length).toBe(1) + expect(grokApi.calls[0].message).toBe("How do I create a group?") + expectAnySent("To create a group, tap +, then New Group.") + }) + + test("customer non-text in GROK → no Grok API call", async () => { + await bot.onGrokNewChatItems(grokViewCustomerMessage("", "image")) + expect(grokApi.calls.length).toBe(0) + }) + + test("Grok API error → error message in group, stays GROK", async () => { + grokApi.willFail() + await bot.onGrokNewChatItems(grokViewCustomerMessage("A question")) + expectAnySent("couldn't process that") + }) + + test("Grok ignores bot commands from customer", async () => { + await bot.onGrokNewChatItems(grokViewCustomerMessage("/team")) + expect(grokApi.calls.length).toBe(0) + }) + + test("Grok per-message: history includes prior Grok sent response as assistant", async () => { + addCustomerMessageToHistory("How do I create a group?", GROK_LOCAL_GROUP_ID) + addBotMessage("To create a group, tap + then New Group.", GROK_LOCAL_GROUP_ID) + addCustomerMessageToHistory("How do I invite members?", GROK_LOCAL_GROUP_ID) + grokApi.willRespond("Open the group and tap Invite.") + await bot.onGrokNewChatItems(grokViewCustomerMessage("How do I invite members?")) + + expect(grokApi.calls.length).toBe(1) + expect(grokApi.calls[0].message).toBe("How do I invite members?") + expect(grokApi.calls[0].history).toEqual([ + {role: "user", content: "How do I create a group?"}, + {role: "assistant", content: "To create a group, tap + then New Group."}, + ]) + }) + + test("Grok ignores non-customer messages", async () => { + chat.groups.set(GROK_LOCAL_GROUP_ID, makeGroupInfo(GROK_LOCAL_GROUP_ID)) + const ci = makeChatItem({dir: "groupRcv", text: "Team message", memberId: "not-customer", memberContactId: TEAM_MEMBER_1_ID}) + const grokEvt = { + type: "newChatItems" as const, + user: makeUser(GROK_USER_ID), + chatItems: [{chatInfo: {type: "group", groupInfo: makeGroupInfo(GROK_LOCAL_GROUP_ID)}, chatItem: ci}], + } + await bot.onGrokNewChatItems(grokEvt) + expect(grokApi.calls.length).toBe(0) + }) + + test("Grok ignores own messages (groupSnd)", async () => { + chat.groups.set(GROK_LOCAL_GROUP_ID, makeGroupInfo(GROK_LOCAL_GROUP_ID)) + const ci = makeChatItem({dir: "groupSnd", text: "My own response"}) + const grokEvt = { + type: "newChatItems" as const, + user: makeUser(GROK_USER_ID), + chatItems: [{chatInfo: {type: "group", groupInfo: makeGroupInfo(GROK_LOCAL_GROUP_ID)}, chatItem: ci}], + } + await bot.onGrokNewChatItems(grokEvt) + expect(grokApi.calls.length).toBe(0) + }) + + test("batch: multiple customer messages in one event → only last triggers Grok API call", async () => { + chat.groups.set(GROK_LOCAL_GROUP_ID, makeGroupInfo(GROK_LOCAL_GROUP_ID)) + addCustomerMessageToHistory("First question", GROK_LOCAL_GROUP_ID) + addCustomerMessageToHistory("Second question", GROK_LOCAL_GROUP_ID) + + const ci1 = makeChatItem({dir: "groupRcv", text: "First question", memberId: CUSTOMER_ID}) + const ci2 = makeChatItem({dir: "groupRcv", text: "Second question", memberId: CUSTOMER_ID}) + const evt = { + type: "newChatItems" as const, + user: makeUser(GROK_USER_ID), + chatItems: [ + {chatInfo: {type: "group", groupInfo: makeGroupInfo(GROK_LOCAL_GROUP_ID)}, chatItem: ci1}, + {chatInfo: {type: "group", groupInfo: makeGroupInfo(GROK_LOCAL_GROUP_ID)}, chatItem: ci2}, + ], + } + + await bot.onGrokNewChatItems(evt) + + expect(grokApi.calls.length).toBe(1) + expect(grokApi.calls[0].message).toBe("Second question") + }) + + test("batch: messages from different groups → each group gets one response", async () => { + const GROK_GROUP_A = 201 + const GROK_GROUP_B = 202 + chat.groups.set(GROK_GROUP_A, makeGroupInfo(GROK_GROUP_A)) + chat.groups.set(GROK_GROUP_B, makeGroupInfo(GROK_GROUP_B)) + addCustomerMessageToHistory("Question A", GROK_GROUP_A) + addCustomerMessageToHistory("Question B", GROK_GROUP_B) + + const ciA = makeChatItem({dir: "groupRcv", text: "Question A", memberId: CUSTOMER_ID}) + const ciB = makeChatItem({dir: "groupRcv", text: "Question B", memberId: CUSTOMER_ID}) + const evt = { + type: "newChatItems" as const, + user: makeUser(GROK_USER_ID), + chatItems: [ + {chatInfo: {type: "group", groupInfo: makeGroupInfo(GROK_GROUP_A)}, chatItem: ciA}, + {chatInfo: {type: "group", groupInfo: makeGroupInfo(GROK_GROUP_B)}, chatItem: ciB}, + ], + } + + await bot.onGrokNewChatItems(evt) + + expect(grokApi.calls.length).toBe(2) + }) + + test("batch: non-customer messages filtered, only customer messages trigger response", async () => { + chat.groups.set(GROK_LOCAL_GROUP_ID, makeGroupInfo(GROK_LOCAL_GROUP_ID)) + addCustomerMessageToHistory("Customer question", GROK_LOCAL_GROUP_ID) + + const custCi = makeChatItem({dir: "groupRcv", text: "Customer question", memberId: CUSTOMER_ID}) + const teamCi = makeChatItem({dir: "groupRcv", text: "Team reply", memberId: "not-customer", memberContactId: TEAM_MEMBER_1_ID}) + const evt = { + type: "newChatItems" as const, + user: makeUser(GROK_USER_ID), + chatItems: [ + {chatInfo: {type: "group", groupInfo: makeGroupInfo(GROK_LOCAL_GROUP_ID)}, chatItem: custCi}, + {chatInfo: {type: "group", groupInfo: makeGroupInfo(GROK_LOCAL_GROUP_ID)}, chatItem: teamCi}, + ], + } + + await bot.onGrokNewChatItems(evt) + + expect(grokApi.calls.length).toBe(1) + expect(grokApi.calls[0].message).toBe("Customer question") + }) + + test("batch: across groups → Grok calls overlap in-flight (parallel dispatch)", async () => { + const GROK_GROUP_A = 201 + const GROK_GROUP_B = 202 + chat.groups.set(GROK_GROUP_A, makeGroupInfo(GROK_GROUP_A)) + chat.groups.set(GROK_GROUP_B, makeGroupInfo(GROK_GROUP_B)) + addCustomerMessageToHistory("A", GROK_GROUP_A) + addCustomerMessageToHistory("B", GROK_GROUP_B) + + const ciA = makeChatItem({dir: "groupRcv", text: "A", memberId: CUSTOMER_ID}) + const ciB = makeChatItem({dir: "groupRcv", text: "B", memberId: CUSTOMER_ID}) + const evt = { + type: "newChatItems" as const, + user: makeUser(GROK_USER_ID), + chatItems: [ + {chatInfo: {type: "group", groupInfo: makeGroupInfo(GROK_GROUP_A)}, chatItem: ciA}, + {chatInfo: {type: "group", groupInfo: makeGroupInfo(GROK_GROUP_B)}, chatItem: ciB}, + ], + } + + // Block both chat() calls until we release. If the handler serialized + // per-group work, only one call would enter chat() before release. + grokApi.blockChat() + const done = bot.onGrokNewChatItems(evt) + // Let both tasks run up to the gate. + await new Promise(r => setTimeout(r, 10)) + expect(grokApi.calls.length).toBe(2) + grokApi.releaseChat() + await done + }) +}) + +describe("/team Activation", () => { + beforeEach(() => setup()) + + test("/team from QUEUE → ALL team members added, teamAddedMessage sent", async () => { + await reachQueue() + addBotMessage("The team will reply to your message") + await bot.onNewChatItems(customerMessage("/team")) + expectMemberAdded(CUSTOMER_GROUP_ID, TEAM_MEMBER_1_ID) + expectMemberAdded(CUSTOMER_GROUP_ID, TEAM_MEMBER_2_ID) + expectSentToGroup(CUSTOMER_GROUP_ID, "We will reply within") + }) + + test("/team as first message → WELCOME→TEAM, no queue message", async () => { + await bot.onNewChatItems(customerMessage("/team")) + expectSentToGroup(CUSTOMER_GROUP_ID, "We will reply within") + expectNotSentToGroup(CUSTOMER_GROUP_ID, "The team will reply to your message") + }) + + test("/team when already activated → teamAlreadyInvitedMessage", async () => { + await reachTeamPending() + addBotMessage("We will reply within 24 hours.") + chat.members.set(CUSTOMER_GROUP_ID, [makeTeamMember(TEAM_MEMBER_1_ID, "Alice")]) + await bot.onNewChatItems(customerMessage("/team")) + expectSentToGroup(CUSTOMER_GROUP_ID, "already been invited") + }) + + test("/team with no team members → noTeamMembersMessage", async () => { + setup({teamMembers: []}) + await reachQueue() + addBotMessage("The team will reply to your message") + await bot.onNewChatItems(customerMessage("/team")) + expectSentToGroup(CUSTOMER_GROUP_ID, "No team members are available") + }) +}) + +describe("One-Way Gate", () => { + beforeEach(() => setup()) + + test("team member sends first TEXT → Grok removed if present", async () => { + await reachTeamPending() + addBotMessage("We will reply within 24 hours.") + chat.members.set(CUSTOMER_GROUP_ID, [makeGrokMember(), makeTeamMember(TEAM_MEMBER_1_ID, "Alice")]) + await bot.onNewChatItems(teamMemberMessage("Hi, how can I help?")) + expect(chat.removed.some(r => r.groupId === CUSTOMER_GROUP_ID && r.memberIds.includes(7777))).toBe(true) + }) + + test("team member non-text (no ciContentText) → Grok NOT removed", async () => { + await reachTeamPending() + addBotMessage("We will reply within 24 hours.") + chat.members.set(CUSTOMER_GROUP_ID, [makeGrokMember()]) + await bot.onNewChatItems(teamMemberMessage("", TEAM_MEMBER_1_ID)) + expect(chat.removed.length).toBe(0) + }) + + test("/grok after gate → teamLockedMessage", async () => { + await reachTeam() + await bot.onNewChatItems(customerMessage("/grok")) + expectSentToGroup(CUSTOMER_GROUP_ID, "team mode") + }) + + test("customer text in TEAM → card update scheduled, no bot reply", async () => { + await reachTeam() + const sentBefore = chat.sentTo(CUSTOMER_GROUP_ID).length + await bot.onNewChatItems(customerMessage("Follow-up question")) + const sentAfter = chat.sentTo(CUSTOMER_GROUP_ID).length + expect(sentAfter).toBe(sentBefore) + }) + + test("/grok in TEAM-PENDING → invite Grok if not present", async () => { + await reachTeamPending() + addBotMessage("We will reply within 24 hours.") + chat.members.set(CUSTOMER_GROUP_ID, [makeTeamMember(TEAM_MEMBER_1_ID, "Alice")]) + const joinPromise = simulateGrokJoinSuccess() + await bot.onNewChatItems(customerMessage("/grok")) + await joinPromise + await bot.flush() + expectMemberAdded(CUSTOMER_GROUP_ID, GROK_CONTACT_ID) + }) +}) + +describe("One-Way Gate with Grok Disabled", () => { + test("team text removes Grok even when grokApi is null", async () => { + setup() + // Recreate bot without grokApi but with grokContactId still set (simulates disabled Grok with persisted contact) + bot = new SupportBot(chat as any, null, config as any, MAIN_USER_ID, null, DESIRED_COMMANDS) + bot.cards = cards + // Reach QUEUE state with Grok + team member already present + addBotMessage("The team will reply to your message") + addBotMessage("We will reply within 24 hours.") + chat.members.set(CUSTOMER_GROUP_ID, [makeGrokMember(), makeTeamMember(TEAM_MEMBER_1_ID, "Alice")]) + // Team member sends text → one-way gate should fire + await bot.onNewChatItems(teamMemberMessage("Hi, how can I help?")) + expect(chat.removed.some(r => r.groupId === CUSTOMER_GROUP_ID && r.memberIds.includes(7777))).toBe(true) + }) + + test("Grok does not respond when disabled even if grokContactId is set", async () => { + setup() + bot = new SupportBot(chat as any, null, config as any, MAIN_USER_ID, null, DESIRED_COMMANDS) + bot.cards = cards + // Set up group with Grok member present + chat.members.set(CUSTOMER_GROUP_ID, [makeGrokMember()]) + addBotMessage("The team will reply to your message") + // Customer sends text in GROK state + await bot.onNewChatItems(customerMessage("How do I use SimpleX?")) + // Grok should not respond (grokApi is null) + expect(grokApi.calls.length).toBe(0) + }) +}) + +describe("Team Member Lifecycle", () => { + beforeEach(() => setup()) + + test("team member connected → promoted to Owner", async () => { + await bot.onMemberConnected(connectedEvent(CUSTOMER_GROUP_ID, makeTeamMember(TEAM_MEMBER_1_ID, "Alice"))) + expect(chat.roleChanges.some(r => r.groupId === CUSTOMER_GROUP_ID && r.memberIds.includes(5000 + TEAM_MEMBER_1_ID) && r.role === GroupMemberRole.Owner)).toBe(true) + }) + + test("/team invites team member → apiSetMembersRole(Owner) called at invite time", async () => { + await bot.onNewChatItems(customerMessage("/team")) + expectMemberAdded(CUSTOMER_GROUP_ID, TEAM_MEMBER_1_ID) + expect(chat.roleChanges.some(r => + r.groupId === CUSTOMER_GROUP_ID + && r.memberIds.includes(5000 + TEAM_MEMBER_1_ID) + && r.role === GroupMemberRole.Owner + )).toBe(true) + }) + + test("/join invites team member → apiSetMembersRole(Owner) called at invite time", async () => { + await bot.onNewChatItems(teamGroupMessage(`/join ${CUSTOMER_GROUP_ID}`)) + expectMemberAdded(CUSTOMER_GROUP_ID, TEAM_MEMBER_1_ID) + expect(chat.roleChanges.some(r => + r.groupId === CUSTOMER_GROUP_ID + && r.memberIds.includes(5000 + TEAM_MEMBER_1_ID) + && r.role === GroupMemberRole.Owner + )).toBe(true) + }) + + test("/team when team member already in group (any non-terminal status) → apiSetMembersRole NOT re-called", async () => { + chat.members.set(CUSTOMER_GROUP_ID, [makeTeamMember(TEAM_MEMBER_1_ID, "Alice")]) + await cards.mergeCustomData(CUSTOMER_GROUP_ID, {state: "TEAM-PENDING"}) + chat.added.length = 0 + chat.roleChanges.length = 0 + + await bot.onNewChatItems(customerMessage("/team")) + expect(chat.added.length).toBe(0) + expect(chat.roleChanges.length).toBe(0) + }) + + test("/join when team member already in group → apiSetMembersRole NOT re-called", async () => { + chat.members.set(CUSTOMER_GROUP_ID, [makeTeamMember(TEAM_MEMBER_1_ID, "Alice")]) + chat.added.length = 0 + chat.roleChanges.length = 0 + + await bot.onNewChatItems(teamGroupMessage(`/join ${CUSTOMER_GROUP_ID}`)) + expect(chat.added.length).toBe(0) + expect(chat.roleChanges.length).toBe(0) + }) + + test("customer connected → NOT promoted to Owner", async () => { + await bot.onMemberConnected(connectedEvent(CUSTOMER_GROUP_ID, makeCustomerMember())) + expect(chat.roleChanges.length).toBe(0) + }) + + test("Grok connected → NOT promoted to Owner", async () => { + await bot.onMemberConnected(connectedEvent(CUSTOMER_GROUP_ID, makeGrokMember())) + expect(chat.roleChanges.length).toBe(0) + }) + + test("all team members leave before sending → state stays TEAM-PENDING", async () => { + await reachTeamPending() + addBotMessage("We will reply within 24 hours.") + // Remove team members from the group + chat.members.set(CUSTOMER_GROUP_ID, []) + // State is authoritative and monotonic — composition changes never demote it. + // Customer is still waiting for the team's response. + const state = await cards.deriveState(CUSTOMER_GROUP_ID) + expect(state).toBe("TEAM-PENDING") + }) + + test("/team after all team members left (TEAM-PENDING, no msg sent) → re-adds members", async () => { + await reachTeamPending() + addBotMessage("We will reply within 24 hours.") + chat.members.set(CUSTOMER_GROUP_ID, []) + chat.added.length = 0 + + await bot.onNewChatItems(customerMessage("/team")) + expectSentToGroup(CUSTOMER_GROUP_ID, "We will reply within") + expectMemberAdded(CUSTOMER_GROUP_ID, TEAM_MEMBER_1_ID) + }) + + test("/team after all team members left (TEAM, msg was sent) → re-adds members", async () => { + await reachTeamPending() + addBotMessage("We will reply within 24 hours.") + chat.members.set(CUSTOMER_GROUP_ID, [makeTeamMember(TEAM_MEMBER_1_ID, "Alice")]) + addTeamMemberMessageToHistory("Hi, how can I help?", TEAM_MEMBER_1_ID) + await bot.onNewChatItems(teamMemberMessage("Hi, how can I help?")) + + // All team members leave + chat.members.set(CUSTOMER_GROUP_ID, []) + chat.added.length = 0 + + await bot.onNewChatItems(customerMessage("/team")) + expectSentToGroup(CUSTOMER_GROUP_ID, "We will reply within") + expectMemberAdded(CUSTOMER_GROUP_ID, TEAM_MEMBER_1_ID) + }) +}) + +describe("Card Dashboard", () => { + beforeEach(() => setup()) + + test("first message creates card with /'join ' final line", async () => { + await bot.onNewChatItems(customerMessage("Hello")) + const teamMsgs = chat.sentTo(TEAM_GROUP_ID) + expect(teamMsgs.length).toBe(1) + const card = teamMsgs[0] + expect(card).toContain(`/'join ${CUSTOMER_GROUP_ID}'`) + // Join command is the final line of the card + const lines = card.split("\n") + expect(lines[lines.length - 1]).toBe(`/'join ${CUSTOMER_GROUP_ID}'`) + }) + + test("card update deletes old card then posts new one", async () => { + chat.customData.set(CUSTOMER_GROUP_ID, {cardItemId: 555}) + await cards.flush() + expect(chat.deleted.length).toBe(0) + + cards.scheduleUpdate(CUSTOMER_GROUP_ID) + await cards.flush() + expectCardDeleted(555) + expect(chat.sentTo(TEAM_GROUP_ID).length).toBeGreaterThan(0) + }) + + test("apiDeleteChatItems failure → ignored, new card posted", async () => { + chat.customData.set(CUSTOMER_GROUP_ID, {cardItemId: 555}) + chat.apiDeleteChatItemsWillFail() + cards.scheduleUpdate(CUSTOMER_GROUP_ID) + await cards.flush() + // New card should still be posted despite delete failure + expect(chat.sentTo(TEAM_GROUP_ID).length).toBeGreaterThan(0) + }) + + test("customData stores cardItemId → survives flush cycle", async () => { + await bot.onNewChatItems(customerMessage("Hello")) + // After card creation, customData should have cardItemId + const data = chat.customData.get(CUSTOMER_GROUP_ID) + expect(data).toBeDefined() + expect(typeof data.cardItemId).toBe("number") + }) + + test("concurrent mergeCustomData on same group → both patches survive", async () => { + // Without per-group serialization, two overlapping mergeCustomData calls + // can both read the same snapshot and the second write clobbers the first + // patch. The mutex keeps them ordered so both patches land. + const GID = 500 + chat.groups.set(GID, makeGroupInfo(GID)) + + const pA = cards.mergeCustomData(GID, {state: "QUEUE"}) + const pB = cards.mergeCustomData(GID, {cardItemId: 123}) + await Promise.all([pA, pB]) + + const final = chat.customData.get(GID) + expect(final.state).toBe("QUEUE") + expect(final.cardItemId).toBe(123) + }) + + test("customer leaves → customData cleared", async () => { + await bot.onNewChatItems(customerMessage("Hello")) + chat.customData.set(CUSTOMER_GROUP_ID, {cardItemId: 999}) + await bot.onLeftMember(leftEvent(CUSTOMER_GROUP_ID, makeCustomerMember())) + expect(chat.customData.has(CUSTOMER_GROUP_ID)).toBe(false) + }) +}) + +describe("Card Debouncing", () => { + beforeEach(() => setup()) + + test("rapid events within flush interval → single card update on flush", async () => { + chat.customData.set(CUSTOMER_GROUP_ID, {cardItemId: 500}) + cards.scheduleUpdate(CUSTOMER_GROUP_ID) + cards.scheduleUpdate(CUSTOMER_GROUP_ID) + cards.scheduleUpdate(CUSTOMER_GROUP_ID) + await cards.flush() + // Only one delete and one post + expect(chat.deleted.length).toBe(1) + // Multiple schedules → single update (one card message) + const teamMsgs = chat.sentTo(TEAM_GROUP_ID) + expect(teamMsgs.length).toBe(1) + }) + + test("multiple groups pending → each reposted once per flush", async () => { + const GROUP_A = 101 + const GROUP_B = 102 + chat.groups.set(GROUP_A, makeGroupInfo(GROUP_A)) + chat.groups.set(GROUP_B, makeGroupInfo(GROUP_B)) + chat.customData.set(GROUP_A, {cardItemId: 501}) + chat.customData.set(GROUP_B, {cardItemId: 502}) + cards.scheduleUpdate(GROUP_A) + cards.scheduleUpdate(GROUP_B) + await cards.flush() + expect(chat.deleted.length).toBe(2) + }) + + test("card create is immediate (not debounced)", async () => { + await bot.onNewChatItems(customerMessage("Hello")) + // Card should be posted immediately without flush + expect(chat.sentTo(TEAM_GROUP_ID).length).toBeGreaterThan(0) + }) + + test("flush with no pending updates → no-op", async () => { + await cards.flush() + expect(chat.deleted.length).toBe(0) + expect(chat.sentTo(TEAM_GROUP_ID).length).toBe(0) + }) + + test("flush on group with no cardItemId → createCard posts a new card (retries failed create)", async () => { + // customData without cardItemId simulates a prior createCard that failed + // mid-flight and re-queued itself. flushOne must dispatch to createCard, + // not updateCard (which would early-return). + const GID = 777 + chat.groups.set(GID, makeGroupInfo(GID)) + chat.customData.set(GID, {state: "QUEUE"}) + cards.scheduleUpdate(GID) + await cards.flush() + expect(chat.sentTo(TEAM_GROUP_ID).length).toBe(1) + expect(typeof chat.customData.get(GID).cardItemId).toBe("number") + }) +}) + +describe("Card Format & State Derivation", () => { + beforeEach(() => setup()) + + test("QUEUE state read from customData.state", async () => { + chat.customData.set(CUSTOMER_GROUP_ID, {cardItemId: 1234, state: "QUEUE"}) + const state = await cards.deriveState(CUSTOMER_GROUP_ID) + expect(state).toBe("QUEUE") + }) + + test("WELCOME state when customData.state is absent", async () => { + const state = await cards.deriveState(CUSTOMER_GROUP_ID) + expect(state).toBe("WELCOME") + }) + + test("GROK state read from customData.state", async () => { + chat.customData.set(CUSTOMER_GROUP_ID, {cardItemId: 1234, state: "GROK"}) + const state = await cards.deriveState(CUSTOMER_GROUP_ID) + expect(state).toBe("GROK") + }) + + test("TEAM-PENDING state read from customData.state", async () => { + chat.customData.set(CUSTOMER_GROUP_ID, {cardItemId: 1234, state: "TEAM-PENDING"}) + const state = await cards.deriveState(CUSTOMER_GROUP_ID) + expect(state).toBe("TEAM-PENDING") + }) + + test("TEAM state read from customData.state", async () => { + chat.customData.set(CUSTOMER_GROUP_ID, {cardItemId: 1234, state: "TEAM"}) + const state = await cards.deriveState(CUSTOMER_GROUP_ID) + expect(state).toBe("TEAM") + }) + + test("message count excludes bot's own messages", async () => { + addCustomerMessageToHistory("Hello") + addBotMessage("Queue message") + addCustomerMessageToHistory("Follow-up") + const chatResult = await cards.getChat(CUSTOMER_GROUP_ID, 100) + const nonBotCount = chatResult.chatItems.filter((ci: any) => ci.chatDir.type !== "groupSnd").length + expect(nonBotCount).toBe(2) + }) +}) + +describe("/join Command (Team Group)", () => { + beforeEach(() => setup()) + + test("/join (numeric only) → team member added to customer group", async () => { + await bot.onNewChatItems(teamGroupMessage(`/join ${CUSTOMER_GROUP_ID}`)) + expectMemberAdded(CUSTOMER_GROUP_ID, TEAM_MEMBER_1_ID) + }) + + test("/join : (legacy form) → team member added", async () => { + await bot.onNewChatItems(teamGroupMessage(`/join ${CUSTOMER_GROUP_ID}:Customer`)) + expectMemberAdded(CUSTOMER_GROUP_ID, TEAM_MEMBER_1_ID) + }) + + test("/join validates target is business group → error if not", async () => { + const nonBizGroupId = 999 + chat.groups.set(nonBizGroupId, makeGroupInfo(nonBizGroupId, {businessChat: null})) + await bot.onNewChatItems(teamGroupMessage(`/join ${nonBizGroupId}:Test`)) + expectSentToGroup(TEAM_GROUP_ID, "not a business chat") + }) + + test("/join with non-existent groupId → error in team group", async () => { + await bot.onNewChatItems(teamGroupMessage("/join 99999:Nobody")) + expect(chat.sentTo(TEAM_GROUP_ID).some(m => m.toLowerCase().includes("error"))).toBe(true) + }) + + test("customer sending /join in customer group → treated as normal message", async () => { + await bot.onNewChatItems(customerMessage("/join 50:Test")) + expectSentToGroup(CUSTOMER_GROUP_ID, "The team will reply to your message") + }) + + test("/join with non-numeric params → error reply, no apiAddMember call", async () => { + await bot.onNewChatItems(teamGroupMessage("/join abc")) + expectSentToGroup(TEAM_GROUP_ID, `Error: invalid group id "abc"`) + expect(chat.added.length).toBe(0) + }) +}) + +describe("DM Handshake", () => { + beforeEach(() => setup()) + + test("team member joins team group → DM sent with contact ID", async () => { + const member = {memberId: "new-team", groupMemberId: 8000, memberContactId: 30, memberStatus: GroupMemberStatus.Connected, memberProfile: {displayName: "Charlie"}} + await bot.onMemberConnected(connectedEvent(TEAM_GROUP_ID, member, {contactId: 30, profile: {displayName: "Charlie"}})) + expectDmSent(30, "Your contact ID is 30:Charlie") + }) + + test("DM with spaces in name → name single-quoted", async () => { + const member = {memberId: "new-team", groupMemberId: 8001, memberContactId: 31, memberStatus: GroupMemberStatus.Connected, memberProfile: {displayName: "Charlie Brown"}} + await bot.onMemberConnected(connectedEvent(TEAM_GROUP_ID, member, {contactId: 31, profile: {displayName: "Charlie Brown"}})) + expectDmSent(31, "31:'Charlie Brown'") + }) + + test("pending DM delivered on contactConnected", async () => { + const invEvt = { + type: "newMemberContactReceivedInv" as const, + user: makeUser(MAIN_USER_ID), + contact: {contactId: 32, profile: {displayName: "Dave"}}, + groupInfo: makeGroupInfo(TEAM_GROUP_ID, {businessChat: null}), + member: {memberId: "dave-member", groupMemberId: 8002, memberContactId: 32, memberStatus: GroupMemberStatus.Connected, memberProfile: {displayName: "Dave"}}, + } + await bot.onMemberContactReceivedInv(invEvt) + + await bot.onContactConnected({ + type: "contactConnected" as const, + user: makeUser(MAIN_USER_ID), + contact: {contactId: 32, profile: {displayName: "Dave"}}, + }) + expectDmSent(32, "Your contact ID is 32:Dave") + }) + + test("team member with no DM contact → creates member contact and sends invitation", async () => { + const member = {memberId: "new-team-no-dm", groupMemberId: 8010, memberContactId: null, memberStatus: GroupMemberStatus.Connected, memberProfile: {displayName: "Frank"}} + await bot.onMemberConnected(connectedEvent(TEAM_GROUP_ID, member, undefined)) + expect(chat.memberContacts.some(c => c.groupId === TEAM_GROUP_ID && c.groupMemberId === 8010)).toBe(true) + expect(chat.memberContactInvitations.some(i => i.text.includes("Your contact ID is") && i.text.includes("Frank"))).toBe(true) + const dms = chat.sent.filter(s => s.chat[0] === ChatType.Direct) + expect(dms.some(m => m.text.includes("Your contact ID is") && m.text.includes("Frank"))).toBe(true) + }) + + test("joinedGroupMember in team group → creates member contact and sends invitation", async () => { + const member = {memberId: "link-joiner", groupMemberId: 8020, memberContactId: null, memberStatus: GroupMemberStatus.Connected, memberProfile: {displayName: "Grace"}} + await bot.onJoinedGroupMember(joinedEvent(TEAM_GROUP_ID, member)) + expect(chat.memberContacts.some(c => c.groupId === TEAM_GROUP_ID && c.groupMemberId === 8020)).toBe(true) + expect(chat.memberContactInvitations.some(i => i.text.includes("Grace"))).toBe(true) + }) + + test("no duplicate DM when both sendTeamMemberDM succeeds and onMemberContactReceivedInv fires", async () => { + const invEvt = { + type: "newMemberContactReceivedInv" as const, + user: makeUser(MAIN_USER_ID), + contact: {contactId: 33, profile: {displayName: "Eve"}}, + groupInfo: makeGroupInfo(TEAM_GROUP_ID, {businessChat: null}), + member: {memberId: "eve-member", groupMemberId: 8003, memberContactId: 33, memberStatus: GroupMemberStatus.Connected, memberProfile: {displayName: "Eve"}}, + } + await bot.onMemberContactReceivedInv(invEvt) + + const eveMember = {memberId: "eve-member", groupMemberId: 8003, memberContactId: 33, memberStatus: GroupMemberStatus.Connected, memberProfile: {displayName: "Eve"}} + await bot.onMemberConnected(connectedEvent(TEAM_GROUP_ID, eveMember, {contactId: 33, profile: {displayName: "Eve"}})) + + await bot.onContactConnected({ + type: "contactConnected" as const, + user: makeUser(MAIN_USER_ID), + contact: {contactId: 33, profile: {displayName: "Eve"}}, + }) + + const dms = chat.sentDirect(33) + const contactIdMsgs = dms.filter(m => m.includes("Your contact ID is 33:Eve")) + expect(contactIdMsgs.length).toBe(1) + }) +}) + +describe("Direct Message Handling", () => { + beforeEach(() => setup()) + + test("regular DM → bot replies with business address link", async () => { + bot.businessAddress = "simplex:/contact#abc123" + await bot.onNewChatItems(directMessage("Hi there", 99)) + expectDmSent(99, "simplex:/contact#abc123") + }) + + test("DM without business address set → no reply", async () => { + bot.businessAddress = null + await bot.onNewChatItems(directMessage("Hi there", 99)) + expect(chat.sentDirect(99).length).toBe(0) + }) + + test("non-message DM event (e.g. contactConnected) → no reply", async () => { + bot.businessAddress = "simplex:/contact#abc123" + const ci = { + chatDir: {type: "directRcv"}, + content: {type: "rcvDirectEvent"}, + meta: {itemId: 9999, createdAt: new Date().toISOString()}, + } + const evt = { + type: "newChatItems" as const, + user: makeUser(MAIN_USER_ID), + chatItems: [makeDirectAChatItem(ci, 99)], + } + await bot.onNewChatItems(evt) + expect(chat.sentDirect(99).length).toBe(0) + }) +}) + +describe("Business Request Handler", () => { + beforeEach(() => setup()) + + test("acceptingBusinessRequest → enables file uploads AND visible history", async () => { + await bot.onBusinessRequest({ + type: "acceptingBusinessRequest" as const, + user: makeUser(MAIN_USER_ID), + groupInfo: makeGroupInfo(CUSTOMER_GROUP_ID), + }) + expect(chat.profileUpdates.some(u => + u.groupId === CUSTOMER_GROUP_ID + && u.profile.groupPreferences?.files?.enable === GroupFeatureEnabled.On + && u.profile.groupPreferences?.history?.enable === GroupFeatureEnabled.On + )).toBe(true) + }) +}) + +describe("chatItemUpdated Handler", () => { + beforeEach(() => setup()) + + test("chatItemUpdated in business group → card update scheduled", async () => { + await bot.onChatItemUpdated(updatedEvent(CUSTOMER_GROUP_ID, makeChatItem({dir: "groupRcv", text: "edited message", memberId: CUSTOMER_ID}))) + chat.customData.set(CUSTOMER_GROUP_ID, {cardItemId: 600}) + await cards.flush() + expectCardDeleted(600) + }) + + test("chatItemUpdated in non-business group → ignored", async () => { + await bot.onChatItemUpdated(updatedEvent(TEAM_GROUP_ID, makeChatItem({dir: "groupRcv", text: "team msg"}))) + await cards.flush() + expect(chat.deleted.length).toBe(0) + }) + + test("chatItemUpdated from wrong user → ignored", async () => { + await bot.onChatItemUpdated(updatedEvent(CUSTOMER_GROUP_ID, makeChatItem({dir: "groupRcv", text: "edited"}), GROK_USER_ID)) + await cards.flush() + expect(chat.deleted.length).toBe(0) + }) +}) + +describe("Reactions", () => { + beforeEach(() => setup()) + + test("reaction in business group → card update scheduled", async () => { + await bot.onChatItemReaction(reactionEvent(CUSTOMER_GROUP_ID, true)) + chat.customData.set(CUSTOMER_GROUP_ID, {cardItemId: 700}) + await cards.flush() + expectCardDeleted(700) + }) + + test("reaction removed (added=false) → no card update", async () => { + await bot.onChatItemReaction(reactionEvent(CUSTOMER_GROUP_ID, false)) + await cards.flush() + expect(chat.deleted.length).toBe(0) + }) +}) + +describe("Customer Leave", () => { + beforeEach(() => setup()) + + test("customer leaves → customData cleared", async () => { + chat.customData.set(CUSTOMER_GROUP_ID, {cardItemId: 800}) + await bot.onLeftMember(leftEvent(CUSTOMER_GROUP_ID, makeCustomerMember())) + expect(chat.customData.has(CUSTOMER_GROUP_ID)).toBe(false) + }) + + test("Grok leaves → in-memory maps cleaned", async () => { + await bot.onLeftMember(leftEvent(CUSTOMER_GROUP_ID, makeGrokMember())) + }) + + test("team member leaves → logged, no crash", async () => { + await bot.onLeftMember(leftEvent(CUSTOMER_GROUP_ID, makeTeamMember(TEAM_MEMBER_1_ID, "Alice"))) + }) + + test("leftMember in non-business group → ignored", async () => { + const member = {memberId: "someone", groupMemberId: 9000, memberStatus: GroupMemberStatus.Connected, memberProfile: {displayName: "Someone"}} + await bot.onLeftMember(leftEvent(TEAM_GROUP_ID, member)) + }) +}) + +describe("Error Handling", () => { + beforeEach(() => setup()) + + test("apiAddMember fails (Grok invite) → grokUnavailableMessage", async () => { + await reachQueue() + addBotMessage("The team will reply to your message") + chat.apiAddMemberWillFail() + await bot.onNewChatItems(customerMessage("/grok")) + await bot.flush() + expectSentToGroup(CUSTOMER_GROUP_ID, "temporarily unavailable") + }) + + test("groupDuplicateMember on Grok invite → only inviting message, no result", async () => { + await reachQueue() + addBotMessage("The team will reply to your message") + chat.apiAddMemberWillFail({chatError: {errorType: {type: "groupDuplicateMember"}}}) + const sentBefore = chat.sent.length + await bot.onNewChatItems(customerMessage("/grok")) + await bot.flush() + // Only the "Inviting Grok" message is sent — no activated/unavailable result + expect(chat.sent.length).toBe(sentBefore + 1) + expectSentToGroup(CUSTOMER_GROUP_ID, "Inviting Grok") + expectNotSentToGroup(CUSTOMER_GROUP_ID, "You are chatting with Grok") + expectNotSentToGroup(CUSTOMER_GROUP_ID, "temporarily unavailable") + }) + + test("/team while members are in Invited status → no second apiAddMember call", async () => { + await reachTeamPending() + addBotMessage("We will reply within 24 hours.") + + // Simulate the realistic post-/team state: both members have been invited + // but have not yet accepted (memberStatus = "invited"). The SimpleX API + // would resend the invitation if apiAddMember is called for an Invited + // member — the pre-check in addOrFindTeamMember must skip them. + const invited = (contactId: number) => ({ + memberId: `team-${contactId}`, + groupMemberId: 5000 + contactId, + memberContactId: contactId, + memberStatus: "invited", + memberProfile: {displayName: `Contact${contactId}`}, + }) + chat.members.set(CUSTOMER_GROUP_ID, [invited(TEAM_MEMBER_1_ID), invited(TEAM_MEMBER_2_ID)]) + chat.added.length = 0 + + await bot.onNewChatItems(customerMessage("/team")) + expect(chat.added.filter(a => a.groupId === CUSTOMER_GROUP_ID)).toEqual([]) + }) + + test("/grok in TEAM-PENDING while Grok is in Invited status → no second apiAddMember call", async () => { + await reachTeamPending() + addBotMessage("We will reply within 24 hours.") + chat.members.set(CUSTOMER_GROUP_ID, [ + makeTeamMember(TEAM_MEMBER_1_ID, "Alice"), + {memberId: "grok-member", groupMemberId: 7777, memberContactId: GROK_CONTACT_ID, memberStatus: "invited", memberProfile: {displayName: "Grok"}}, + ]) + chat.added.length = 0 + + await bot.onNewChatItems(customerMessage("/grok")) + await bot.flush() + + expect(chat.added.filter(a => a.contactId === GROK_CONTACT_ID)).toEqual([]) + expectNotSentToGroup(CUSTOMER_GROUP_ID, "Inviting Grok") + }) +}) + +describe("Profile / Event Filtering", () => { + beforeEach(() => setup()) + + test("newChatItems from Grok profile → ignored by main handler", async () => { + const evt = { + type: "newChatItems" as const, + user: makeUser(GROK_USER_ID), + chatItems: [makeAChatItem(makeChatItem({dir: "groupRcv", text: "test"}))], + } + const sentBefore = chat.sent.length + await bot.onNewChatItems(evt) + expect(chat.sent.length).toBe(sentBefore) + }) + + test("Grok events from main profile → ignored by Grok handlers", async () => { + const evt = { + type: "receivedGroupInvitation" as const, + user: makeUser(MAIN_USER_ID), + groupInfo: makeGroupInfo(300), + contact: {contactId: 1}, + fromMemberRole: GroupMemberRole.Admin, + memberRole: GroupMemberRole.Member, + } + await bot.onGrokGroupInvitation(evt) + expect(chat.joined.length).toBe(0) + }) + + test("own messages (groupSnd) → ignored", async () => { + const ci = makeChatItem({dir: "groupSnd", text: "Bot message"}) + const evt = { + type: "newChatItems" as const, + user: makeUser(MAIN_USER_ID), + chatItems: [makeAChatItem(ci)], + } + const sentBefore = chat.sent.length + await bot.onNewChatItems(evt) + expect(chat.sent.length).toBe(sentBefore) + }) + + test("non-business group messages → ignored", async () => { + const ci = makeChatItem({dir: "groupRcv", text: "test"}) + const nonBizGroup = makeGroupInfo(999, {businessChat: null}) + const evt = { + type: "newChatItems" as const, + user: makeUser(MAIN_USER_ID), + chatItems: [{chatInfo: {type: "group", groupInfo: nonBizGroup}, chatItem: ci}], + } + const sentBefore = chat.sent.length + await bot.onNewChatItems(evt) + expect(chat.sent.length).toBe(sentBefore) + }) +}) + +describe("Grok Join Flow", () => { + beforeEach(() => setup()) + + test("Grok receivedGroupInvitation → apiJoinGroup called", async () => { + // First need to set up a pending grok join + // Simulate the main profile side: add Grok to a group + await reachQueue() + addBotMessage("The team will reply to your message") + + // This kicks off activateGrok which adds member and waits + const joinComplete = new Promise(async (resolve) => { + // Simulate Grok invitation after a small delay + setTimeout(async () => { + const addedGrok = chat.added.find(a => a.contactId === GROK_CONTACT_ID) + if (addedGrok) { + const memberId = `member-${GROK_CONTACT_ID}` + await bot.onGrokGroupInvitation({ + type: "receivedGroupInvitation", + user: makeUser(GROK_USER_ID), + groupInfo: {...makeGroupInfo(GROK_LOCAL_GROUP_ID), membership: {memberId}}, + contact: {contactId: 99}, + fromMemberRole: GroupMemberRole.Admin, + memberRole: GroupMemberRole.Member, + }) + } + resolve() + }, 10) + }) + + // Don't await bot.onNewChatItems yet — let it start + const botPromise = bot.onNewChatItems(customerMessage("/grok")) + await joinComplete + // Complete the join + await bot.onGrokMemberConnected({ + type: "connectedToGroupMember", + user: makeUser(GROK_USER_ID), + groupInfo: makeGroupInfo(GROK_LOCAL_GROUP_ID), + member: {memberId: "bot-in-grok-view", groupMemberId: 9999}, + }) + await botPromise + + expect(chat.joined).toContain(GROK_LOCAL_GROUP_ID) + }) + + test("unmatched Grok invitation → buffered, not joined", async () => { + const evt = { + type: "receivedGroupInvitation" as const, + user: makeUser(GROK_USER_ID), + groupInfo: {...makeGroupInfo(999), membership: {memberId: "unknown-member"}}, + contact: {contactId: 99}, + fromMemberRole: GroupMemberRole.Admin, + memberRole: GroupMemberRole.Member, + } + await bot.onGrokGroupInvitation(evt) + expect(chat.joined.length).toBe(0) + }) + + test("buffered invitation drained after pendingGrokJoins set → apiJoinGroup called", async () => { + // Simulate the race: invitation arrives before pendingGrokJoins is set + const memberId = `member-${GROK_CONTACT_ID}` + const invEvt = { + type: "receivedGroupInvitation" as const, + user: makeUser(GROK_USER_ID), + groupInfo: {...makeGroupInfo(GROK_LOCAL_GROUP_ID), membership: {memberId}}, + contact: {contactId: 99}, + fromMemberRole: GroupMemberRole.Admin, + memberRole: GroupMemberRole.Member, + } + // Buffer the invitation (no pending join registered yet) + await bot.onGrokGroupInvitation(invEvt) + expect(chat.joined.length).toBe(0) + + // Now trigger activateGrok — apiAddMember returns, pendingGrokJoins set, buffer drained + const joinComplete = new Promise((resolve) => { + setTimeout(async () => { + // Grok connected after buffer drain processed the invitation + await bot.onGrokMemberConnected({ + type: "connectedToGroupMember", + user: makeUser(GROK_USER_ID), + groupInfo: makeGroupInfo(GROK_LOCAL_GROUP_ID), + member: {memberId: "bot-in-grok-view", groupMemberId: 9999}, + }) + resolve() + }, 20) + }) + + await reachQueue() + addBotMessage("The team will reply to your message") + const botPromise = bot.onNewChatItems(customerMessage("/grok")) + await joinComplete + await botPromise + await bot.flush() + + expect(chat.joined).toContain(GROK_LOCAL_GROUP_ID) + expectSentToGroup(CUSTOMER_GROUP_ID, "You are chatting with Grok") + }) + + test("per-message responses suppressed during activateGrok initial response", async () => { + await reachQueue() + addBotMessage("The team will reply to your message") + + // Customer's message visible in Grok's view (activateGrok reads it for initial response) + addCustomerMessageToHistory("Hello, I need help", GROK_LOCAL_GROUP_ID) + chat.groups.set(GROK_LOCAL_GROUP_ID, makeGroupInfo(GROK_LOCAL_GROUP_ID)) + + // Start /grok activation (fireAndForget) + const botPromise = bot.onNewChatItems(customerMessage("/grok")) + + // Wait for apiAddMember to complete + await new Promise(r => setTimeout(r, 10)) + + // Simulate Grok invitation → sets grokGroupMap/reverseGrokMap + const memberId = `member-${GROK_CONTACT_ID}` + await bot.onGrokGroupInvitation({ + type: "receivedGroupInvitation", + user: makeUser(GROK_USER_ID), + groupInfo: {...makeGroupInfo(GROK_LOCAL_GROUP_ID), membership: {memberId}}, + contact: {contactId: 99}, + fromMemberRole: GroupMemberRole.Admin, + memberRole: GroupMemberRole.Member, + }) + + // grokInitialResponsePending is set, reverseGrokMap is set. + // Simulate per-message event (as if message backlog arrived for Grok profile) + await bot.onGrokNewChatItems(grokViewCustomerMessage("Hello, I need help")) + + // Gating: per-message handler must NOT have called Grok API + expect(grokApi.calls.length).toBe(0) + + // Now complete the join → activateGrok sends initial combined response + await bot.onGrokMemberConnected({ + type: "connectedToGroupMember", + user: makeUser(GROK_USER_ID), + groupInfo: makeGroupInfo(GROK_LOCAL_GROUP_ID), + member: {memberId: "bot-in-grok-view", groupMemberId: 9999, memberContactId: undefined}, + }) + + await botPromise + await bot.flush() + + // Only 1 Grok API call: the initial combined response from activateGrok + expect(grokApi.calls.length).toBe(1) + expect(grokApi.calls[0].message).toContain("Hello, I need help") + }) + + test("per-message responses resume after activateGrok completes", async () => { + await reachGrok() + await bot.flush() + const callsAfterActivation = grokApi.calls.length + + // Send a new customer message via Grok's view — should be processed normally + addCustomerMessageToHistory("Follow-up question", GROK_LOCAL_GROUP_ID) + await bot.onGrokNewChatItems(grokViewCustomerMessage("Follow-up question")) + + expect(grokApi.calls.length).toBe(callsAfterActivation + 1) + expect(grokApi.calls[grokApi.calls.length - 1].message).toBe("Follow-up question") + }) + + test("activateGrok groupDuplicateMember path → gate cleared by outer finally", async () => { + // After reachGrok(), gate is cleared and reverseGrokMap is populated. + await reachGrok() + await bot.flush() + const callsBaseline = grokApi.calls.length + + // Second /grok while Grok is already present → apiAddMember throws duplicate. + // The outer try/finally must clear the gate even though the handler returns + // silently from inside the try — otherwise per-message responses stay + // suppressed for this group forever. + chat.apiAddMemberWillFail({chatError: {errorType: {type: "groupDuplicateMember"}}}) + await bot.onNewChatItems(customerMessage("/grok")) + await bot.flush() + + // Gate must be clear: a subsequent per-message event triggers Grok. + addCustomerMessageToHistory("another question", GROK_LOCAL_GROUP_ID) + await bot.onGrokNewChatItems(grokViewCustomerMessage("another question")) + expect(grokApi.calls.length).toBe(callsBaseline + 1) + }) +}) + +describe("Grok No-History Fallback", () => { + beforeEach(() => setup()) + + test("Grok joins but sees no customer messages → sends grokNoHistoryMessage", async () => { + chat.chatItems.set(GROK_LOCAL_GROUP_ID, []) + chat.groups.set(GROK_LOCAL_GROUP_ID, makeGroupInfo(GROK_LOCAL_GROUP_ID)) + + const grokJoinPromise = simulateGrokJoinSuccess() + await bot.onNewChatItems(customerMessage("/grok")) + await grokJoinPromise + await bot.flush() + expectAnySent("couldn't see your earlier messages") + }) +}) + +describe("Non-customer messages trigger card update", () => { + beforeEach(() => setup()) + + test("Grok response in customer group → card update scheduled", async () => { + await bot.onNewChatItems(grokResponseMessage("Grok says hi")) + chat.customData.set(CUSTOMER_GROUP_ID, {cardItemId: 900}) + await cards.flush() + expectCardDeleted(900) + }) + + test("team member message → card update scheduled", async () => { + await bot.onNewChatItems(teamMemberMessage("Team says hi")) + chat.customData.set(CUSTOMER_GROUP_ID, {cardItemId: 901}) + await cards.flush() + expectCardDeleted(901) + }) +}) + +describe("End-to-End Flows", () => { + beforeEach(() => setup()) + + test("WELCOME → QUEUE → /team → TEAM-PENDING → team msg → TEAM", async () => { + await bot.onNewChatItems(customerMessage("Help me")) + expectSentToGroup(CUSTOMER_GROUP_ID, "The team will reply to your message") + addBotMessage("The team will reply to your message") + + await bot.onNewChatItems(customerMessage("/team")) + expectSentToGroup(CUSTOMER_GROUP_ID, "We will reply within") + expectMemberAdded(CUSTOMER_GROUP_ID, TEAM_MEMBER_1_ID) + addBotMessage("We will reply within 24 hours.") + + chat.members.set(CUSTOMER_GROUP_ID, [makeTeamMember(TEAM_MEMBER_1_ID, "Alice")]) + const pendingState = await cards.deriveState(CUSTOMER_GROUP_ID) + expect(pendingState).toBe("TEAM-PENDING") + + addTeamMemberMessageToHistory("I'll help you", TEAM_MEMBER_1_ID) + await bot.onNewChatItems(teamMemberMessage("I'll help you")) + + const teamState = await cards.deriveState(CUSTOMER_GROUP_ID) + expect(teamState).toBe("TEAM") + }) + + test("WELCOME → /grok first msg → GROK", async () => { + const joinPromise = simulateGrokJoinSuccess() + await bot.onNewChatItems(customerMessage("/grok")) + await joinPromise + await bot.flush() + + expectSentToGroup(CUSTOMER_GROUP_ID, "You are chatting with Grok") + expectNotSentToGroup(CUSTOMER_GROUP_ID, "The team will reply to your message") + expect(chat.sentTo(TEAM_GROUP_ID).length).toBeGreaterThan(0) + }) + + test("multiple concurrent conversations are independent", async () => { + const GROUP_A = 101 + const GROUP_B = 102 + chat.groups.set(GROUP_A, makeGroupInfo(GROUP_A, {customerId: "cust-a"})) + chat.groups.set(GROUP_B, makeGroupInfo(GROUP_B, {customerId: "cust-b"})) + + const ciA = makeChatItem({dir: "groupRcv", text: "Help A", memberId: "cust-a"}) + await bot.onNewChatItems({ + type: "newChatItems", + user: makeUser(MAIN_USER_ID), + chatItems: [{chatInfo: {type: "group", groupInfo: makeGroupInfo(GROUP_A, {customerId: "cust-a"})}, chatItem: ciA}], + }) + + const ciB = makeChatItem({dir: "groupRcv", text: "Help B", memberId: "cust-b"}) + await bot.onNewChatItems({ + type: "newChatItems", + user: makeUser(MAIN_USER_ID), + chatItems: [{chatInfo: {type: "group", groupInfo: makeGroupInfo(GROUP_B, {customerId: "cust-b"})}, chatItem: ciB}], + }) + + expectSentToGroup(GROUP_A, "The team will reply to your message") + expectSentToGroup(GROUP_B, "The team will reply to your message") + }) +}) + +describe("Message Templates", () => { + test("welcomeMessage is a non-empty string", () => { + expect(typeof welcomeMessage).toBe("string") + expect(welcomeMessage.length).toBeGreaterThan(0) + }) + + test("grokActivatedMessage mentions chatting with Grok", () => { + expect(grokActivatedMessage).toContain("chatting with Grok") + }) + + test("teamLockedMessage mentions team mode", () => { + expect(teamLockedMessage).toContain("team mode") + }) + + test("queueMessage mentions hours", () => { + const msg = queueMessage("UTC", true) + expect(msg).toContain("hours") + }) +}) + +describe("State persistence in customData", () => { + beforeEach(() => setup()) + + test("first customer text writes state=QUEUE to customData", async () => { + await bot.onNewChatItems(customerMessage("Hello")) + expect(chat.customData.get(CUSTOMER_GROUP_ID)?.state).toBe("QUEUE") + }) + + test("/team writes state=TEAM-PENDING immediately (before team accepts)", async () => { + await reachQueue() + addBotMessage("The team will reply to your message") + await bot.onNewChatItems(customerMessage("/team")) + expect(chat.customData.get(CUSTOMER_GROUP_ID)?.state).toBe("TEAM-PENDING") + }) + + test("/grok writes state=GROK when activation succeeds", async () => { + await reachQueue() + addBotMessage("The team will reply to your message") + const joinPromise = simulateGrokJoinSuccess() + await bot.onNewChatItems(customerMessage("/grok")) + await joinPromise + await bot.flush() + expect(chat.customData.get(CUSTOMER_GROUP_ID)?.state).toBe("GROK") + }) + + test("/grok from QUEUE reverts state to QUEUE if activation fails", async () => { + await reachQueue() + addBotMessage("The team will reply to your message") + chat.apiAddMemberWillFail() + await bot.onNewChatItems(customerMessage("/grok")) + await bot.flush() + expect(chat.customData.get(CUSTOMER_GROUP_ID)?.state).toBe("QUEUE") + }) + + test("concurrent /team during Grok activation timeout does not demote state", async () => { + await reachQueue() + addBotMessage("The team will reply to your message") + + // Pause activateGrok at waitForGrokJoin so /team can run in the meantime. + // Patching apiAddMember won't work: it's wrapped in withMainProfile's mutex, + // which /team's activateTeam also needs. waitForGrokJoin awaits outside the + // mutex — that's the real race window in production. + let releaseJoin!: (joined: boolean) => void + ;(bot as any).waitForGrokJoin = () => + new Promise((resolve) => { releaseJoin = resolve }) + + // /grok: writes state=GROK optimistically, fire-and-forgets activateGrok. + await bot.onNewChatItems(customerMessage("/grok")) + expect(chat.customData.get(CUSTOMER_GROUP_ID)?.state).toBe("GROK") + + // Let activateGrok progress past apiAddMember into waitForGrokJoin. + await Promise.resolve() + await Promise.resolve() + + // /team while activateGrok is waiting for join — writes TEAM-PENDING + adds members. + await bot.onNewChatItems(customerMessage("/team")) + expect(chat.customData.get(CUSTOMER_GROUP_ID)?.state).toBe("TEAM-PENDING") + expectMemberAdded(CUSTOMER_GROUP_ID, TEAM_MEMBER_1_ID) + + // Simulate Grok join timeout — activateGrok's revertStateOnFail runs. + releaseJoin(false) + await bot.flush() + + // Fix asserts: revert guard sees state != "GROK" and leaves TEAM-PENDING alone. + expect(chat.customData.get(CUSTOMER_GROUP_ID)?.state).toBe("TEAM-PENDING") + }) + + test("first team text writes state=TEAM via gate", async () => { + await reachTeamPending() + addBotMessage("We will reply within 24 hours.") + chat.members.set(CUSTOMER_GROUP_ID, [makeTeamMember(TEAM_MEMBER_1_ID, "Alice")]) + await bot.onNewChatItems(teamMemberMessage("I'll help")) + expect(chat.customData.get(CUSTOMER_GROUP_ID)?.state).toBe("TEAM") + }) +}) + +describe("Card Preview Sender Prefixes", () => { + beforeEach(() => setup()) + + // Helper: extract preview line from card text posted to team group + function getCardPreview(): string { + const teamMsgs = chat.sentTo(TEAM_GROUP_ID) + const cardText = teamMsgs[0] + if (!cardText) return "" + const lines = cardText.split("\n") + // Card layout: header, state, preview, /'join N' — preview is second to last + return lines[lines.length - 2] || "" + } + + test("customer-only messages: first prefixed, rest not", async () => { + const gi = makeGroupInfo(CUSTOMER_GROUP_ID, {displayName: "Alice"}) + chat.groups.set(CUSTOMER_GROUP_ID, gi) + addCustomerMessageToHistory("Hello") + addCustomerMessageToHistory("Need help") + await cards.createCard(CUSTOMER_GROUP_ID, gi) + const preview = getCardPreview() + expect(preview).toContain("Alice: Hello") + expect(preview).toContain("!3 /! Need help") + // Second message must NOT have prefix (same sender) + expect(preview).not.toContain("Alice: Need help") + }) + + test("three consecutive customer messages: only first gets prefix", async () => { + const gi = makeGroupInfo(CUSTOMER_GROUP_ID, {displayName: "Alice"}) + chat.groups.set(CUSTOMER_GROUP_ID, gi) + addCustomerMessageToHistory("First") + addCustomerMessageToHistory("Second") + addCustomerMessageToHistory("Third") + await cards.createCard(CUSTOMER_GROUP_ID, gi) + const preview = getCardPreview() + const prefixCount = (preview.match(/Alice:/g) || []).length + expect(prefixCount).toBe(1) + expect(preview).toContain("Alice: First") + }) + + test("alternating customer and Grok: each sender change triggers prefix", async () => { + const gi = makeGroupInfo(CUSTOMER_GROUP_ID, {displayName: "Alice"}) + chat.groups.set(CUSTOMER_GROUP_ID, gi) + addCustomerMessageToHistory("How does encryption work?") + addGrokMessageToHistory("SimpleX uses double ratchet") + addCustomerMessageToHistory("And metadata?") + await cards.createCard(CUSTOMER_GROUP_ID, gi) + const preview = getCardPreview() + expect(preview).toContain("Alice: How does encryption work?") + expect(preview).toContain("Grok: SimpleX uses double ratchet") + expect(preview).toContain("Alice: And metadata?") + }) + + test("Grok identified by grokContactId, not by display name", async () => { + const gi = makeGroupInfo(CUSTOMER_GROUP_ID, {displayName: "Alice"}) + chat.groups.set(CUSTOMER_GROUP_ID, gi) + // Grok message uses GROK_CONTACT_ID → labeled "Grok" regardless of memberProfile + addGrokMessageToHistory("I am Grok") + await cards.createCard(CUSTOMER_GROUP_ID, gi) + const preview = getCardPreview() + expect(preview).toContain("Grok: I am Grok") + }) + + test("team member messages use their memberProfile displayName", async () => { + const gi = makeGroupInfo(CUSTOMER_GROUP_ID, {displayName: "Alice"}) + chat.groups.set(CUSTOMER_GROUP_ID, gi) + addCustomerMessageToHistory("Help please") + // Add team member message with explicit display name + const teamCi = makeChatItem({ + dir: "groupRcv", text: "On it!", + memberId: `team-${TEAM_MEMBER_1_ID}`, memberContactId: TEAM_MEMBER_1_ID, + memberDisplayName: "Bob", + }) + const items = chat.chatItems.get(CUSTOMER_GROUP_ID) || [] + items.push(teamCi) + chat.chatItems.set(CUSTOMER_GROUP_ID, items) + await cards.createCard(CUSTOMER_GROUP_ID, gi) + const preview = getCardPreview() + expect(preview).toContain("Alice: Help please") + expect(preview).toContain("Bob: On it!") + }) + + test("bot messages (groupSnd) excluded from preview", async () => { + const gi = makeGroupInfo(CUSTOMER_GROUP_ID, {displayName: "Alice"}) + chat.groups.set(CUSTOMER_GROUP_ID, gi) + addCustomerMessageToHistory("Hello") + addBotMessage("The team will reply to your message") + addCustomerMessageToHistory("Thanks") + await cards.createCard(CUSTOMER_GROUP_ID, gi) + const preview = getCardPreview() + expect(preview).not.toContain("The team will reply to your message") + // Both customer messages are from the same sender — only first prefixed + expect(preview).toContain("Alice: Hello") + expect(preview).toContain("!3 /! Thanks") + }) + + test("media-only message shows type label", async () => { + const gi = makeGroupInfo(CUSTOMER_GROUP_ID, {displayName: "Alice"}) + chat.groups.set(CUSTOMER_GROUP_ID, gi) + const imgCi = makeChatItem({dir: "groupRcv", text: "", memberId: CUSTOMER_ID, msgType: "image"}) + const items = chat.chatItems.get(CUSTOMER_GROUP_ID) || [] + items.push(imgCi) + chat.chatItems.set(CUSTOMER_GROUP_ID, items) + await cards.createCard(CUSTOMER_GROUP_ID, gi) + const preview = getCardPreview() + expect(preview).toContain("[image]") + }) + + test("media message with caption shows label + text", async () => { + const gi = makeGroupInfo(CUSTOMER_GROUP_ID, {displayName: "Alice"}) + chat.groups.set(CUSTOMER_GROUP_ID, gi) + const imgCi = makeChatItem({dir: "groupRcv", text: "screenshot of the bug", memberId: CUSTOMER_ID, msgType: "image"}) + const items = chat.chatItems.get(CUSTOMER_GROUP_ID) || [] + items.push(imgCi) + chat.chatItems.set(CUSTOMER_GROUP_ID, items) + await cards.createCard(CUSTOMER_GROUP_ID, gi) + const preview = getCardPreview() + expect(preview).toContain("[image] screenshot of the bug") + }) + + test("long message truncated with [truncated]", async () => { + const gi = makeGroupInfo(CUSTOMER_GROUP_ID, {displayName: "Alice"}) + chat.groups.set(CUSTOMER_GROUP_ID, gi) + const longMsg = "x".repeat(300) + addCustomerMessageToHistory(longMsg) + await cards.createCard(CUSTOMER_GROUP_ID, gi) + const preview = getCardPreview() + expect(preview).toContain("[truncated]") + // Truncated at ~200 chars + prefix + expect(preview.length).toBeLessThan(300) + }) + + test("total overflow truncates oldest messages, keeps newest", async () => { + const gi = makeGroupInfo(CUSTOMER_GROUP_ID, {displayName: "Alice"}) + chat.groups.set(CUSTOMER_GROUP_ID, gi) + // Add many messages to exceed 1000 chars total + for (let i = 0; i < 20; i++) { + addCustomerMessageToHistory(`Message number ${i} with some extra padding text to fill space quickly`) + } + await cards.createCard(CUSTOMER_GROUP_ID, gi) + const preview = getCardPreview() + expect(preview).toContain("[truncated]") + // Newest messages should be present, oldest truncated + expect(preview).toContain("Message number 19") + expect(preview).not.toContain("Message number 0") + // Should not include all 20 messages + const slashCount = (preview.match(/ \/ /g) || []).length + expect(slashCount).toBeLessThan(19) + }) + + test("empty preview when no messages", async () => { + const gi = makeGroupInfo(CUSTOMER_GROUP_ID, {displayName: "Alice"}) + chat.groups.set(CUSTOMER_GROUP_ID, gi) + await cards.createCard(CUSTOMER_GROUP_ID, gi) + const preview = getCardPreview() + expect(preview).toBe('""') + }) + + test("only bot messages → empty preview", async () => { + const gi = makeGroupInfo(CUSTOMER_GROUP_ID, {displayName: "Alice"}) + chat.groups.set(CUSTOMER_GROUP_ID, gi) + addBotMessage("Welcome!") + addBotMessage("Queue message") + await cards.createCard(CUSTOMER_GROUP_ID, gi) + const preview = getCardPreview() + expect(preview).toBe('""') + }) + + test("newlines in message text → replaced with spaces", async () => { + const gi = makeGroupInfo(CUSTOMER_GROUP_ID, {displayName: "Alice"}) + chat.groups.set(CUSTOMER_GROUP_ID, gi) + addCustomerMessageToHistory("line1\nline2\n\nline3") + await cards.createCard(CUSTOMER_GROUP_ID, gi) + const preview = getCardPreview() + expect(preview).not.toContain("\n") + expect(preview).toContain("line1 line2 line3") + }) + + test("newlines in customer display name → sanitized in card header", async () => { + const gi = makeGroupInfo(CUSTOMER_GROUP_ID, {displayName: "First\nLast"}) + chat.groups.set(CUSTOMER_GROUP_ID, gi) + addCustomerMessageToHistory("Hello") + await cards.createCard(CUSTOMER_GROUP_ID, gi) + const teamMsgs = chat.sentTo(TEAM_GROUP_ID) + expect(teamMsgs.length).toBe(1) + const cardText = teamMsgs[0] + // Card header should have sanitized name (no newlines) + expect(cardText).toContain("First Last") + // Exactly 4 lines: header, state, preview, /'join N' + expect(cardText.split("\n").length).toBe(4) + expect(cardText).toContain(`/'join ${CUSTOMER_GROUP_ID}'`) + }) +}) + +describe("Restart Card Recovery", () => { + beforeEach(() => setup()) + + test("refreshAllCards refreshes groups with active cards", async () => { + const GROUP_A = 101 + const GROUP_B = 102 + const GROUP_NO_CARD = 103 + chat.groups.set(GROUP_A, makeGroupInfo(GROUP_A)) + chat.groups.set(GROUP_B, makeGroupInfo(GROUP_B)) + chat.groups.set(GROUP_NO_CARD, makeGroupInfo(GROUP_NO_CARD)) + chat.customData.set(GROUP_A, {cardItemId: 501}) + chat.customData.set(GROUP_B, {cardItemId: 503}) + + await cards.refreshAllCards() + + expectCardDeleted(501) + expectCardDeleted(503) + expect(chat.sentTo(TEAM_GROUP_ID).length).toBe(2) // 2 cards × 1 message each + }) + + test("refreshAllCards with no active cards → no-op", async () => { + await cards.refreshAllCards() + expect(chat.deleted.length).toBe(0) + expect(chat.sentTo(TEAM_GROUP_ID).length).toBe(0) + }) + + test("refreshAllCards ignores groups without cardItemId in customData", async () => { + const GROUP_A = 101 + chat.groups.set(GROUP_A, makeGroupInfo(GROUP_A)) + chat.customData.set(GROUP_A, {someOtherData: true}) + + await cards.refreshAllCards() + expect(chat.deleted.length).toBe(0) + expect(chat.sentTo(TEAM_GROUP_ID).length).toBe(0) + }) + + test("refreshAllCards orders by cardItemId ascending (oldest first, newest last)", async () => { + // GROUP_C has higher cardItemId (more recent) than GROUP_A and GROUP_B + const GROUP_A = 101, GROUP_B = 102, GROUP_C = 103 + chat.groups.set(GROUP_A, makeGroupInfo(GROUP_A)) + chat.groups.set(GROUP_B, makeGroupInfo(GROUP_B)) + chat.groups.set(GROUP_C, makeGroupInfo(GROUP_C)) + chat.customData.set(GROUP_C, {cardItemId: 900}) // newest — should refresh last + chat.customData.set(GROUP_A, {cardItemId: 100}) // oldest — should refresh first + chat.customData.set(GROUP_B, {cardItemId: 500}) // middle + + await cards.refreshAllCards() + + // Verify deletion order: oldest cardItemId first, newest last + expect(chat.deleted.length).toBe(3) + expect(chat.deleted[0].itemIds).toEqual([100]) + expect(chat.deleted[1].itemIds).toEqual([500]) + expect(chat.deleted[2].itemIds).toEqual([900]) + + // Newest card is posted last → appears at bottom of team group + const teamMsgs = chat.sentTo(TEAM_GROUP_ID) + expect(teamMsgs.length).toBe(3) // 3 cards × 1 message each + }) + + test("refreshAllCards skips cards marked complete", async () => { + const GROUP_A = 101, GROUP_B = 102 + chat.groups.set(GROUP_A, makeGroupInfo(GROUP_A)) + chat.groups.set(GROUP_B, makeGroupInfo(GROUP_B)) + chat.customData.set(GROUP_A, {cardItemId: 100, complete: true}) + chat.customData.set(GROUP_B, {cardItemId: 200}) + + await cards.refreshAllCards() + + expect(chat.deleted.length).toBe(1) + expect(chat.deleted[0].itemIds).toEqual([200]) + expect(chat.deleted.some(d => d.itemIds.includes(100))).toBe(false) + }) + + test("refreshAllCards deletes old card before reposting", async () => { + const GROUP_A = 101 + chat.groups.set(GROUP_A, makeGroupInfo(GROUP_A)) + chat.customData.set(GROUP_A, {cardItemId: 501}) + + await cards.refreshAllCards() + + // Old card should be deleted + expect(chat.deleted.length).toBe(1) + expect(chat.deleted[0].itemIds).toEqual([501]) + // New card posted + expect(chat.sentTo(TEAM_GROUP_ID).length).toBe(1) + }) + + test("refreshAllCards ignores delete failure (>24h old card)", async () => { + const GROUP_A = 101 + chat.groups.set(GROUP_A, makeGroupInfo(GROUP_A)) + chat.customData.set(GROUP_A, {cardItemId: 501}) + chat.apiDeleteChatItemsWillFail() + + await cards.refreshAllCards() + + // Delete failed but new card still posted + expect(chat.sentTo(TEAM_GROUP_ID).length).toBe(1) + // customData updated with new cardItemId + const newData = chat.customData.get(GROUP_A) + expect(typeof newData.cardItemId).toBe("number") + expect(newData.cardItemId).not.toBe(501) // new ID, not the old one + }) + + test("card flush writes complete: true for auto-completed conversations", async () => { + const GROUP_A = 101 + chat.groups.set(GROUP_A, makeGroupInfo(GROUP_A)) + chat.members.set(GROUP_A, [makeTeamMember(TEAM_MEMBER_1_ID, "Alice")]) + // Team member message from 4 hours ago (> completeHours=3h) → auto-complete + const oldCi = makeChatItem({dir: "groupRcv", text: "Resolved!", memberId: `team-${TEAM_MEMBER_1_ID}`, memberContactId: TEAM_MEMBER_1_ID}) + oldCi.meta.createdAt = new Date(Date.now() - 4 * 3600_000).toISOString() + chat.chatItems.set(GROUP_A, [oldCi]) + // Create initial card data + chat.customData.set(GROUP_A, {cardItemId: 500}) + + cards.scheduleUpdate(GROUP_A) + await cards.flush() + + const data = chat.customData.get(GROUP_A) + expect(data.complete).toBe(true) + }) + + test("card flush clears complete flag when conversation becomes active again", async () => { + const GROUP_A = 101 + chat.groups.set(GROUP_A, makeGroupInfo(GROUP_A)) + chat.members.set(GROUP_A, [makeTeamMember(TEAM_MEMBER_1_ID, "Alice")]) + // Team member message from 4h ago + recent customer message → NOT complete + const teamCi = makeChatItem({dir: "groupRcv", text: "Resolved!", memberId: `team-${TEAM_MEMBER_1_ID}`, memberContactId: TEAM_MEMBER_1_ID}) + teamCi.meta.createdAt = new Date(Date.now() - 4 * 3600_000).toISOString() + const custCi = makeChatItem({dir: "groupRcv", text: "Actually one more question", memberId: CUSTOMER_ID}) + chat.chatItems.set(GROUP_A, [teamCi, custCi]) + // Previously complete + chat.customData.set(GROUP_A, {cardItemId: 500, complete: true}) + + cards.scheduleUpdate(GROUP_A) + await cards.flush() + + const data = chat.customData.get(GROUP_A) + expect(data.complete).toBeUndefined() + }) + + test("refreshAllCards continues on individual card failure", async () => { + const GROUP_A = 101, GROUP_B = 102 + chat.groups.set(GROUP_A, makeGroupInfo(GROUP_A)) + chat.groups.set(GROUP_B, makeGroupInfo(GROUP_B)) + chat.customData.set(GROUP_A, {cardItemId: 100}) + chat.customData.set(GROUP_B, {cardItemId: 200}) + + chat.apiDeleteChatItemsWillFail() + await cards.refreshAllCards() + expectCardDeleted(200) + }) +}) + +describe("joinedGroupMember Event Filtering", () => { + beforeEach(() => setup()) + + test("joinedGroupMember in non-team group → ignored (no DM)", async () => { + const member = {memberId: "someone", groupMemberId: 9000, memberContactId: null, memberStatus: GroupMemberStatus.Connected, memberProfile: {displayName: "Someone"}} + await bot.onJoinedGroupMember(joinedEvent(CUSTOMER_GROUP_ID, member)) + expect(chat.rawCmds.length).toBe(0) + expect(chat.sent.filter(s => s.chat[0] === ChatType.Direct).length).toBe(0) + }) + + test("joinedGroupMember from wrong user → ignored", async () => { + const member = {memberId: "someone", groupMemberId: 9001, memberContactId: null, memberStatus: GroupMemberStatus.Connected, memberProfile: {displayName: "Someone"}} + await bot.onJoinedGroupMember(joinedEvent(TEAM_GROUP_ID, member, GROK_USER_ID)) + expect(chat.rawCmds.length).toBe(0) + }) +}) + +describe("parseConfig Validation", () => { + const baseArgs = ["--team-group", "Support"] + + test("--complete-hours non-numeric → throws", () => { + expect(() => parseConfig([...baseArgs, "--complete-hours", "abc"])) + .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(/required option '--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", () => { + // 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"/) + }) + + test("--card-flush-seconds non-numeric → throws", () => { + expect(() => parseConfig([...baseArgs, "--card-flush-seconds", "xyz"])) + .toThrow(/--card-flush-seconds must be a non-negative integer, got "xyz"/) + }) + + test("--timezone invalid IANA → throws", () => { + expect(() => parseConfig([...baseArgs, "--timezone", "Not/AZone"])) + .toThrow(/--timezone "Not\/AZone" is not a valid IANA time zone/) + }) + + test("--complete-hours 0 → allowed (disables auto-complete)", () => { + const cfg = parseConfig([...baseArgs, "--complete-hours", "0"]) + expect(cfg.completeHours).toBe(0) + }) + + test("valid IANA timezone → accepted", () => { + const cfg = parseConfig([...baseArgs, "--timezone", "America/New_York"]) + expect(cfg.timezone).toBe("America/New_York") + }) +}) + +describe("GrokApiClient HTTP timeout", () => { + test("chat() calls AbortSignal.timeout(60_000) and passes the signal to fetch", async () => { + const timeoutSpy = vi.spyOn(AbortSignal, "timeout") + const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response(JSON.stringify({choices: [{message: {content: "ok"}}]}), {status: 200}), + ) + + const client = new GrokApiClient("test-key", "system prompt") + await client.chat([], "hello") + + expect(timeoutSpy).toHaveBeenCalledWith(60_000) + expect((fetchSpy.mock.calls[0][1] as RequestInit).signal).toBeInstanceOf(AbortSignal) + + fetchSpy.mockRestore() + timeoutSpy.mockRestore() + }) +}) + +// Lazy per-group command sync. sendToGroup always calls +// apiUpdateGroupProfile on the first send per group when the group's +// stored groupPreferences.commands don't match desiredCommands. Each +// group is synced at most once per process (cache hit on subsequent +// sends). +describe("Command sync in sendToGroup", () => { + beforeEach(() => setup()) + + test("first send → apiUpdateGroupProfile called once with merged commands", async () => { + await bot.sendToGroup(CUSTOMER_GROUP_ID, "Hello, just a greeting.") + expect(chat.profileUpdates).toHaveLength(1) + const {groupId, profile} = chat.profileUpdates[0] + expect(groupId).toBe(CUSTOMER_GROUP_ID) + expect(profile.groupPreferences.commands).toEqual(DESIRED_COMMANDS) + // Existing groupProfile fields (displayName, fullName) are preserved. + expect(profile.displayName).toBe(`Group${CUSTOMER_GROUP_ID}`) + expect(profile.fullName).toBe("") + // The actual message still goes out after the sync. + expect(chat.lastSentTo(CUSTOMER_GROUP_ID)).toBe("Hello, just a greeting.") + }) + + test("group already has desired commands → no apiUpdateGroupProfile, but still cached", async () => { + const gi = makeGroupInfo(CUSTOMER_GROUP_ID) + gi.groupProfile.groupPreferences = {commands: DESIRED_COMMANDS} + chat.groups.set(CUSTOMER_GROUP_ID, gi) + + await bot.sendToGroup(CUSTOMER_GROUP_ID, "Click /grok for help.") + expect(chat.profileUpdates).toHaveLength(0) + // Cache was populated — a subsequent send even against a divergent DB + // won't re-check. + gi.groupProfile.groupPreferences = {commands: []} + await bot.sendToGroup(CUSTOMER_GROUP_ID, "Send /team for a human.") + expect(chat.profileUpdates).toHaveLength(0) + }) + + test("cache: two sends to same group → sync only once", async () => { + await bot.sendToGroup(CUSTOMER_GROUP_ID, "Click /grok first.") + await bot.sendToGroup(CUSTOMER_GROUP_ID, "Or send /team.") + expect(chat.profileUpdates).toHaveLength(1) + expect(chat.sentTo(CUSTOMER_GROUP_ID)).toHaveLength(2) + }) + + test("independent per group: different groups each sync separately", async () => { + const gId2 = 101 + chat.groups.set(gId2, makeGroupInfo(gId2)) + await bot.sendToGroup(CUSTOMER_GROUP_ID, "Click /grok.") + await bot.sendToGroup(gId2, "Send /team.") + expect(chat.profileUpdates.map(p => p.groupId).sort()).toEqual([CUSTOMER_GROUP_ID, gId2].sort()) + }) + + test("merge preserves existing group preference fields (files, etc.)", async () => { + const gi = makeGroupInfo(CUSTOMER_GROUP_ID) + gi.groupProfile.groupPreferences = { + files: {enable: "on"}, + reactions: {enable: "on"}, + } + chat.groups.set(CUSTOMER_GROUP_ID, gi) + + await bot.sendToGroup(CUSTOMER_GROUP_ID, "Click /grok.") + expect(chat.profileUpdates).toHaveLength(1) + const prefs = chat.profileUpdates[0].profile.groupPreferences + expect(prefs.commands).toEqual(DESIRED_COMMANDS) + expect(prefs.files).toEqual({enable: "on"}) + expect(prefs.reactions).toEqual({enable: "on"}) + }) +}) diff --git a/apps/simplex-support-bot/package-lock.json b/apps/simplex-support-bot/package-lock.json new file mode 100644 index 0000000000..c6bc006a4b --- /dev/null +++ b/apps/simplex-support-bot/package-lock.json @@ -0,0 +1,2061 @@ +{ + "name": "simplex-chat-support-bot", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "simplex-chat-support-bot", + "version": "0.1.0", + "license": "AGPL-3.0", + "dependencies": { + "@simplex-chat/types": "^0.5.0", + "async-mutex": "^0.5.0", + "commander": "^14.0.3", + "simplex-chat": "^6.5.0-beta.10" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "typescript": "^5.9.3", + "vitest": "^1.6.1" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jest/schemas": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", + "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sinclair/typebox": "^0.27.8" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.2.tgz", + "integrity": "sha512-dnlp69efPPg6Uaw2dVqzWRfAWRnYVb1XJ8CyyhIbZeaq4CA5/mLeZ1IEt9QqQxmbdvagjLIm2ZL8BxXv5lH4Yw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.2.tgz", + "integrity": "sha512-OqZTwDRDchGRHHm/hwLOL7uVPB9aUvI0am/eQuWMNyFHf5PSEQmyEeYYheA0EPPKUO/l0uigCp+iaTjoLjVoHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.2.tgz", + "integrity": "sha512-UwRE7CGpvSVEQS8gUMBe1uADWjNnVgP3Iusyda1nSRwNDCsRjnGc7w6El6WLQsXmZTbLZx9cecegumcitNfpmA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.2.tgz", + "integrity": "sha512-gjEtURKLCC5VXm1I+2i1u9OhxFsKAQJKTVB8WvDAHF+oZlq0GTVFOlTlO1q3AlCTE/DF32c16ESvfgqR7343/g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.2.tgz", + "integrity": "sha512-Bcl6CYDeAgE70cqZaMojOi/eK63h5Me97ZqAQoh77VPjMysA/4ORQBRGo3rRy45x4MzVlU9uZxs8Uwy7ZaKnBw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.2.tgz", + "integrity": "sha512-LU+TPda3mAE2QB0/Hp5VyeKJivpC6+tlOXd1VMoXV/YFMvk/MNk5iXeBfB4MQGRWyOYVJ01625vjkr0Az98OJQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.2.tgz", + "integrity": "sha512-2QxQrM+KQ7DAW4o22j+XZ6RKdxjLD7BOWTP0Bv0tmjdyhXSsr2Ul1oJDQqh9Zf5qOwTuTc7Ek83mOFaKnodPjg==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.2.tgz", + "integrity": "sha512-TbziEu2DVsTEOPif2mKWkMeDMLoYjx95oESa9fkQQK7r/Orta0gnkcDpzwufEcAO2BLBsD7mZkXGFqEdMRRwfw==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.2.tgz", + "integrity": "sha512-bO/rVDiDUuM2YfuCUwZ1t1cP+/yqjqz+Xf2VtkdppefuOFS2OSeAfgafaHNkFn0t02hEyXngZkxtGqXcXwO8Rg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.2.tgz", + "integrity": "sha512-hr26p7e93Rl0Za+JwW7EAnwAvKkehh12BU1Llm9Ykiibg4uIr2rbpxG9WCf56GuvidlTG9KiiQT/TXT1yAWxTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.2.tgz", + "integrity": "sha512-pOjB/uSIyDt+ow3k/RcLvUAOGpysT2phDn7TTUB3n75SlIgZzM6NKAqlErPhoFU+npgY3/n+2HYIQVbF70P9/A==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.2.tgz", + "integrity": "sha512-2/w+q8jszv9Ww1c+6uJT3OwqhdmGP2/4T17cu8WuwyUuuaCDDJ2ojdyYwZzCxx0GcsZBhzi3HmH+J5pZNXnd+Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.2.tgz", + "integrity": "sha512-11+aL5vKheYgczxtPVVRhdptAM2H7fcDR5Gw4/bTcteuZBlH4oP9f5s9zYO9aGZvoGeBpqXI/9TZZihZ609wKw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.2.tgz", + "integrity": "sha512-i16fokAGK46IVZuV8LIIwMdtqhin9hfYkCh8pf8iC3QU3LpwL+1FSFGej+O7l3E/AoknL6Dclh2oTdnRMpTzFQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.2.tgz", + "integrity": "sha512-49FkKS6RGQoriDSK/6E2GkAsAuU5kETFCh7pG4yD/ylj9rKhTmO3elsnmBvRD4PgJPds5W2PkhC82aVwmUcJ7A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.2.tgz", + "integrity": "sha512-mjYNkHPfGpUR00DuM1ZZIgs64Hpf4bWcz9Z41+4Q+pgDx73UwWdAYyf6EG/lRFldmdHHzgrYyge5akFUW0D3mQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.2.tgz", + "integrity": "sha512-ALyvJz965BQk8E9Al/JDKKDLH2kfKFLTGMlgkAbbYtZuJt9LU8DW3ZoDMCtQpXAltZxwBHevXz5u+gf0yA0YoA==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.2.tgz", + "integrity": "sha512-UQjrkIdWrKI626Du8lCQ6MJp/6V1LAo2bOK9OTu4mSn8GGXIkPXk/Vsp4bLHCd9Z9Iz2OTEaokUE90VweJgIYQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.2.tgz", + "integrity": "sha512-bTsRGj6VlSdn/XD4CGyzMnzaBs9bsRxy79eTqTCBsA8TMIEky7qg48aPkvJvFe1HyzQ5oMZdg7AnVlWQSKLTnw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.2.tgz", + "integrity": "sha512-6d4Z3534xitaA1FcMWP7mQPq5zGwBmGbhphh2DwaA1aNIXUu3KTOfwrWpbwI4/Gr0uANo7NTtaykFyO2hPuFLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.2.tgz", + "integrity": "sha512-NetAg5iO2uN7eB8zE5qrZ3CSil+7IJt4WDFLcC75Ymywq1VZVD6qJ6EvNLjZ3rEm6gB7XW5JdT60c6MN35Z85Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.2.tgz", + "integrity": "sha512-NCYhOotpgWZ5kdxCZsv6Iudx0wX8980Q/oW4pNFNihpBKsDbEA1zpkfxJGC0yugsUuyDZ7gL37dbzwhR0VI7pQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.2.tgz", + "integrity": "sha512-RXsaOqXxfoUBQoOgvmmijVxJnW2IGB0eoMO7F8FAjaj0UTywUO/luSqimWBJn04WNgUkeNhh7fs7pESXajWmkg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.2.tgz", + "integrity": "sha512-qdAzEULD+/hzObedtmV6iBpdL5TIbKVztGiK7O3/KYSf+HIzU257+MX1EXJcyIiDbMAqmbwaufcYPvyRryeZtA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.2.tgz", + "integrity": "sha512-Nd/SgG27WoA9e+/TdK74KnHz852TLa94ovOYySo/yMPuTmpckK/jIF2jSwS3g7ELSKXK13/cVdmg1Z/DaCWKxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@simplex-chat/types": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@simplex-chat/types/-/types-0.5.0.tgz", + "integrity": "sha512-f680CRlf+O8WfIaPb7wxVj3PB8mTIOE+HqmetCSe0NBheVAjU3ovg3+zkrWwDlavrHuCLbb7Gmeu4HyNtjDfog==", + "license": "AGPL-3.0", + "dependencies": { + "typescript": "^5.9.2" + } + }, + "node_modules/@sinclair/typebox": { + "version": "0.27.10", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.10.tgz", + "integrity": "sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "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, + "license": "MIT", + "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==", + "license": "MIT", + "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", + "integrity": "sha512-jXL+9+ZNIJKruofqXuuTClf44eSpcHlgj3CiuNihUF3Ioujtmc0zIa3UJOW5RjDK1YLBJZnWBlPuqhYycLioog==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "1.6.1", + "@vitest/utils": "1.6.1", + "chai": "^4.3.10" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-1.6.1.tgz", + "integrity": "sha512-3nSnYXkVkf3mXFfE7vVyPmi3Sazhb/2cfZGGs0JRzFsPFvAMBEcrweV1V1GsrstdXeKCTXlJbvnQwGWgEIHmOA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "1.6.1", + "p-limit": "^5.0.0", + "pathe": "^1.1.1" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-1.6.1.tgz", + "integrity": "sha512-WvidQuWAzU2p95u8GAKlRMqMyN1yOJkGHnx3M1PL9Raf7AQ1kwLKg04ADlCa3+OXUZE7BceOhVZiuWAbzCKcUQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "magic-string": "^0.30.5", + "pathe": "^1.1.1", + "pretty-format": "^29.7.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-1.6.1.tgz", + "integrity": "sha512-MGcMmpGkZebsMZhbQKkAf9CX5zGvjkBTqf8Zx3ApYWXr3wG+QvEu2eXWfnIIWYSJExIp4V9FCKDEeygzkYrXMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^2.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-1.6.1.tgz", + "integrity": "sha512-jOrrUvXM4Av9ZWiG1EajNto0u96kWAhJ1LmPmJhXXQx/32MecEKd10pOLYgS2BQx1TgkGhloPU1ArDW2vvaY6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "diff-sequences": "^29.6.3", + "estree-walker": "^3.0.3", + "loupe": "^2.3.7", + "pretty-format": "^29.7.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-walk": { + "version": "8.3.5", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz", + "integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.11.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/assertion-error": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.1.0.tgz", + "integrity": "sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/async-mutex": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/async-mutex/-/async-mutex-0.5.0.tgz", + "integrity": "sha512-1A94B18jkJ3DYq284ohPxoXbfTA5HsQ7/Mf4DEhcyLx3Bz27Rh59iScbB6EPiP+B+joue6YCxcMXSbFC1tZKwA==", + "license": "MIT", + "dependencies": { + "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==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/chai": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-4.5.0.tgz", + "integrity": "sha512-RITGBfijLkBddZvnn8jdqoTypxvqbOLYQkGGxXzeFjVHvudaPw0HNFD9x928/eUwYWd2dPCugVqspGALTZZQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^1.1.0", + "check-error": "^1.0.3", + "deep-eql": "^4.1.3", + "get-func-name": "^2.0.2", + "loupe": "^2.3.6", + "pathval": "^1.1.1", + "type-detect": "^4.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/check-error": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-1.0.3.tgz", + "integrity": "sha512-iKEoDYaRmd1mxM90a2OEfWhjsjPpYPuQ+lMYsoxB126+t8fw7ySEO48nmDg5COTjxDI65/Y2OWpeEHk3ZOe8zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-func-name": "^2.0.2" + }, + "engines": { + "node": "*" + } + }, + "node_modules/commander": { + "version": "14.0.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", + "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==", + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/confbox": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz", + "integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-eql": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-4.1.4.tgz", + "integrity": "sha512-SUwdGfqdKOwxCPeVYjwSyRpJ7Z+fhpwIAtmCUdZIWZ/YP5R9WAsyuSgpLVDi9bjWoN2LXHNss/dk3urXtdQxGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-detect": "^4.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/diff-sequences": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz", + "integrity": "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==", + "dev": true, + "license": "MIT", + "engines": { + "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==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/execa": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-8.0.1.tgz", + "integrity": "sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^8.0.1", + "human-signals": "^5.0.0", + "is-stream": "^3.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^5.1.0", + "onetime": "^6.0.0", + "signal-exit": "^4.1.0", + "strip-final-newline": "^3.0.0" + }, + "engines": { + "node": ">=16.17" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/execa/node_modules/get-stream": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-8.0.1.tgz", + "integrity": "sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + }, + "funding": { + "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==", + "license": "BSD-2-Clause", + "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==", + "license": "MIT" + }, + "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==", + "license": "MIT", + "dependencies": { + "pend": "~1.2.0" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/get-func-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.2.tgz", + "integrity": "sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ==", + "dev": true, + "license": "MIT", + "engines": { + "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==", + "license": "MIT", + "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", + "integrity": "sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=16.17.0" + } + }, + "node_modules/is-stream": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-3.0.0.tgz", + "integrity": "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/local-pkg": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-0.5.1.tgz", + "integrity": "sha512-9rrA30MRRP3gBD3HTGnC6cDFpaE1kVDWxWgqWJUN0RvDNAo+Nz/9GxB+nHOH0ifbVFy0hSA1V6vFDvnx54lTEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mlly": "^1.7.3", + "pkg-types": "^1.2.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/loupe": { + "version": "2.3.7", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-2.3.7.tgz", + "integrity": "sha512-zSMINGVYkdpYSOBmLi0D1Uo7JU9nVdQKrHxC8eYlV+9YKK9WePqAlL7lSlorG/U2Fw1w0hTBmaa/jrQ3UbPHtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-func-name": "^2.0.1" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/mimic-fn": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-4.0.0.tgz", + "integrity": "sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mlly": { + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.2.tgz", + "integrity": "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.16.0", + "pathe": "^2.0.3", + "pkg-types": "^1.3.1", + "ufo": "^1.6.3" + } + }, + "node_modules/mlly/node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "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==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "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==", + "license": "MIT", + "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", + "integrity": "sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^4.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/npm-run-path/node_modules/path-key": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", + "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "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==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/onetime": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-6.0.0.tgz", + "integrity": "sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-fn": "^4.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-limit": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-5.0.0.tgz", + "integrity": "sha512-/Eaoq+QyLSiXQ4lyYV23f14mZRQcXnxfHrN0vCai+ak9G0pp9iEQukIIZq5NccEvwRB8PUnZT0KsOoDCINS1qQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^1.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-1.1.1.tgz", + "integrity": "sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==", + "dev": true, + "license": "MIT", + "engines": { + "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==", + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/pkg-types": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz", + "integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "confbox": "^0.1.8", + "mlly": "^1.7.4", + "pathe": "^2.0.1" + } + }, + "node_modules/pkg-types/node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/postcss": { + "version": "8.5.10", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz", + "integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/pretty-format": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", + "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "^29.6.3", + "ansi-styles": "^5.0.0", + "react-is": "^18.0.0" + }, + "engines": { + "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==", + "license": "MIT", + "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", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/rollup": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.2.tgz", + "integrity": "sha512-J9qZyW++QK/09NyN/zeO0dG/1GdGfyp9lV8ajHnRVLfo/uFsbji5mHnDgn/qYdUHyCkM2N+8VyspgZclfAh0eQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.2", + "@rollup/rollup-android-arm64": "4.60.2", + "@rollup/rollup-darwin-arm64": "4.60.2", + "@rollup/rollup-darwin-x64": "4.60.2", + "@rollup/rollup-freebsd-arm64": "4.60.2", + "@rollup/rollup-freebsd-x64": "4.60.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.2", + "@rollup/rollup-linux-arm-musleabihf": "4.60.2", + "@rollup/rollup-linux-arm64-gnu": "4.60.2", + "@rollup/rollup-linux-arm64-musl": "4.60.2", + "@rollup/rollup-linux-loong64-gnu": "4.60.2", + "@rollup/rollup-linux-loong64-musl": "4.60.2", + "@rollup/rollup-linux-ppc64-gnu": "4.60.2", + "@rollup/rollup-linux-ppc64-musl": "4.60.2", + "@rollup/rollup-linux-riscv64-gnu": "4.60.2", + "@rollup/rollup-linux-riscv64-musl": "4.60.2", + "@rollup/rollup-linux-s390x-gnu": "4.60.2", + "@rollup/rollup-linux-x64-gnu": "4.60.2", + "@rollup/rollup-linux-x64-musl": "4.60.2", + "@rollup/rollup-openbsd-x64": "4.60.2", + "@rollup/rollup-openharmony-arm64": "4.60.2", + "@rollup/rollup-win32-arm64-msvc": "4.60.2", + "@rollup/rollup-win32-ia32-msvc": "4.60.2", + "@rollup/rollup-win32-x64-gnu": "4.60.2", + "@rollup/rollup-win32-x64-msvc": "4.60.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/simplex-chat": { + "version": "6.5.0-beta.10", + "resolved": "https://registry.npmjs.org/simplex-chat/-/simplex-chat-6.5.0-beta.10.tgz", + "integrity": "sha512-K5yt4zAA04Ds0XvrSLhuknXx1rmCM8ByjgjJ0iHcQmPU5us9aaiIuez9HPP8FG1a+9xj8XMcgJfi0W1f2fTCdQ==", + "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" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/strip-final-newline": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-3.0.0.tgz", + "integrity": "sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-literal": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-2.1.1.tgz", + "integrity": "sha512-631UJ6O00eNGfMiWG78ck80dfBab8X6IVFB51jZK5Icd7XAs60Z5y7QdSd/wGIklnWvRbUNloVzhOKKmutxQ6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinypool": { + "version": "0.8.4", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-0.8.4.tgz", + "integrity": "sha512-i11VH5gS6IFeLY3gMBQ00/MmLncVP7JLXOw1vlgkytLmJK7QnEr7NXf0LBdxfmNPAeyetukOk0bOYrJrFGjYJQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-2.2.1.tgz", + "integrity": "sha512-KYad6Vy5VDWV4GH3fjpseMQ/XU2BhIYP7Vzd0LG44qRWm/Yt2WCOTicFdvmgo6gWaqooMQCawTtILVQJupKu7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/type-detect": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.1.0.tgz", + "integrity": "sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/ufo": { + "version": "1.6.3", + "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.3.tgz", + "integrity": "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-1.6.1.tgz", + "integrity": "sha512-YAXkfvGtuTzwWbDSACdJSg4A4DZiAqckWe90Zapc/sEX3XvHcw1NdurM/6od8J207tSDqNbSsgdCacBgvJKFuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.3.4", + "pathe": "^1.1.1", + "picocolors": "^1.0.0", + "vite": "^5.0.0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-1.6.1.tgz", + "integrity": "sha512-Ljb1cnSJSivGN0LqXd/zmDbWEM0RNNg2t1QW/XUhYl/qPqyu7CsqeWtqQXHVaJsecLPuDoak2oJcZN2QoRIOag==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "1.6.1", + "@vitest/runner": "1.6.1", + "@vitest/snapshot": "1.6.1", + "@vitest/spy": "1.6.1", + "@vitest/utils": "1.6.1", + "acorn-walk": "^8.3.2", + "chai": "^4.3.10", + "debug": "^4.3.4", + "execa": "^8.0.1", + "local-pkg": "^0.5.0", + "magic-string": "^0.30.5", + "pathe": "^1.1.1", + "picocolors": "^1.0.0", + "std-env": "^3.5.0", + "strip-literal": "^2.0.0", + "tinybench": "^2.5.1", + "tinypool": "^0.8.3", + "vite": "^5.0.0", + "vite-node": "1.6.1", + "why-is-node-running": "^2.2.2" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/node": "^18.0.0 || >=20.0.0", + "@vitest/browser": "1.6.1", + "@vitest/ui": "1.6.1", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "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==", + "license": "ISC" + }, + "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==", + "license": "MIT", + "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", + "integrity": "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/apps/simplex-support-bot/package.json b/apps/simplex-support-bot/package.json new file mode 100644 index 0000000000..68105851b8 --- /dev/null +++ b/apps/simplex-support-bot/package.json @@ -0,0 +1,23 @@ +{ + "name": "simplex-chat-support-bot", + "version": "0.1.0", + "private": true, + "main": "dist/index.js", + "scripts": { + "build": "tsc", + "start": "node dist/index.js" + }, + "dependencies": { + "@simplex-chat/types": "^0.5.0", + "async-mutex": "^0.5.0", + "commander": "^14.0.3", + "simplex-chat": "^6.5.0-beta.10" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "typescript": "^5.9.3", + "vitest": "^1.6.1" + }, + "author": "SimpleX Chat", + "license": "AGPL-3.0" +} diff --git a/apps/simplex-support-bot/plans/20260207-support-bot-implementation.md b/apps/simplex-support-bot/plans/20260207-support-bot-implementation.md new file mode 100644 index 0000000000..c3c11ef61f --- /dev/null +++ b/apps/simplex-support-bot/plans/20260207-support-bot-implementation.md @@ -0,0 +1,1471 @@ +# SimpleX Support Bot — Implementation Plan + +## 1. Executive Summary + +SimpleX Chat support bot — standalone Node.js app using `simplex-chat-nodejs` native NAPI binding. Single `ChatApi` instance with two user profiles (main bot + Grok agent) sharing one SQLite database. A `profileMutex` serializes all profile-switching + SimpleX API calls. Team sees active conversations as cards in a dashboard group — no text forwarding. Implements flow: Welcome → Queue → Grok/Team-Pending → Team. + +## 2. Architecture + +``` +┌─────────────────────────────────────────────────┐ +│ Support Bot Process (Node.js) │ +│ │ +│ chat: ChatApi ← ChatApi.init("./data/simplex") │ +│ Single database, two user profiles │ +│ │ +│ mainUserId ← non-Grok user (default name: │ +│ "Ask SimpleX Team") │ +│ • Business address, event routing, state mgmt │ +│ • Controls group membership │ +│ │ +│ grokUserId ← "Grok" profile │ +│ • Joins customer groups as Member │ +│ • Sends Grok responses into groups │ +│ │ +│ profileMutex: serialize apiSetActiveUser + call │ +│ GrokApiClient → api.x.ai/v1/chat/completions │ +└─────────────────────────────────────────────────┘ +``` + +- Single Node.js process, single `ChatApi` instance via native NAPI +- Two user profiles in one database. The main profile is returned directly from `bot.run()`. The Grok profile's `userId` is persisted to `state.json` as `grokUserId` on the first run (when the bot creates it); subsequent runs identify Grok strictly by that persisted ID (never by display name, which a rename would invalidate). The main profile's displayName is set only on fresh-DB user creation (`"Ask SimpleX Team"`) and is never rewritten by bot code thereafter — `bot.run()` is invoked with `updateProfile: false`. Bot commands (`/grok`, `/team`) are never pushed via global `apiUpdateProfile`; instead they sync lazily per-group in `sendToGroup` — the first send to each group triggers `syncGroupCommands(groupId)`, which verifies the group's `groupPreferences.commands` against `desiredCommands` and calls `apiUpdateGroupProfile` if different (scoped broadcast to that group's members only). Subsequent sends to the same group are cache hits. +- `profileMutex` serializes `apiSetActiveUser(userId)` + the subsequent SimpleX API call. Grok HTTP API calls run **outside** the mutex. +- Events delivered for all profiles — routed by `event.user` field (main → main handler, Grok → Grok handler) +- Business address auto-accept creates a group per customer +- Grok is a second profile invited as a Member — appears as a separate participant +- No cross-profile ID mapping needed — Grok profile uses its own local group IDs from its own events + +## 3. Project Structure + +``` +apps/simplex-support-bot/ +├── package.json # deps: simplex-chat, @simplex-chat/types, async-mutex; devDeps: vitest, @types/node +├── tsconfig.json # ES2022, strict, Node16 module resolution +├── vitest.config.ts # test runner config, path aliases for mocks +├── src/ +│ ├── index.ts # Entry: parse config, init instance, run +│ ├── config.ts # CLI arg parsing, ID:name validation, Config type +│ ├── bot.ts # SupportBot class: state derivation, event dispatch, cards +│ ├── cards.ts # Card formatting, debouncing, lifecycle +│ ├── grok.ts # GrokApiClient: xAI API wrapper, system prompt, history +│ ├── messages.ts # All user-facing message templates +│ └── util.ts # isWeekend, profileMutex, logging helpers +├── bot.test.ts # Vitest suite (154 tests, 31 describes) +├── test/ +│ └── __mocks__/ +│ ├── simplex-chat.js # MockChatApi + utility re-exports +│ └── simplex-chat-types.js # enum re-exports for tests +└── data/ # SQLite databases (created at runtime) +``` + +The Grok system-prompt / context file is supplied at runtime via `--context-file ` (see §4). It is not part of the repo tree. + +## 4. Configuration + +**CLI flags:** + +| Flag | Required | Default | Format | Purpose | +|------|----------|---------|--------|---------| +| `--db-prefix` | No | `./data/simplex` | path | Database file prefix (both profiles share it) | +| `--team-group` | Yes | — | `name` | Team group display name (auto-created if absent, resolved by persisted ID on restarts) | +| `--auto-add-team-members` / `-a` | No | `""` | `ID:name,...` | Comma-separated team member contacts. Validated at startup — exits on mismatch. | +| `--context-file` | Required when `GROK_API_KEY` set | — | path | Grok system-prompt file (SimpleX documentation context). `parseConfig` throws if `GROK_API_KEY` is set without this flag. | +| `--timezone` | No | `"UTC"` | IANA tz | For weekend detection (24h vs 48h). Weekend = Sat 00:00 – Sun 23:59 in this tz. `parseConfig` validates the value by constructing a probe `Intl.DateTimeFormat` and throws with a clear error on `RangeError` (invalid IANA zone) — bot exits before init. | +| `--complete-hours` | No | `3` | integer ≥ 0 | Hours of customer inactivity after last team/Grok reply before auto-completing a conversation (✅). `parseConfig` rejects non-numeric, negative, or `NaN` values with a fail-fast error. `0` is allowed and disables auto-complete. | +| `--card-flush-seconds` | No | `300` | integer ≥ 0 | Seconds between card dashboard update flushes. `parseConfig` rejects non-numeric, negative, or `NaN` values with a fail-fast error. `0` is allowed and disables periodic flush (card updates still occur on explicit `scheduleUpdate` callers but never auto-drain). | + +**Env vars:** `GROK_API_KEY` (optional) — xAI API key. If unset or empty, the bot starts with Grok support fully disabled: it logs `"No GROK_API_KEY provided, disabling Grok support"`, skips Grok profile/contact setup and event handler registration, omits `/grok` from the bot command list, drops the `/grok` clause from customer-facing messages, and treats any `/grok` the customer still types as an unknown command. + +**Numeric argument validation:** `parseConfig` MUST validate every numeric flag (`--complete-hours`, `--card-flush-seconds`) using a helper that throws on non-finite or negative results, rather than raw `parseInt`: + +```typescript +function parseNonNegativeInt(raw: string, flag: string): number { + const n = parseInt(raw, 10) + if (!Number.isFinite(n) || n < 0) { + throw new Error(`${flag} must be a non-negative integer, got "${raw}"`) + } + return n +} + +const completeHours = parseNonNegativeInt(optionalArg(args, "--complete-hours", "3"), "--complete-hours") +const cardFlushSeconds = parseNonNegativeInt(optionalArg(args, "--card-flush-seconds", "300"), "--card-flush-seconds") +``` + +Rationale: `parseInt("foo", 10)` returns `NaN`, and `NaN * 3600_000 === NaN`. Every subsequent comparison (`now - lastTeamGrokTime >= completeMs`) is `false`, so the feature silently becomes a no-op — auto-complete never fires, cards never auto-refresh — and the operator has no signal that they typo'd a flag. Failing fast at startup surfaces the typo before customers interact. `0` is explicitly allowed as a valid "disable" setting. + +**Timezone validation:** `parseConfig` MUST validate `--timezone` by constructing a probe `Intl.DateTimeFormat`: + +```typescript +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}`) +} +``` + +Rationale: `isWeekend` is called from `queueMessage` and `teamAddedMessage` — both run on the hot customer message path. `new Intl.DateTimeFormat(..., {timeZone: , ...})` throws `RangeError: Invalid time zone specified` at every call. Without startup validation, a typo in `--timezone` turns every `/grok`, `/team`, or first-customer-message dispatch into an unhandled error that crashes the per-item handler (though the outer try/catch in `onNewChatItems` contains it, customers receive no reply at all). Validating once at startup surfaces the typo in the operator's console before any customer interaction. + +```typescript +interface Config { + dbPrefix: string + teamGroup: {id: number; name: string} // id=0 at parse time, resolved at startup + teamMembers: {id: number; name: string}[] + grokContactId: number | null // always restored from state file at startup (even when Grok API is disabled, so the one-way gate can identify and remove Grok members) + timezone: string + completeHours: number // default 3 + cardFlushSeconds: number // default 300 + contextFile: string | null // path to Grok system-prompt file; required when grokApiKey !== null + grokApiKey: string | null // null when GROK_API_KEY is not set → Grok disabled +} +``` + +**State file** — `{dbPrefix}_state.json` (co-located with DB files): +```json +{"teamGroupId": 123, "grokContactId": 4} +``` + +Only two keys. All other state is persisted in the group's `customData` (per-conversation state, card IDs) or derived from group metadata (`apiListMembers`). Display data like message counts is read from chat history on demand. + +**Grok contact resolution** (state-file lookup always runs; contact establishment only when enabled): +1. Read `grokContactId` from state file → validate via `apiListContacts` → set `config.grokContactId` (this always runs, even when `grokApiKey === null`, so the one-way gate can identify and remove Grok members from groups) +2. If not found and `grokEnabled`: main profile creates one-time invite link, Grok profile connects, wait for a `contactConnected` event filtered by profile identity (60s — see "Grok contact identification" below), persist the resulting `contactId` atomically before proceeding. +3. If unavailable (with Grok otherwise enabled), bot runs but `/grok` returns "temporarily unavailable" +4. If `grokApiKey === null`: the Grok profile is not resolved or created, no invite link is issued — but `config.grokContactId` is still set from the state file if the contact exists. + +### Grok contact identification + +`grokContactId` is written once and used forever — it is the single identifier for every subsequent Grok check (one-way gate, `onMemberConnected` skip, `isGrok` in card rendering). Identification MUST be narrowly scoped so that the `contactId` stored is unambiguously Grok's and no other contact completing a handshake in the 60s establishment window can be latched by mistake. + +Use the predicate form of `ChatApi.wait`. The signature (defined in `node_modules/simplex-chat/src/api.ts:217`) is: + +```typescript +wait( + event: K, + predicate: ((event: ChatEvent & {type: K}) => boolean) | undefined, + timeout: number, +): Promise +``` + +The implementation (api.ts:234) keeps the subscriber attached when the predicate returns `false`, so non-matching events are silently discarded and the wait continues until a matching event arrives or the timeout fires. + +Identification accepts only a `contactConnected` event observed by the MAIN profile (the profile whose `apiCreateLink` issued the invite, and whose `contactId` we persist and later pass to `apiAddMember`) whose connecting contact's profile `displayName` equals the Grok profile's displayName: + +```typescript +const grokProfileName = grokUser.profile.displayName // "Grok" (canonical) +const evt = await chat.wait( + "contactConnected", + (e) => + e.user.userId === mainUser.userId && + e.contact.profile.displayName === grokProfileName, + 60_000, +) +if (!evt) { + console.error(`Timeout waiting for Grok contact (60s, displayName="${grokProfileName}"). ` + + `Check SMP relay availability or re-run after clearing state. Exiting.`) + process.exit(1) +} +config.grokContactId = evt.contact.contactId +state.grokContactId = config.grokContactId +writeState(stateFilePath, state) // atomic: tmp-file + rename (see §13 state persistence) +log(`Grok contact established: ID=${config.grokContactId} (displayName="${grokProfileName}")`) +``` + +Filter rationale: +- `e.user.userId === mainUser.userId` selects the main profile's view of the handshake. Both profiles observe the handshake (the Grok-side event describes the main profile as the `contact`); only the main-side event carries the `contactId` we need for subsequent `apiAddMember` calls. +- `e.contact.profile.displayName === grokProfileName` accepts only the contact whose profile matches the Grok profile just created/updated. This rejects stray inbound contacts (late business-request acceptance, operator test DM, a reconnect of an existing contact) that may complete in the same 60s window. The displayName is read from `evt.contact.profile`, which is `LocalProfile` (see `@simplex-chat/types/src/types.ts:2867`). + +`grokProfileName` is captured from `grokUser.profile.displayName` immediately before the wait, so whichever name the Grok profile was created/updated with earlier in startup is the exact string matched here. + +Single-tenant deployment caveat: if a human contact happens to set its SimpleX displayName to the literal `"Grok"` and completes a handshake with the main profile in the 60s window, the displayName filter alone cannot distinguish them. MVP is single-tenant and Grok's profile is created by the bot itself, so this is not expected in practice; deployments that need stronger guarantees can add a second filter (e.g. `e.contact.profile.image === grokImage` — the bot knows the exact image bytes it assigned to the Grok profile). + +Persistence: `writeState` is atomic (tmp-file + `fs.renameSync`, see §13 "State persistence") so a crash between identification and persistence cannot corrupt the state file. `state.grokContactId` is flushed to disk BEFORE proceeding to bot event wiring — if the process dies after wiring but before persistence, the next startup would issue a second invite link and leave the first Grok contact orphaned in the database. + +**Team group resolution** (auto-create): +1. Read `teamGroupId` from state file → validate via group list +2. If not found: create with `apiNewGroup`, persist new group ID +3. If found: compare `fullGroupPreferences` (directMessages, fullDelete, commands) and displayName with desired values. Only call `apiUpdateGroupProfile` if something differs — avoids unnecessary SMP relay round-trips on every restart. + +**Team group invite link lifecycle:** +1. Delete stale link (best-effort), create new link, print to stdout. Creation is best-effort — if the SMP relay is unreachable, the error is logged and the bot continues without an invite link. The 10-minute deletion timer is only scheduled if creation succeeded. +2. Delete after 10 minutes. On SIGINT/SIGTERM, delete before exit. Deletion must go through `profileMutex` with `apiSetActiveUser(mainUserId)` — the active user may be the Grok profile at the time the timer fires or the signal arrives. + +**Team member validation:** +- If `--auto-add-team-members` (`-a`) provided: validate each contact ID/name pair, fail-fast on mismatch +- If not provided: `/team` tells customers "no team members available yet" + +## 5. State Derivation (Stateless) + +Per-conversation state is stored in the group's `customData` and written at the moment the bot handles each transition (customer's first message, `/grok`, `/team`, team member's first message). On subsequent events `deriveState` returns the stored state as-is — composition changes (team members leaving, Grok leaving) do **not** demote the stored state. The customer's mode (e.g. "waiting for a team response") is meaningful even when no team member is currently present; keeping the state preserves that. Composition is read only by specific handlers (e.g. the `/team` duplicate-invite guard). No chat-history scans for state decisions. No in-memory conversations map — survives restarts. + +**WELCOME detection:** customData has no `state` field until the bot handles the first transition. `deriveState` returns `WELCOME` precisely when `customData.state` is absent. + +**Type vs. persisted state.** The `ConversationState` union in `cards.ts` enumerates all five conceptual states (`WELCOME | QUEUE | GROK | TEAM-PENDING | TEAM`) so event handlers and composition can reason about them uniformly. However, `WELCOME` is NEVER written to `customData.state` — the runtime invariant is "persisted state ∈ {QUEUE, GROK, TEAM-PENDING, TEAM}; absence of the `state` field derives as WELCOME". The `isConversationState` guard in `cards.ts` rejects `WELCOME` on read to preserve this invariant (any stale `state: "WELCOME"` from a crashed transition is treated as absent). Do NOT introduce a separate `PersistedState` type in MVP — the invariant is small enough to enforce at two choke points: `getRawCustomData` on read and the dispatch handlers on write. + +**State-write matrix:** + +| Bot-observed event | `customData.state` written | +|---|---| +| *(initial — no customData yet)* | *(absent ⇒ WELCOME)* | +| Customer's first non-command message | `QUEUE` | +| `/grok` handled — Grok invited | `GROK` | +| `/team` handled — team members added (written at handler time; does not wait for team acceptance) | `TEAM-PENDING` | +| First team-member text message observed | `TEAM` | + +**State is authoritative and monotonic.** Once written, `customData.state` persists across member leave/join events. The only path that clears it is the existing `onLeftMember` handler when the customer themselves leaves — at that point the entire customData is cleared. + +**Failure-path revert is CAS-guarded.** `activateGrok` runs fire-and-forget, so its `setStateOnFail` revert (`QUEUE`) can race with a concurrent transition (e.g. `/team` writing `TEAM-PENDING` while `waitForGrokJoin` is pending). To preserve monotonicity, `revertStateOnFail` is a compare-and-set: it only writes `setStateOnFail` if `customData.state === "GROK"` (the optimistic value both call sites write before invoking `activateGrok`). If another handler has since stamped a different state, the revert is skipped — the in-flight transition wins and stays. + +TEAM-PENDING takes priority over GROK when both Grok and team are present (after `/team` but before team member's first message). `/grok` remains available in TEAM-PENDING — if Grok is not yet in the group, it gets invited; if already present, the command is ignored. + +**State derivation helpers:** +- `getGroupComposition(groupId)` → `{grokMember, teamMembers}` from `apiListMembers` — used for card rendering and the `/team` duplicate-invite guard. +- `deriveState(groupId)` → reads `customData.state`. Returns `WELCOME` iff `customData.state` is absent. No composition lookup. +- `getLastCustomerMessageTime(groupId)` / `getLastTeamOrGrokMessageTime(groupId)` → chat-history timestamp reads used by the card renderer for wait-time and auto-complete only (display, not state). + +**Transitions:** +``` +WELCOME ──(1st msg)──────> QUEUE (send queue msg, create card 🆕) +WELCOME ──(/grok 1st)────> GROK (skip queue msg, create card 🤖) +WELCOME ──(/team 1st)────> TEAM-PENDING (skip queue msg, add team members, create card 👋) +QUEUE ──(/grok)──────────> GROK (invite Grok, update card) +QUEUE ──(/team)──────────> TEAM-PENDING (add team members, update card) +GROK ──(/team)───────────> TEAM-PENDING (add all team members, Grok stays, update card) +GROK ──(user msg)────────> GROK (Grok responds, update card) +TEAM-PENDING ──(/grok)───> invite Grok if not present, else ignore (state stays TEAM-PENDING) +TEAM-PENDING ──(/team)───> reply "already invited" (if team members still present; else re-add silently) +TEAM-PENDING ──(team msg)> TEAM (remove Grok, disable /grok permanently, update card) +TEAM ──(/grok)───────────> reply "team mode", stay TEAM +``` + +## 6. Card-Based Dashboard + +The team group is a live dashboard. The bot maintains exactly one message ("card") per active customer conversation. Cards are deleted and reposted on changes — the group is always a current snapshot. + +### Card format + +Card is a single message. The join command is the final line of the card text — there is no separate join message. + +``` +[ICON] *[Customer Name]* · [wait] · [N msgs] +[STATE][· agent1, agent2, ...] +"[last message(s), truncated]" +/'join [id]' +``` + +**Icons:** + +| Icon | Condition | +|------|-----------| +| 🆕 | QUEUE — first message < 5 min ago | +| 🟡 | QUEUE — waiting < 2 h | +| 🔴 | QUEUE — waiting > 2 h | +| 🤖 | GROK — Grok handling | +| 👋 | TEAM — team added, no reply yet | +| 💬 | TEAM — team has replied, conversation active (customer replied after team) | +| ⏰ | TEAM — customer follow-up unanswered > 2 h | +| ✅ | Done — no customer reply for `completeHours` (default 3h) after last team/Grok message | + +**State labels:** `Queue`, `Grok`, `Team – pending`, `Team` + +**Agents:** comma-separated display names of team members in the group. Omitted when none. + +**Message count:** All messages in chat history except the bot's own (`groupSnd` from main profile). + +**Message preview:** Last several messages, most recent last, separated by ` / `. Newlines in message text are replaced with spaces to prevent card layout bloat from spam. The customer's display name is sanitized (newlines → spaces) for the card header; the `/join` command embeds only the numeric group id. Newest messages are prioritized — when the total exceeds ~500 chars (`maxTotal = 500` in `composeCard`), the oldest messages are truncated (with `[truncated]` prepended) while the newest are always shown. When truncation occurs, the first visible message is guaranteed to have a sender prefix even if it was a continuation in the original sequence. Each message is prefixed with the sender's name (`Name: message`) on the first message in a consecutive run from that sender - subsequent messages from the same sender omit the prefix until a different sender's message appears. Sender identification: Grok contact is detected by `grokContactId` and labeled "Grok"; the customer is identified by matching `memberId` to the group's `customerId` and labeled with their display name; all other members use their `memberProfile.displayName`. Bot's own messages (`groupSnd`) are excluded. Each message truncated to ~200 chars. Media-only messages show type labels: `[image]`, `[file]`, `[voice]`, `[video]`. + +**Join command:** the final line of the card renders as `/'join '` where `` is the customer group's numeric ID. The outer single quotes around `join ` are rendered by SimpleX clients as a clickable quoted command; tapping it sends `/join ` back to the team group. The handler does not pattern-match the message text — it uses the framework's structured command parser (`util.ciBotCommand`) which returns `{keyword: "join", params: ""}` directly from the chat item. The handler then converts `params` to an integer via `Number.parseInt(params, 10)` and rejects anything that is not a positive integer. There is no legacy `/join :` form — the card never emits it, so the handler never needs to strip it. + +### Card lifecycle + +**Tracking:** `{state, cardItemId, complete?}` stored in customer group's `customData` via `apiSetGroupCustomData`. `state` is the canonical conversation state (`QUEUE | GROK | TEAM-PENDING | TEAM`); `cardItemId` is the team-group chat item ID for the (single) card message; `complete` flags the auto-completed state. Absence of `state` means WELCOME. Written at event time by the dispatch handlers — `/grok` handler writes `GROK` on invite; `/team` handler writes `TEAM-PENDING` immediately (does not wait for team acceptance); first observed team-member text message writes `TEAM`; first customer text message writes `QUEUE`. Read back from `groupInfo.customData` — single source of truth, survives restarts. All writes go through `CardManager.mergeCustomData` to preserve fields across independent write paths. + +**Create** — on first customer message (→ QUEUE) or `/grok` as first message (→ GROK): +1. Compose card text (including the `/'join '` final line) +2. Post it via `apiSendMessages(chatRef, [{msgContent: {type: "text", text}, mentions: {}}])` → get one `chatItemId`. The card is a single message; the `/'join '` line is clickable because SimpleX clients render the slash-prefixed single-quoted token as a clickable command even inside a multi-line message. +3. Write `{cardItemId}` to customer group's `customData` + +**Update** (delete + repost) — on every subsequent event (new customer msg, team/Grok reply, state change, agent join): +1. Read `{cardItemId}` from `customData` +2. Delete old card via `apiDeleteChatItems([Group, teamGroupId], [cardItemId], "broadcast")`. Per `simplex-chat/src/api.ts:436-445` the call either returns `T.ChatItemDeletion[]` (possibly empty if the item no longer exists) or throws `ChatCommandError`. Both outcomes are acceptable: the surrounding `try { ... } catch { /* log and continue */ }` allows execution to proceed whether the item was still present, already gone, or the server returned a transient error. +3. Post new card as a single message via `apiSendMessages` → get new `cardItemId`. **On failure** the partial-failure policy below applies: log, re-queue this groupId into `pendingUpdates`, return without writing `customData`. +4. Write `{cardItemId, complete?}` to `customData` via `mergeCustomData`. **On failure** the tracking-write policy below applies. + +**Debouncing:** Card updates debounced globally — pending changes flushed every `cardFlushSeconds` seconds (default 300, configurable via `--card-flush-seconds`). Within a batch, each group's card reposted at most once with latest state. + +**Wait time rules:** Time since the customer's last unanswered message. For ✅ (auto-completed) conversations, the wait field shows the literal string "done". If customer sends a follow-up, wait time resets to count from that message. + +**Auto-complete:** A conversation is marked ✅ when `completeHours` (default 3h, configurable via `--complete-hours`) have passed since the last team/Grok message **without any customer reply**. The card debounce flush (every 300 seconds / 5 min, configurable via `--card-flush-seconds`) checks elapsed time and transitions to ✅ when the threshold is met. Customer follow-up at any point — including after ✅ — reverts to the derived active icon (👋/💬/⏰ for team states, 🟡/🔴 for queue), and wait time resets from that message. + +**Card icon derivation (TEAM states) — computed at each card render by comparing the timestamps of the most recent customer and team/Grok messages in the group; nothing about the icon is stored:** +``` +Team added, no reply yet → 👋 +Team replied → 💬 +Customer follow-up unanswered >2h → ⏰ +No customer reply for completeHours → ✅ +Customer sends after ✅ → back to 💬 or ⏰ (derived from wait time) +``` + +**Cleanup** — customer leaves: card remains (TBD retention), clear `customData`. + +**Restart recovery:** On startup, `CardManager.refreshAllCards()` lists all groups, finds those with `customData.cardItemId` set and `customData.complete` not set, sorts by `cardItemId` ascending (higher ID = more recently updated), and re-posts them oldest-first so the most recently active cards end up at the bottom of the team group. Completed cards (`complete: true`) and old/pre-bot groups (no `customData`) are skipped. Old card messages are deleted before reposting; deletion failures (e.g., >24h old) are silently ignored. Individual card failures are caught and logged without aborting the batch. + +### Partial-failure and retry policy + +`createCard` and `updateCard` perform a multi-step sequence (delete + send + customData write). To design the correct policy we MUST be explicit about which failures the SimpleX core already handles for us vs. which surface to the bot: + +**SimpleX core semantics** (per `simplex-chat/src/api.ts` JSDoc): +- `apiSendMessages` — "Network usage: background". The call returns `newChatItems` once the chat item is CREATED LOCALLY (written to SQLite) and the SMP broadcast is QUEUED. The core's background machinery retries relay delivery transparently — **the bot never observes a transient relay failure from `apiSendMessages`**. A thrown `ChatCommandError` means the local create step itself failed: permission denied, chat does not exist, invalid content, DB locked/corrupted. +- `apiDeleteChatItems` — "Network usage: background". Same pattern: local delete + queued broadcast + core-managed delivery retry. A thrown error means the local delete step failed (item not found, permission, DB error). +- `apiSetGroupCustomData` — "Network usage: **no**". Pure local SQLite write, no SMP involvement at all. A thrown error means a local DB error. + +Consequence: failures surfaced to the bot are **terminal local errors** (bad state, DB problem, permission change), not transient network blips. Retrying the same operation against the same DB/relay state will usually hit the same error. Retry value comes from the narrow slice of genuinely transient local conditions — a brief SQLite lock held by a concurrent write, a race with group-state mutation elsewhere in the same process — where the next attempt sees a different state. + +This reshapes the policy: the bot does not need aggressive retry for "network" reasons (core handles that), and compensating actions for customData-write failure are rarely useful (if the pure-local customData write fails, the retry's customData write will almost certainly fail for the same reason). The bot needs a light safety net: re-queue on any step failure, let the flush loop try again at most once per `cardFlushSeconds`, and on persistent failure accept that operator intervention is needed. + +Policy (applies to both `createCard` and `updateCard`): + +**Any step fails** — whether step 2 (delete), step 3 (send), or step 4 (customData write): +- Log via `logError` with `{groupId, step, err}` so the operator can diagnose the underlying cause (permission change, DB corruption, bot removed from team group, etc). +- Re-add `groupId` to `pendingUpdates` via `this.scheduleUpdate(groupId)`. +- Return. Do NOT attempt compensating actions (no compensating delete for tracking-write failure — the scenario where send succeeds locally but customData write fails requires the SQLite DB to be healthy-then-unhealthy between two synchronous calls in the same transaction window, which is not a realistic transient state; the retry path handles any resulting duplicate by reading the stale `cardItemId` and deleting it on the next update attempt). + +**Flush dispatch** — the current `flush` loop calls `updateCard` unconditionally and `updateCard` returns early when `customData.cardItemId` is unset. This silently drops the retry path for a failed `createCard` — the group is in `pendingUpdates` but nothing will ever create a card for it. Replace with a single `flushOne(groupId)` that reads `customData` once and dispatches to create or update: + +```typescript +private async flushOne(groupId: number): Promise { + const groupInfo = await this.getGroupInfo(groupId) + if (!groupInfo) return // group deleted + const customData = this.deriveCustomData(groupInfo) + if (customData.complete) return // ✅ conversations don't auto-repost + if (typeof customData.cardItemId === "number") { + await this.updateCard(groupId, groupInfo) + } else { + await this.createCard(groupId, groupInfo) + } +} + +async flush(): Promise { + const groups = [...this.pendingUpdates] + this.pendingUpdates.clear() + for (const groupId of groups) { + try { await this.flushOne(groupId) } + catch (err) { + logError(`flush failed for group ${groupId}`, err) + this.scheduleUpdate(groupId) // re-queue on any thrown error + } + } +} +``` + +Retry behavior for each failure point under this design: + +| Failure point | `customData` after failure | Retry's `flushOne` path | Retry outcome if condition cleared | +|---|---|---|---| +| `createCard` send fails | `cardItemId` absent | create-path | fresh card posted, `customData` written | +| `updateCard` delete fails | old `cardItemId` still set | update-path | delete retried (idempotent — see below) + send + write | +| `updateCard` send fails (delete succeeded) | old (now-deleted) `cardItemId` still set | update-path | delete retried against stale ID — tolerated (see below) — then send + write | +| `updateCard` write fails (send succeeded, duplicate may exist) | old `cardItemId` still set, new card orphaned in team group | update-path | delete retried against stale old ID — tolerated — new card posted, tracking written; **leaked** new card from the failed attempt persists until operator removes it | + +**Delete idempotency on retry** — `apiDeleteChatItems` against already-deleted IDs returns either an empty `ChatItemDeletion[]` or throws `ChatCommandError`. The step-2 `try { ... } catch { logError(...) }` swallows both; execution proceeds to step 3. Do NOT escalate a step-2 error to the partial-failure policy — that would create a retry loop for a permanent condition (items past the 24h deletion window will throw on every retry forever). + +**Persistent failures** — if the underlying condition is not transient (bot removed from team group, DB corruption, permission revoked), every retry hits the same error and the group stays in `pendingUpdates` indefinitely, logging at each flush. MVP accepts this — the operator-visible log stream makes the problem diagnosable. A bounded-retry-with-backoff-and-giveup strategy can be added later without changing the failure-point table above. + +### Card implementation + +```typescript +class CardManager { + private pendingUpdates = new Set() // groupIds with pending updates + private flushInterval: NodeJS.Timeout + + constructor(private chat: ChatApi, private config: Config, private mainUserId: number, + flushIntervalMs = 300 * 1000) { + this.flushInterval = setInterval(() => this.flush(), flushIntervalMs) + this.flushInterval.unref() + } + + scheduleUpdate(groupId: number): void { + this.pendingUpdates.add(groupId) + } + + async createCard(groupId: number, groupInfo: T.GroupInfo): Promise { + const {text} = await this.composeCard(groupId, groupInfo) + // Single-message card — the `/'join '` line is the final line of `text`. + const items = await this.chat.apiSendMessages(chatRef, [ + {msgContent: {type: "text", text}, mentions: {}}, + ]) + await this.chat.apiSetGroupCustomData(groupId, { + cardItemId: items[0].chatItem.meta.itemId, + }) + } + + async flush(): Promise { + const groups = [...this.pendingUpdates] + this.pendingUpdates.clear() + for (const groupId of groups) { + await this.updateCard(groupId) + } + } + + async refreshAllCards(): Promise { + const groups = await this.chat.apiListGroups(mainUserId) + const activeCards = groups + .filter(g => typeof g.customData?.cardItemId === "number" && !g.customData?.complete) + .map(g => ({groupId: g.groupId, cardItemId: g.customData.cardItemId})) + // Sort ascending by cardItemId (higher = more recently updated) + activeCards.sort((a, b) => a.cardItemId - b.cardItemId) + for (const {groupId} of activeCards) { + try { await this.updateCard(groupId) } + catch (err) { logError(`Startup card refresh failed for group ${groupId}`, err) } + } + } + + private async updateCard(groupId: number): Promise { + // Read customData via apiListGroups + const customData = ... // {cardItemId} from groupInfo.customData + if (!customData?.cardItemId) return + // Delete old card message + try { + await this.chat.apiDeleteChatItems(Group, teamGroupId, + [customData.cardItemId], "broadcast") + } catch {} // card may already be deleted + const {text, complete} = await this.composeCard(groupId, groupInfo) + const items = await this.chat.apiSendMessages(chatRef, [ + {msgContent: {type: "text", text}, mentions: {}}, + ]) + const data = { + cardItemId: items[0].chatItem.meta.itemId, + ...(complete ? {complete: true} : {}), + } + await this.chat.apiSetGroupCustomData(groupId, data) + } + + private async composeCard(groupId: number, groupInfo: T.GroupInfo): Promise<{text: string, complete: boolean}> { + // Icon, state, agents, preview (with sender-name prefixes), /'join ' — per spec format + // The final line of `text` is `/'join '` — clickable in SimpleX clients. + // buildPreview(chatItems, customerName, customerId) — prefixes each sender's first message in a run + // Preview messages joined with blue "/" separator: " !3 /! " (SimpleX markdown for blue colored text) + // Message text is escaped via escapeStyledMarkdown() before joining — inserts U+200B after "!" + // when followed by a color trigger (1-6,r,g,b,y,c,m,-) to prevent false markdown interpretation. + // No escape mechanism exists in the SimpleX markdown parser for "!" styled text. + // complete = (icon === "✅") + } +} +``` + +## 7. Bot Initialization + +**Main bot** uses `bot.run()` with `events` parameter: + +```typescript +let supportBot: SupportBot + +const [chat, mainUser, mainAddress] = await bot.run({ + profile: {displayName: "Ask SimpleX Team", fullName: "", image: supportImage}, + dbOpts: {dbFilePrefix: config.dbPrefix}, + options: { + addressSettings: { + businessAddress: true, + autoAccept: true, + welcomeMessage, + }, + commands: [ + {type: "command", keyword: "grok", label: "Ask Grok"}, + {type: "command", keyword: "team", label: "Switch to team"}, + ], + useBotProfile: true, + updateProfile: false, // bot code never rewrites displayName/image/etc. + }, + events: { + acceptingBusinessRequest: (evt) => supportBot?.onBusinessRequest(evt), + newChatItems: (evt) => supportBot?.onNewChatItems(evt), + chatItemUpdated: (evt) => supportBot?.onChatItemUpdated(evt), + chatItemReaction: (evt) => supportBot?.onChatItemReaction(evt), + leftMember: (evt) => supportBot?.onLeftMember(evt), + joinedGroupMember: (evt) => supportBot?.onJoinedGroupMember(evt), + connectedToGroupMember: (evt) => supportBot?.onMemberConnected(evt), + newMemberContactReceivedInv: (evt) => supportBot?.onMemberContactReceivedInv(evt), + contactConnected: (evt) => supportBot?.onContactConnected(evt), + contactSndReady: (evt) => supportBot?.onContactSndReady(evt), + }, +}) +``` + +Note: `/grok` and `/team` are passed in `options.commands` so `bot.run()` has a profile to use when `apiCreateActiveUser` is needed on a fresh DB, but since `updateProfile: false` is set, `bot.run()` never writes the profile on subsequent runs. The user profile's `preferences.commands` is intentionally not pushed globally at startup — broadcasting `XInfo` to every contact is not wanted. Instead, the `SupportBot` takes `desiredCommands` as a constructor argument and syncs commands lazily per-group: `sendToGroup` (`src/bot.ts`) always calls `syncGroupCommands(groupId)` before dispatching the message. That helper reads the group via `apiGetChat(Group, groupId, 0)` (local, no network), and if `groupPreferences.commands` differs from `desiredCommands`, issues `apiUpdateGroupProfile` with the merged profile. `apiUpdateGroupProfile` broadcasts `XGrpInfo`/`XGrpPrefs` to group members only (scoped to the chat audience). Already-synced groups are cached in `syncedGroups: Set` so subsequent sends skip the read entirely — the first send per group costs one local read; every later send is a cache hit. Earlier drafts used a regex on the outgoing text to skip the sync when no command keyword appeared; that optimization was removed because the cache already makes repeated syncs free and the parser was a fragile source of correctness bugs. `/join` is registered as a team group command separately — after team group is resolved, call `apiUpdateGroupProfile(teamGroupId, groupProfile)` with `groupPreferences` including the `/join` command definition. Customer sending `/join` in a customer group → treated as ordinary message (unrecognized command). + +**Grok profile** — resolved from same ChatApi instance. Grok is identified strictly by the `userId` persisted in `state.json`; there is no by-name fallback (a renamed profile would otherwise be silently mistaken): + +```typescript +let grokUser: T.User | null = null +if (state.grokUserId !== undefined) { + const users = await chat.apiListUsers() + grokUser = users.find(u => u.user.userId === state.grokUserId)?.user ?? null + if (!grokUser) { + throw new Error( + `Persisted Grok userId=${state.grokUserId} not found in DB. ` + + `Either restore the user or delete state.json to re-create Grok.` + ) + } +} else { + // First run: create Grok and persist its userId immediately. + grokUser = await chat.apiCreateActiveUser({displayName: "Grok", fullName: "", image: grokImage}) + // apiCreateActiveUser sets Grok as active — switch back to main + await chat.apiSetActiveUser(mainUser.userId) + state.grokUserId = grokUser.userId + writeState(stateFilePath, state) +} + +// Refresh Grok's profile if it has drifted from the canonical values. +const grokProfile = {displayName: "Grok", fullName: "", image: grokImage} +const current = util.fromLocalProfile(grokUser.profile) +if (current.image !== grokProfile.image || current.displayName !== grokProfile.displayName || current.fullName !== grokProfile.fullName) { + await chat.apiSetActiveUser(grokUser.userId) + await chat.apiUpdateProfile(grokUser.userId, grokProfile) + await chat.apiSetActiveUser(mainUser.userId) +} +``` + +**Profile mutex** — all SimpleX API calls go through: + +```typescript +import {Mutex} from "async-mutex" + +const profileMutex = new Mutex() + +async function withProfile(userId: number, fn: () => Promise): Promise { + return profileMutex.runExclusive(async () => { + await chat.apiSetActiveUser(userId) + return fn() + }) +} +``` + +Grok HTTP API calls are made **outside** the mutex to avoid blocking. + +**Per-group customData mutex** — `mergeCustomData` and `clearCustomData` must be serialized per customer group. `mergeCustomData` has two awaits (read via `getRawCustomData` → `apiListGroups`, then write via `apiSetGroupCustomData`); between them the event loop runs, so two concurrent async chains operating on the same `groupId` can both read the same snapshot, both produce a merged object, and the second write clobbers the first's patch. + +Concrete call sites that can overlap on one `groupId`: +- `processMainChatItem` writing `state` transitions (WELCOME→QUEUE, WELCOME→GROK, QUEUE→GROK, one-way gate →TEAM) +- `activateGrok`'s `revertStateOnFail` (fire-and-forget) racing with subsequent customer messages +- `activateTeam` writing `TEAM-PENDING` racing with `/grok` or another `/team` on the same group +- `CardManager.flush → updateCard` writing `{cardItemId, complete}` racing with dispatch writing `state` +- `createCard` writing `{cardItemId}` immediately after dispatch writes `state` + +The CAS-on-state inside `revertStateOnFail` guards only the `state` key — other keys (`cardItemId`, `complete`) can still be lost when spread from a stale snapshot. + +Implementation: + +```typescript +// In CardManager +private customDataMutexes = new Map() + +private getCustomDataMutex(groupId: number): Mutex { + let m = this.customDataMutexes.get(groupId) + if (!m) { m = new Mutex(); this.customDataMutexes.set(groupId, m) } + return m +} + +async mergeCustomData(groupId: number, patch: Partial): Promise { + return this.getCustomDataMutex(groupId).runExclusive(async () => { + const current = (await this.getRawCustomData(groupId)) ?? {} + const merged = {...current, ...patch} + for (const key of Object.keys(merged) as (keyof CardData)[]) { + if (merged[key] === undefined) delete merged[key] + } + await this.withMainProfile(() => this.chat.apiSetGroupCustomData(groupId, merged)) + }) +} + +async clearCustomData(groupId: number): Promise { + return this.getCustomDataMutex(groupId).runExclusive(() => + this.withMainProfile(() => this.chat.apiSetGroupCustomData(groupId)) + ) +} +``` + +Nesting rule: the per-group customData mutex is the **outer** lock; `profileMutex` (via `withMainProfile`) is the **inner** lock. Never acquire them in the opposite order, and never hold the customData mutex while calling an external (non-SimpleX) async function — this prevents cross-group deadlock and keeps the critical section short. + +Cleanup: entries in `customDataMutexes` are bounded by the number of customer groups. Removing the entry on `onLeftMember(customer)` is sufficient (the group's `customData` is also cleared at that point). Skip this refinement in MVP if acceptable — a long-running bot with many customers accumulates a few bytes per group. + +**Profile images:** Both profiles have base64-encoded JPEG profile pictures (128x128, quality 85, under the 12,500-char data URI limit enforced by iOS/Android clients) set via the `image` field in `T.Profile`. The images are defined as `data:image/jpg;base64,...` string constants in `index.ts`. The main profile image is passed to `bot.run()` which handles update-on-change automatically. The Grok profile image is passed to `apiCreateActiveUser()` on first run; on subsequent runs, the bot compares the current profile against the desired one using `util.fromLocalProfile()` and calls `apiUpdateProfile()` if any field differs — this sends the update to all Grok contacts. + +**Startup sequence:** +0. **Active user recovery + name preservation:** Two related safeguards. + + **(a) Active user recovery.** On restart, the active user may be Grok (if the previous run was killed mid-profile-switch). `bot.run()` uses `apiGetActiveUser()` and would then operate against Grok's `userId` as if it were the main user. Fix: when `state.grokUserId` is set (i.e. this is not the very first run), pre-init the DB with a temporary `ChatApi` and compare the active user's `userId` against `state.grokUserId`. If they match, `apiListUsers()` + `apiSetActiveUser()` to the single non-Grok user — throw loudly if zero or multiple candidates exist, rather than silently picking. Close the temporary `ChatApi` before `bot.run()` reopens it. Identification is by userId, never by display name; a renamed Grok profile would defeat name matching. + + **(b) Never rewrite the main profile.** The core auto-creates a preset contact named `"Ask SimpleX Team"` in every user's DB (`src/Simplex/Chat/Library/Internal.hs:2749`, exact name from commit `362bdc328` 2025-07-12). That collides with the bot's preferred main-profile displayName within the user's `display_names` namespace (`UNIQUE (user_id, local_display_name)`), so any attempt to rename the main profile to `"Ask SimpleX Team"` fails with `duplicateName`. Worse, `bot.run`'s internal `updateBotUserProfile` (`packages/simplex-chat-nodejs/dist/bot.js:176`) re-syncs image, preferences, and `contactLink` on every startup, and on a DB where `users.local_display_name` has drifted from `contact_profiles.display_name`, the fast path (`src/Simplex/Chat/Store/Profiles.hs:311`) silently rewrites the customer-facing `contact_profiles.display_name`. Fix: pass `options.updateProfile: false` to `bot.run()` so the bot code never calls `apiUpdateProfile` on its own initiative. Whatever displayName the CLI saw is what stays. + + **(c) Lazy per-group command sync.** The bot's command list (`/grok`, `/team`) is synced lazily and per-group, not globally. `sendToGroup` (in `src/bot.ts`) unconditionally calls `syncGroupCommands(groupId)` before dispatching the message. That helper uses `apiGetChat(Group, groupId, 0)` (local DB read, no network) to read the current `groupProfile.groupPreferences.commands`, and if it doesn't match `desiredCommands`, issues `apiUpdateGroupProfile` with the commands merged in. `apiUpdateGroupProfile` broadcasts `XGrpInfo`/`XGrpPrefs` to group members only — scoped to the chat audience, never the whole contact list. Groups confirmed in-sync are cached in `syncedGroups: Set` so the first send per group costs one local read; every later send is a cache hit. No `apiUpdateProfile` (global XInfo broadcast) is ever invoked by bot code. Earlier drafts gated the sync behind a regex match on the outgoing text (to skip the read when no `/keyword` appeared); that optimization was removed because the cache already made repeated syncs free and the parser was a fragile source of correctness bugs. +1. `bot.run()` → init ChatApi, create/resolve main profile (with profile image), business address. Print business address link to stdout. +2. Resolve Grok profile: if `state.grokUserId` is set, look it up by ID via `apiListUsers()` (throw if missing); otherwise create via `apiCreateActiveUser()` and persist the new `userId`. Then compare the resolved profile against the canonical `{displayName, fullName, image}` and call `apiUpdateProfile()` if anything changed — pushes to Grok's contacts. +3. Read `{dbPrefix}_state.json` for `teamGroupId` and `grokContactId` +4. Enable auto-accept DM contacts: `apiSetAutoAcceptMemberContacts(mainUser.userId, true)` +5. List contacts, resolve Grok contact (from state or auto-establish) +6. Resolve team group (from state or auto-create) +7. Ensure direct messages + delete for everyone enabled on team group (conditional — only updates profile if preferences or name differ from desired) +8. Create team group invite link (best-effort), schedule 10min deletion if created +9. Validate `--auto-add-team-members` (`-a`) if provided +10. Register Grok event handlers on `chat` (filtered by `event.user === grokUserId`) +10b. Refresh stale cards: `CardManager.refreshAllCards()` — lists all groups, skips those with `customData.complete` or no `customData.cardItemId`, sorts remaining by `cardItemId` ascending, re-posts oldest-first so newest cards land at the bottom of team group +11. On SIGINT/SIGTERM → `clearTimeout(inviteLinkTimer)` (noop if already deleted), `cards.destroy()` (stops the card-flush interval), `deleteInviteLink()` (profileMutex-gated `apiDeleteGroupLink`), `process.exit(0)`. Signal handler is reentrant-safe: an `inviteLinkDeleted` flag prevents double-deletion; `clearTimeout`/`clearInterval` are no-op on undefined. + +**Grok event registration** (same ChatApi, filtered by profile): + +```typescript +chat.on("receivedGroupInvitation", async (evt) => { + if (evt.user.userId !== grokUserId) return + supportBot?.onGrokGroupInvitation(evt) +}) +chat.on("newChatItems", async (evt) => { + if (evt.user.userId !== grokUserId) return + supportBot?.onGrokNewChatItems(evt) +}) +chat.on("connectedToGroupMember", (evt) => { + if (evt.user.userId !== grokUserId) return + supportBot?.onGrokMemberConnected(evt) +}) +``` + +## 8. Event Processing + +**Main profile event handlers:** + +| Event | Handler | Action | +|-------|---------|--------| +| `acceptingBusinessRequest` | `onBusinessRequest` | Enable file uploads + visible history on business group | +| `newChatItems` | `onNewChatItems` | Route: team group → handle `/join`; customer group → derive state, dispatch; direct message → reply with business address link | +| `chatItemUpdated` | `onChatItemUpdated` | Schedule card update | +| `leftMember` | `onLeftMember` | Customer left → cleanup, card remains. Grok left → cleanup. Team member left → revert if no message sent. | +| `joinedGroupMember` | `onJoinedGroupMember` | Team group joiner (link-join): initiate DM via `apiCreateMemberContact` + `apiSendMemberContactInvitation`. Fires for any member joining via group invite link. | +| `connectedToGroupMember` | `onMemberConnected` | In team group: send DM with contact ID (if not already sent by `onJoinedGroupMember`). In customer group: promote to Owner (unless customer or Grok). | +| `chatItemReaction` | `onChatItemReaction` | Team/Grok reaction in customer group → schedule card update (auto-complete) | +| `newMemberContactReceivedInv` | `onMemberContactReceivedInv` | Team group member DM contact received: send contact ID message immediately (dedup via `sentTeamDMs`) | +| `contactConnected` | `onContactConnected` | Deliver pending DM if queued (dedup via `sentTeamDMs`) | +| `contactSndReady` | `onContactSndReady` | Deliver pending DM if queued (dedup via `sentTeamDMs`) | + +**Grok profile event handlers:** + +| Event | Handler | Action | +|-------|---------|--------| +| `receivedGroupInvitation` | `onGrokGroupInvitation` | Look up `pendingGrokJoins`; if found, auto-accept via `apiJoinGroup`; if not found (race), buffer in `bufferedGrokInvitations` for `activateGrok` to drain | +| `connectedToGroupMember` | `onGrokMemberConnected` | Grok now fully connected — read last 100 msgs from own view, call Grok API, send initial response | +| `newChatItems` | `onGrokNewChatItems` | Batch dedup: collect last customer text message per group in the event. Skip groups with `grokInitialResponsePending` set (initial combined response in flight). For the selected message: read last 100 msgs, call Grok API, send response. Non-text (images, files, voice) → ignored by Grok (card update handled by main profile). | + +**Message routing in `onNewChatItems` (main profile):** + +```typescript +// For each chatItem: +// 1. Direct message (not group) → reply with business address link, stop +// 2. Team group (groupId === teamGroupId) → handle /join command +// 3. Skip non-business-chat groups +// 4. Skip groupSnd (own messages) +// 5. Identify sender via businessChat.customerId +// 6. Team member message → check if first team text (trigger one-way gate: remove Grok, disable /grok), schedule card update +// 7. Customer message → derive state, dispatch: +// - WELCOME: create card, send queue msg (or handle /grok first msg → WELCOME→GROK, skip queue) +// - QUEUE: /grok → invite Grok; /team → add ALL configured team members; else schedule card update +// - GROK: /team → add ALL configured team members (Grok stays); else schedule card update +// - TEAM-PENDING: /grok → invite Grok if not present, else ignore; /team → if team members still present, reply "already invited"; if all team members have left, re-add silently (state stays TEAM-PENDING); else no action +// - TEAM: /grok → reply "team mode"; else no action +``` + +## 9. One-Way Gate + +The gate is event-driven and persists its transitions. The initial `/team` guard reads `customData.state` AND group composition: if state is already `TEAM-PENDING`/`TEAM` **and** team members are still present, the bot replies `teamAlreadyInvitedMessage` without re-adding. If state is `TEAM-PENDING`/`TEAM` but all team members have left, the bot re-adds them (state stays `TEAM-PENDING`). The first-team-message detection writes `state: 'TEAM'` into customData at the moment the bot observes the message, then removes Grok and disables `/grok`. + +1. User sends `/team` → ALL configured `--auto-add-team-members` (`-a`) added to group (each promoted to Owner at invite time via `apiSetMembersRole`, re-asserted on connect as fallback) → Grok stays if present → TEAM-PENDING +2. Repeat `/team` → detected via `customData.state ∈ {TEAM-PENDING, TEAM}` **and team members still present** → reply with `teamAlreadyInvitedMessage`. If team members have since left, re-add them silently (state stays `TEAM-PENDING`). +3. `/grok` still works in TEAM-PENDING (if Grok not present, invite it; if present, ignore — Grok responds to customer messages) +4. Any team member sends first text message in customer group → **gate triggers**: + - Remove Grok from group (`apiRemoveMembers`) + - `/grok` permanently disabled → replies: "You are now in team mode. A team member will reply to your message." + - State = `TEAM` (written as `customData.state = 'TEAM'` at observation time) +5. Detection: in `onNewChatItems`, when sender is a team member and `customData.state !== 'TEAM'`, trigger the gate and write `state: 'TEAM'` via `mergeCustomData`. + +**Edge cases:** +- All team members leave before sending → state stays `TEAM-PENDING` (customer is still waiting for a response); sending `/team` re-adds them without the "already invited" reply. +- Team member leaves after sending → state stays `TEAM` (`customData.state` persists). Customer can send `/team` again to re-add team members. + +## 10. Grok Integration + +Grok is a **second user profile** in the same ChatApi instance. Self-contained: watches its own events, reads history from its own view, calls Grok HTTP API, sends responses. + +### Grok-disabled mode (no `GROK_API_KEY`) + +If `GROK_API_KEY` is unset or empty, `parseConfig` returns `grokApiKey: null` (via `process.env.GROK_API_KEY || null`, so `GROK_API_KEY=` is treated the same as unset; no throw) and `index.ts` derives `grokEnabled = config.grokApiKey !== null`. When `grokEnabled === false`: + +- Startup logs: `"No GROK_API_KEY provided, disabling Grok support"`. +- **`config.grokContactId` is still restored from the state file** (the lookup runs unconditionally before the `if (grokEnabled)` block). This ensures `getGroupComposition` can identify Grok members so the one-way gate can remove them when a team member sends a text message — even while Grok API is disabled. Without this, Grok members would become "phantom" members: physically present in groups but invisible to the state machine, preventing the gate from firing and causing dual responses (Grok + team) if Grok is later re-enabled. +- The Grok profile is not resolved or created (no `apiListUsers`/`apiCreateActiveUser` for "Grok"; no invite link issued). +- `GrokApiClient` is not instantiated. +- `SupportBot` receives `grokApi = null` and `grokUserId = null`. +- Bot command list registered at startup contains only `/team` — `/grok` is not advertised. +- Grok event handlers (`receivedGroupInvitation`, `connectedToGroupMember`, Grok-side `newChatItems`) are not registered. Handlers that are shared with the main profile (e.g. `onMemberConnected`) remain correct because their Grok checks are guarded by `this.config.grokContactId !== null`. +- Customer-facing messages (`queueMessage`, `noTeamMembersMessage`) accept a `grokEnabled` flag and drop the `/grok` clause when false. +- If the customer still types `/grok` manually, `processMainChatItem` rewrites `cmd` to `null` when `rawCmd?.keyword === "grok" && !this.grokEnabled`, so the dispatcher treats it as an unrecognized command (same as any other plain text). +- Defense in depth: `activateGrok` and `processGrokChatItem` short-circuit on entry when `this.grokApi === null`; `withGrokProfile` throws if called with `grokUserId === null`. + +Type signatures affected: +- `Config.grokApiKey: string | null` +- `SupportBot` constructor: `chat, grokApi: GrokApiClient | null, config, mainUserId, grokUserId: number | null, desiredCommands: T.ChatBotCommand[]` — `desiredCommands` is required (used by `sendToGroup`'s lazy per-group commands sync; see §20.4 suite 30 and the §7 "Note" describing `syncGroupCommands`). +- `queueMessage(timezone: string, grokEnabled: boolean): string` +- `noTeamMembersMessage(grokEnabled: boolean): string` (was a plain `const string`) + +### Grok join flow + +**Critical:** `activateGrok` awaits `waitForGrokJoin(120s)` which depends on future events dispatched through the same sequential event loop (`runEventsLoop` in api.ts). Awaiting it in an event handler deadlocks — the event loop is blocked waiting for events it can't dispatch. **Solution:** All `activateGrok` calls use `fireAndForget()` — tracked but not awaited. Tests call `bot.flush()` to await completion. + +**Main profile side (invite + failure detection):** +0. Send `grokInvitingMessage` ("Inviting Grok, please wait...") +1. **Set `grokInitialResponsePending.add(groupId)` FIRST** — the gate must be raised before any operation that could make Grok recognizable to `onGrokNewChatItems`. Specifically: before `apiAddMember`, before `pendingGrokJoins` is set, and before `bufferedGrokInvitations` is drained (which populates `reverseGrokMap`). Without this ordering, the sequence `apiAddMember → pendingGrokJoins.set → drain → reverseGrokMap.set → gate.add` contains a window where `reverseGrokMap` identifies the group as a Grok-active group but the gate is still DOWN. A customer message arriving in that window triggers a per-message response concurrent with the initial combined response — producing duplicate Grok replies. Every error path below MUST clear the gate. +2. **Pre-check via `apiListMembers`**: silent return if Grok is already in the group in any non-terminal status (covers `GSMemInvited`, which the SimpleX API would otherwise resend the invitation for without throwing). Then `apiAddMember(groupId, grokContactId, Member)` → get `member.memberId`. On `groupDuplicateMember` (race between pre-check and add — Grok joined as Connected meanwhile), **clear the gate** and silent return — the in-flight activation handles the outcome. On any other error, clear the gate, revert state, send `grokUnavailableMessage`. +3. Store `pendingGrokJoins.set(memberId, mainGroupId)` +4. Drain `bufferedGrokInvitations` — if the `receivedGroupInvitation` event arrived during step 2's await (race condition), process it now. (The gate is already up from step 1, so `onGrokNewChatItems` suppresses any per-message responses during drain and the subsequent join.) +5. `waitForGrokJoin(120s)` — awaits resolver from Grok profile's `connectedToGroupMember` (step 8 below) +6. Timeout → notify customer (`grokUnavailableMessage`), send queue message if was WELCOME→GROK, fall back to QUEUE (CAS-guarded: only if `customData.state` is still `GROK` — a concurrent `/team` that switched to `TEAM-PENDING` is respected), clear `grokInitialResponsePending` + +**Grok profile side (independent, triggered by its own events):** +7. `receivedGroupInvitation` → look up `pendingGrokJoins` by `evt.groupInfo.membership.memberId`. If found, auto-accept via `apiJoinGroup(groupId)`, set up `grokGroupMap` and `reverseGrokMap`. If not found (race: event arrived before step 2), buffer in `bufferedGrokInvitations` for step 3. Grok is NOT yet connected — cannot read history or send messages. +8. `connectedToGroupMember` → Grok now fully connected. Uses `reverseGrokMap` to find `mainGroupId`, resolves `grokJoinResolvers` — this unblocks step 5. + +**Back in `activateGrok` (after step 5 resolves):** +9. Read visible history — last 100 messages — build Grok API context (customer messages → `user` role) +10. If no customer messages found (visible history disabled or API failed), send generic greeting asking customer to repeat their question +11. Call Grok HTTP API (outside mutex) +12. Send response via `apiSendTextMessage` (through mutex with Grok profile) +13. Clear `grokInitialResponsePending` (via `finally` block — runs on success, failure, or early return). After this, per-message responses from `onGrokNewChatItems` resume normally for subsequent customer messages. Note: because the gate is raised at step 1 (before any other work), the `finally` block MUST be wired to cover every code path from step 1 onward — including the `groupDuplicateMember` silent-return and all revert/timeout branches — otherwise per-message responses stay suppressed indefinitely for the affected group. + +```typescript +const pendingGrokJoins = new Map() // memberId → mainGroupId +const bufferedGrokInvitations = new Map() // memberId → buffered event +const grokGroupMap = new Map() // mainGroupId → grokLocalGroupId +const reverseGrokMap = new Map() // grokLocalGroupId → mainGroupId +const grokJoinResolvers = new Map void>() // mainGroupId → resolve fn +const grokInitialResponsePending = new Set() // mainGroupIds where activateGrok is sending initial response +``` + +### Per-message Grok conversation + +Grok profile's `onGrokNewChatItems` handler: +1. **Batch deduplication:** When multiple customer messages arrive in a single `newChatItems` event (e.g., rapid messages delivered as a batch), collect the last customer message per group. Only the last message triggers a Grok API call — earlier messages are included in the history context via `apiGetChat`. Without this, each message in the batch would trigger a separate API call, and earlier calls would include later messages in their history (already in the group) — producing incoherent responses that reference messages "from the future" and duplicate replies. +2. **Initial response gate:** Skip groups where `grokInitialResponsePending` is set (checked via `reverseGrokMap` to translate Grok's local groupId to mainGroupId). This prevents per-message responses from racing with the initial combined response in `activateGrok`. +3. Only trigger for `groupRcv` **text** messages from customer (identified via `businessChat.customerId`) +4. Ignore: non-text messages (images, files, voice — card update handled by main profile), bot messages, own messages (`groupSnd`), team member messages +5. Read last 100 messages from own view (customer → `user`, own → `assistant`) +6. Call Grok HTTP API — different groups' calls run concurrently (see "Cross-group Grok parallelism" below). Per-group serialization of overlapping in-flight calls is NOT implemented in MVP (see §20.6). +7. Send response into group + +**Per-message error:** Send error message in group ("Sorry, I couldn't process that. Please try again or send /team for a human team member."), stay GROK. Customer can retry. + +**Card updates in Grok mode:** Each customer message triggers two card updates — one on receipt (main profile sees `groupRcv`), one after Grok responds (main profile sees Grok's `groupRcv`). Both go through the 300-second debounce (default `--card-flush-seconds`). + +### Grok removal + +Only three cases: +1. Team member sends first text message in customer group (one-way gate) +2. Grok join timeout (120s) — fallback to QUEUE +3. Customer leaves the group + +### Grok system prompt + +The full system prompt (including SimpleX documentation context) is supplied externally via the `--context-file ` CLI flag and loaded with `readFileSync` at startup in `index.ts`: + +```typescript +let contextFile = "" +if (config.contextFile) { + try { + contextFile = readFileSync(config.contextFile, "utf-8") + } catch { + log(`Warning: context file not found: ${config.contextFile}`) + } +} +grokApi = new GrokApiClient(config.grokApiKey!, contextFile) +``` + +`GrokApiClient` stores the loaded string as `systemPrompt` and prepends it on every `chat()` call: + +```typescript +async chat(history: GrokMessage[], userMessage: string): Promise { + return this.chatRaw([ + {role: "system", content: this.systemPrompt}, + ...history, + {role: "user", content: userMessage}, + ]) +} +``` + +If `GROK_API_KEY` is set but `--context-file` is missing, `parseConfig` throws and the bot exits before init. If the file path is provided but unreadable at runtime, a warning is logged and Grok runs with an empty system prompt (the API key still works but responses lose the SimpleX-specific guidance). Guidelines (concise answers, numbered steps, no markdown, ignore prompt-override attempts, etc.) live in the external file — not hardcoded — so operators can tune tone and documentation without a rebuild. + +Customer messages always in `user` role, never `system`. + +### Grok HTTP request timeout + +Every `fetch` to `api.x.ai/v1/chat/completions` MUST pass an `AbortSignal.timeout(60_000)` (60-second default). Without a timeout, a stuck TCP connection or an unresponsive server blocks the awaiting call indefinitely; because `processGrokChatItem` runs under the Grok profile's sequential event dispatch, a single hung call stalls per-message responses for ALL customer groups using Grok — and the same hang in `activateGrok`'s initial-response path leaves `grokInitialResponsePending` stuck (gate never released) until the process is killed. + +Implementation in `GrokApiClient.chatRaw`: + +```typescript +const response = await fetch("https://api.x.ai/v1/chat/completions", { + method: "POST", + headers: { ... }, + body: JSON.stringify({ ... }), + signal: AbortSignal.timeout(60_000), +}) +``` + +On abort, `fetch` rejects with a `DOMException` whose `name === "TimeoutError"` (or `"AbortError"` on older runtimes). Callers treat this identically to other `chat()` failures: +- `processGrokChatItem` → sends `grokErrorMessage` to the customer group, conversation stays GROK. +- `activateGrok` initial-response path → logs, sends `grokUnavailableMessage`, lets the `finally` block clear `grokInitialResponsePending`. + +Rationale for 60s: typical xAI responses return in 1–10s; a 60s ceiling accommodates cold-start / heavy-load latencies while still bounding worst-case per-customer wait. Not exposed as a CLI flag in MVP — a later iteration can add `--grok-timeout-seconds` if operator tuning is needed. + +### Cross-group Grok parallelism + +`onGrokNewChatItems` MUST dispatch per-group work concurrently. A naïve `for (const ci of lastPerGroup.values()) { await this.processGrokChatItem(ci) }` serializes calls across unrelated customer groups — if xAI takes 3s per call and five customers message in one event batch, customer #5 waits ~15s instead of ~3s. This is pure latency amplification with no ordering benefit (the groups are independent; within-group order is already preserved by batch deduplication picking the last message). + +Implementation: + +```typescript +async onGrokNewChatItems(evt: CEvt.NewChatItems): Promise { + const lastPerGroup = new Map() + for (const ci of evt.chatItems) { + // filter: groupRcv, customer text, not bot/team + // keep last per groupId + } + await Promise.allSettled( + [...lastPerGroup.values()].map((ci) => this.processGrokChatItem(ci)), + ) +} +``` + +Why `Promise.allSettled` (not `Promise.all`): one group's Grok API failure MUST NOT cancel or reject pending work for other groups. Each `processGrokChatItem` already handles its own errors (sends `grokErrorMessage`, logs); the outer handler only needs to wait until all per-group tasks finish before returning control to the event dispatcher. + +Concurrency bound: the number of distinct customer groups that have new Grok-eligible messages in a single event batch — typically ≤ the SimpleX batch-delivery size, practically small. No global semaphore needed in MVP. If xAI rate limits become a concern, add a shared semaphore later; orthogonal to this fix. + +Ordering guarantees preserved: +- Within a group, batch deduplication still picks only the latest message and earlier messages appear in the history context via `apiGetChat`. +- Across groups, there is no ordering requirement — each customer group is an independent conversation. +- The per-group gate (`grokInitialResponsePending`) still serializes against `activateGrok`'s initial response; this is a group-local check unaffected by cross-group parallelism. + +## 11. Team Group Commands + +| Command | Effect | +|---------|--------| +| `/join ` | Join specified customer group | + +**`/join` handling:** +1. Extract `{keyword, params}` from the chat item with `util.ciBotCommand(chatItem)`. The framework already parses the leading `/keyword` and returns the trimmed remainder as `params` — the handler does not run its own regex over the message text. Cards emit `/'join '`; a team-member tap delivers a chat item whose text is `/join `, which `ciBotCommand` returns as `{keyword: "join", params: ""}`. +2. Convert `params` to a number with `const targetGroupId = Number.parseInt(params, 10)`. If `Number.isNaN(targetGroupId) || targetGroupId <= 0`, reply in the team group with `Error: invalid group id "${params}"` and return. No regex, no `split(":")`, no legacy fallback — operators must use the numeric form (which is what the card always emits). +3. Validate target is a business group (has `businessChat` property) — error in team group if not. +4. Add requesting team member to customer group via `addOrFindTeamMember` (which calls `apiAddMember` + immediately `apiSetMembersRole(Owner)`). +5. On connect, `connectedToGroupMember` re-asserts Owner as an idempotent fallback (see §8). + +**Team member promotion:** Promotion happens at two points, both idempotent: +- **At invite time** — immediately after `apiAddMember`, `addOrFindTeamMember` calls `apiSetMembersRole(groupId, [memberId], Owner)`. The call is wrapped in try/catch: if the member is not yet connected and the API rejects, it's silently ignored (the connect-time promotion covers the fallback). SimpleX persists the role on `GSMemInvited` members so the role is active when they accept. This is only called for *newly invited* members — the pre-check in `addOrFindTeamMember` returns early for any member already in the group in a non-terminal status, so an already-invited member is not re-promoted. +- **On connect** — every `connectedToGroupMember` event in a customer group promotes to Owner unless the member is the customer or Grok. Idempotent. + +**DM handshake:** When a team member joins or connects in the team group, the bot sends a DM with the member's contact ID. Four delivery paths, deduplicated via `sentTeamDMs` Set: + +1. **`onJoinedGroupMember`** — fires when ANY member joins the team group via invite link (`joinedGroupMember` event). Calls `sendTeamMemberDM` without a `memberContact`. Since link-joiners typically have no existing DM contact, this creates the contact via `apiCreateMemberContact(groupId, groupMemberId)`, then sends the invitation with message via `apiSendMemberContactInvitation(contactId, msg)`. +2. **`onMemberConnected`** — `sendTeamMemberDM` called with `memberContact` from the event. If not already sent by path 1: + - If `contactId` exists: sends DM via `apiSendTextMessage`. + - If `contactId` is null: uses the same `apiCreateMemberContact` + `apiSendMemberContactInvitation` path as path 1. +3. **`onMemberContactReceivedInv`** — fires when the member initiates a DM first. Sends the contact ID message immediately. If send fails, queues for `contactConnected`/`contactSndReady`. +4. **`onContactConnected` / `onContactSndReady`** — delivers any pending DM queued by paths 1, 2, or 3. + +DM message: +> Added you to be able to invite you to customer chats later, keep this contact. Your contact ID is `N:name` + +## 12. Message Templates + +```typescript +const welcomeMessage = `Hello! This is a *SimpleX team* support bot - not an AI. +Please ask any question about SimpleX Chat.` + +function queueMessage(timezone: string, grokEnabled: boolean): string { + const hours = isWeekend(timezone) ? "48" : "24" + const base = `The team will reply to your message within ${hours} hours.` + if (!grokEnabled) return base + return `${base} + +If your question is about SimpleX, click /grok for an *instant Grok answer*. + +Send /team to switch back.` +} + +const grokActivatedMessage = `*You are chatting with Grok* - use any language.` + +function teamAddedMessage(timezone: string, grokPresent: boolean): string { + const hours = isWeekend(timezone) ? "48" : "24" + const base = `We will reply within ${hours} hours.` + if (!grokPresent) return base + return `${base} +Grok will be answering your questions until then.` +} + +const teamAlreadyInvitedMessage = "A team member has already been invited to this conversation and will reply when available." + +const teamLockedMessage = "You are now in team mode. A team member will reply to your message." + +function noTeamMembersMessage(grokEnabled: boolean): string { + return grokEnabled + ? "No team members are available yet. Please try again later or click /grok." + : "No team members are available yet. Please try again later." +} + +const grokInvitingMessage = "Inviting Grok, please wait..." + +const grokUnavailableMessage = "Grok is temporarily unavailable. Please try again later or send /team for a human team member." + +const grokErrorMessage = "Sorry, I couldn't process that. Please try again or send /team for a human team member." + +const grokNoHistoryMessage = "I just joined but couldn't see your earlier messages. Could you repeat your question?" +``` + +`teamAddedMessage` takes a second `grokPresent` argument — when the customer switches from GROK → TEAM-PENDING (Grok still in the group until the gate triggers), the message appends a second line telling the customer Grok will keep answering until the team replies. Callers detect this by checking the current group composition for a Grok member before sending. + +**Weekend detection:** +```typescript +function isWeekend(timezone: string): boolean { + const day = new Intl.DateTimeFormat("en-US", {timeZone: timezone, weekday: "short"}).format(new Date()) + return day === "Sat" || day === "Sun" +} +``` + +## 13. Direct Message Handling + +If a user contacts the bot via a regular direct-message address (not business address), the bot replies with the business address link and does not continue the conversation. The reply is guarded by `chatItem.content.type === "rcvMsgContent"` — only actual text messages trigger the business address reply. System events on the DM contact (e.g. `contactConnected`, `rcvDirectEvent`) are ignored to prevent spam. + +## 14. Persistent State + +**State file:** `{dbPrefix}_state.json` — three keys: + +| Key | Type | Why persisted | +|-----|------|---------------| +| `teamGroupId` | number | Team group created once on first run | +| `grokContactId` | number | Bot↔Grok contact takes 60s to establish | +| `grokUserId` | number | Identifies the Grok user by ID across restarts; prevents silent mis-matching if the Grok profile is ever renamed | + +**Not persisted:** + +| State | Where it lives | +|-------|---------------| +| `state`, `cardItemId`, `complete` | Customer group's `customData` | +| `mainUserId` | Returned by `bot.run()` on startup; created fresh per DB | +| Message counts, timestamps | Derived from chat history | +| Customer name | Group display name | +| `pendingGrokJoins` | In-flight during 120s window only | +| `grokInitialResponsePending` | In-flight during `activateGrok` initial response only | +| Owner promotion | Idempotent: fired at invite time in `addOrFindTeamMember` and again on every `memberConnected` | + +**Failure modes:** +- State file deleted → new team group created, Grok contact re-established (60s delay) +- Grok remains in groups it was already in — self-contained, continues responding via own events + +## 15. Error Handling + +| Scenario | Handling | +|----------|----------| +| ChatApi init fails | Exit (let process manager restart) | +| Active user is Grok on restart | Pre-init DB, find main user, set active, close — before `bot.run()` | +| Grok join timeout (120s) | Notify customer, fall back to QUEUE | +| Grok API error (initial or per-message) | Send error in group, stay GROK. Customer can retry or `/team`. | +| `apiAddMember` fails | Send error msg, stay in current state | +| `groupDuplicateMember` on Grok invite | Silent return — in-flight activation handles the outcome (customer sent `/grok` again before join completed) | +| `apiRemoveMembers` fails | Ignore (member may have left) | +| `apiDeleteChatItems` fails (card) | Ignore, post new card, overwrite `customData` | +| Customer leaves | Cleanup in-memory state, card remains | +| Team member leaves (no message sent) | State stays `TEAM-PENDING` (`customData.state` persists). Customer's next `/team` re-adds silently. | +| Team member leaves (message sent) | State stays `TEAM` (`customData.state` persists). Customer's next `/team` re-adds silently. | +| No `--auto-add-team-members` (`-a`) configured | `/team` → "no team members available yet" | +| `grokContactId` unavailable | `/grok` → "temporarily unavailable" | +| Member already in group when `/team` re-runs | `addOrFindTeamMember` pre-checks via `apiListMembers` and skips BOTH `apiAddMember` and the invite-time `apiSetMembersRole(Owner)` entirely if the contact is present in any non-terminal status (so an `Invited`-but-not-yet-accepted member is never re-invited — the SimpleX API would otherwise resend the invitation for `GSMemInvited` — and is never re-promoted) | + +## 16. API Call Map + +| # | Operation | Instance | Method | When | +|---|-----------|----------|--------|------| +| 1 | Init bot | main | `bot.run()` | Startup | +| 2 | List users | chat | `apiListUsers()` | Startup — resolve profiles | +| 3 | Create Grok user | chat | `apiCreateActiveUser()` | First run | +| 4 | Set active user | chat | `apiSetActiveUser(userId)` | Before every API call (via mutex) | +| 5 | Resolve team group | main | `apiNewGroup()` / state file | Startup | +| 6 | Create team invite link | main | `apiCreateGroupLink()` | Startup | +| 7 | Delete team invite link | main | `apiDeleteGroupLink()` | 10min / shutdown | +| 8 | Auto-accept DM | main | `apiSetAutoAcceptMemberContacts(userId, true)` | Startup | +| 9 | List contacts | main | `apiListContacts()` | Startup — validate members | +| 10 | Establish Grok contact | main+grok | `apiCreateLink()` + `apiConnectActiveUser()` | First run | +| 11 | Update group profile | main | `apiUpdateGroupProfile()` | Business request; startup (conditional — only if preferences differ) | +| 12 | Send msg to customer | main | `apiSendTextMessage([Group, gId], text)` | Various | +| 13 | Post card to team group | main | `apiSendMessages(chatRef, [{card text with /'join ' final line}])` | Card create/update — one message per card | +| 14 | Delete card | main | `apiDeleteChatItems([Group, teamGId], [cardItemId], "broadcast")` | Card update | +| 15 | Set customData | main | `apiSetGroupCustomData(gId, data)` | Card lifecycle | +| 16 | Invite Grok | main | `apiAddMember(gId, grokContactId, Member)` | `/grok` | +| 17 | Grok joins | grok | `apiJoinGroup(gId)` | `receivedGroupInvitation` | +| 18 | Grok reads history | grok | `apiGetChat([Group, gId], 100)` | After join + per message | +| 19 | Grok sends response | grok | `apiSendTextMessage([Group, gId], text)` | After API call | +| 20 | Add team member | main | `apiAddMember(gId, teamContactId, Member)` | `/team`, `/join` — only when not already in group | +| 21 | Promote to Owner | main | `apiSetMembersRole(gId, [memberId], Owner)` | Immediately after #20 (invite-time) AND `connectedToGroupMember` (fallback) | +| 22 | Remove Grok | main | `apiRemoveMembers(gId, [memberId])` | Gate trigger / timeout / leave | +| 23 | List members | main | `apiListMembers(gId)` | State derivation, duplicate check | +| 24 | Register team commands | main | `apiUpdateGroupProfile(teamGId, profile)` | Startup — register `/join` in team group | +| 25 | Get group info | main | `apiListGroups()` + find by ID | Card compose — read `customData.cardItemId` from `groupInfo` | +| 26 | Create DM contact | main | `apiCreateMemberContact(gId, memberId)` | `joinedGroupMember` / `onMemberConnected` — bot-initiated DM with team member | +| 27 | Send DM invitation | main | `apiSendMemberContactInvitation(contactId, msg)` | After #26 — sends invite with message in one step | + +## 17. Implementation Sequence + +**Phase 1: Scaffold** +- `package.json`, `tsconfig.json`, `config.ts`, `util.ts` (isWeekend, profileMutex) +- `index.ts`: init ChatApi, resolve both profiles, state file, startup sequence +- **Verify:** Instance inits, profiles resolved, Grok contact established, team group created + +**Phase 2: Event processing + cards** +- `bot.ts`: SupportBot class, state derivation helpers, event dispatch +- `cards.ts`: CardManager — format, debounce, lifecycle (create/update/cleanup) +- `messages.ts`: all templates +- Handle `acceptingBusinessRequest` → enable file uploads + visible history +- Handle `newChatItems` → WELCOME/QUEUE routing, card creation +- Handle DM → reply with business address link +- **Verify:** Customer connects → welcome → sends msg → card appears in team group → queue reply + +**Phase 3: Grok integration** +- `grok.ts`: GrokApiClient with system prompt + docs +- Grok event handlers (invitation → join, newChatItems → respond) +- `/grok` activation: invite, wait join, Grok reads history + responds independently +- `/grok` as first message (WELCOME → GROK, skip queue) +- Per-message Grok conversation + serialization per group +- **Verify:** `/grok` → Grok joins as separate participant → responds from "Grok" + +**Phase 4: Team mode + one-way gate** +- `/team` → add team members, Grok stays +- One-way gate: detect first team text → remove Grok, disable `/grok` +- `/join` command in team group (validate business group, add member, promote Owner) +- DM handshake with team members +- Team member promotion on `connectedToGroupMember` +- **Verify:** Full flow: QUEUE → /grok → GROK → /team → TEAM-PENDING → team msg → TEAM + +**Phase 5: Polish** +- Edge cases: customer leave, Grok timeout, member leave, restart recovery +- Team group invite link lifecycle +- Graceful shutdown +- Supply Grok context via `--context-file ` at runtime (required when `GROK_API_KEY` is set) +- End-to-end test all flows + +## 18. Self-Review Requirement + +Each code artifact must undergo adversarial self-review/fix loop: +1. Write/edit code +2. Self-review against this plan: correctness, completeness, all state transitions, all API calls, all error cases +3. Fix issues found +4. Repeat until **2 consecutive zero-issue passes** +5. Report completion → user reviews → if changes needed, restart from step 1 + +## 19. Verification + +**Startup:** +```bash +cd apps/simplex-support-bot +npm install +# With Grok support: +GROK_API_KEY=xai-... npx ts-node src/index.ts \ + --team-group SupportTeam \ + --timezone America/New_York \ + --context-file ./context.md + +# Without Grok (logs "No GROK_API_KEY provided, disabling Grok support"): +npx ts-node src/index.ts \ + --team-group SupportTeam \ + --timezone America/New_York +``` + +**Test scenarios:** +1. Connect → verify welcome message, business address link printed to stdout +2. Send question → verify card appears in team group (🆕), queue reply received +3. `/grok` → verify Grok joins, responses from "Grok", card updates to 🤖 +4. `/grok` as first message → verify WELCOME→GROK, no queue message, card 🤖 +5. `/team` in GROK → verify team added, Grok stays, card 👋 Team-pending +6. `/grok` in TEAM-PENDING → verify Grok still responds +7. Team member sends text → verify Grok removed, `/grok` rejected, card → 💬 +8. `/grok` in TEAM → verify "team mode" rejection +9. `/team` when already invited → verify "already invited" message +10. Card debouncing: multiple rapid events → verify single card update per 300s flush (default) +11. `/join` from team group → verify team member added to customer group, promoted to Owner +12. `/join` with non-business group → verify error +13. Weekend → verify "48 hours" +14. Customer leaves → verify cleanup, card remains +15. Grok timeout → verify fallback to QUEUE, queue message sent +16. Grok API error (per-message) → verify error in group, stays GROK +17. Grok no-history fallback → verify generic greeting sent +18. Non-text message in GROK mode → verify no Grok API call, card updated +19. Team/Grok reaction → verify card auto-complete (✅ icon, "done") +20. DM contact text message → verify business address link reply +21. DM contact non-message event (e.g. contactConnected) → verify no reply (rcvMsgContent guard) +22. DM handshake via `joinedGroupMember` → team member joins team group via link → verify `apiCreateMemberContact` + `apiSendMemberContactInvitation` called, contact ID message sent +23. DM handshake via `connectedToGroupMember` → verify contact ID message sent (dedup with #22) +24. Restart → verify same team group + Grok contact from state file, cards resume via `customData` +25. No `--auto-add-team-members` (`-a`) → `/team` → verify "no team members available" +26. Repeated `/team` while members are still in `Invited` status → verify `apiAddMember` is NOT called again (pre-check in `addOrFindTeamMember` returns the existing member) +27. Team member leaves (no message sent) → verify revert to QUEUE +28. Team member leaves (message sent), customer sends `/team` → verify re-adds team members +29. Card preview sender prefixes → verify first message in each consecutive sender run gets `Name:` prefix, subsequent same-sender messages do not +30. `/team` after all team members left → verify re-adds team members (not "already invited") + +### Critical Reference Files + +- **Native library API:** `packages/simplex-chat-nodejs/src/api.ts` +- **Bot automation:** `packages/simplex-chat-nodejs/src/bot.ts` +- **Utilities:** `packages/simplex-chat-nodejs/src/util.ts` +- **Types:** `packages/simplex-chat-client/types/typescript/src/types.ts` +- **Events:** `packages/simplex-chat-client/types/typescript/src/events.ts` +- **Product spec:** `apps/simplex-support-bot/plans/20260207-support-bot.md` + +## 20. Testing + +Vitest 1.x (Node 18 compatible). All tests verify **observable behavior** — messages sent, members added/removed, cards posted/deleted, API calls made — never internal state. + +### 20.1 Mock Infrastructure + +**Approach:** Vite resolve aliases redirect native-dependent packages to lightweight JS stubs at build time. Tests import from TypeScript source (`./src/bot.js`) — Vitest transpiles inline, so mocks apply before any code runs. + +**Files:** + +| File | Purpose | +|------|---------| +| `bot.test.ts` | All tests (co-located with source) | +| `vitest.config.ts` | Resolve aliases, globals, timeout | +| `test/__mocks__/simplex-chat.js` | CJS stub: `api.ChatApi`, `util.ciContentText`, `util.ciBotCommand`, `util.contactAddressStr` | +| `test/__mocks__/simplex-chat-types.js` | CJS stub: `T.ChatType`, `T.GroupMemberRole`, `T.GroupMemberStatus`, `T.GroupFeatureEnabled`, `T.CIDeleteMode` | + +```typescript +// vitest.config.ts +export default defineConfig({ + test: { globals: true, testTimeout: 10000 }, + resolve: { + alias: { + "simplex-chat": path.resolve(__dirname, "test/__mocks__/simplex-chat.js"), + "@simplex-chat/types": path.resolve(__dirname, "test/__mocks__/simplex-chat-types.js"), + }, + }, +}) +``` + +**`MockChatApi`** — inline class in `bot.test.ts`: + +- **Tracking arrays:** `sent`, `added`, `removed`, `joined`, `deleted`, `customData`, `roleChanges`, `profileUpdates`, `memberContacts`, `memberContactInvitations` +- **Simulated DB:** `members` (Map), `chatItems` (Map), `groups` (Map), `activeUserId` +- **Failure injection:** `apiAddMemberWillFail(err?)`, `apiDeleteChatItemsWillFail()` +- **Query helpers:** `sentTo(groupId)`, `lastSentTo(groupId)`, `sentDirect(contactId)` +- `apiSendTextMessage` returns `[{chatItem: {meta: {itemId: N}}}]` — auto-incrementing IDs +- `apiGetChat` returns from `chatItems` map with `chatInfo.groupInfo` from `groups` map +- `apiCreateMemberContact(groupId, groupMemberId)` — returns a contact object with auto-incrementing `contactId`. Tracks calls in `memberContacts` array. +- `apiSendMemberContactInvitation(contactId, msg)` — returns a contact object. Tracks calls in `memberContactInvitations` array. + +**`MockGrokApi`** — inline class: + +- `calls` array tracks `{history, message}` for each `chat()` call +- `willRespond(text)` / `willFail()` control responses +- Resets to default response `"Grok answer"` after each failure + +**Key design:** no `vi.mock()` hoisting — resolve aliases intercept all `require()`/`import()` before module evaluation. Console output silenced via `vi.spyOn(console, "log/error")`. + +### 20.2 Factory Helpers & Event Builders + +Tests construct events via composable helpers: + +```typescript +// Factory helpers +makeConfig(overrides?) // Config with defaults (team group, 2 team members, UTC) +makeGroupInfo(groupId, opts?) // GroupInfo with businessChat, customerId, etc. +makeUser(userId) // {userId, profile: {displayName}} +makeChatItem(opts) // ChatItem with dir/text/memberId/msgType +makeAChatItem(chatItem, groupId?) // AChatItem wrapping chatItem + groupInfo + +// Member factories — typed member objects +makeTeamMember(contactId, name?, groupMemberId?) // team member with standard memberId pattern +makeGrokMember(groupMemberId?) // Grok member (default groupMemberId=7777) +makeCustomerMember(status?) // customer member + +// Event builders — return full newChatItems events +customerMessage(text, groupId?) // from customer in customer group +customerNonTextMessage(groupId?) // non-text (image) from customer +teamMemberMessage(text, contactId?, groupId?) // from team member +grokResponseMessage(text, groupId?) // from Grok in customer group +directMessage(text, contactId) // from direct contact +teamGroupMessage(text, senderContactId?) // in team group +grokViewCustomerMessage(text, msgType?) // customer msg arriving in Grok's view + +// Event factories — return full lifecycle events +connectedEvent(groupId, member, memberContact?) // connectedToGroupMember +leftEvent(groupId, member) // leftMember (auto-sets Left status) +updatedEvent(groupId, chatItem, userId?) // chatItemUpdated +reactionEvent(groupId, added) // chatItemReaction +joinedEvent(groupId, member, userId?) // joinedGroupMember + +// History builders — add to mock chatItems map +addBotMessage(text, groupId?) +addCustomerMessageToHistory(text, groupId?) +addTeamMemberMessageToHistory(text, contactId?, groupId?) +addGrokMessageToHistory(text, groupId?) + +// Assertion helpers — intention-revealing, with debuggable failure messages +expectSentToGroup(groupId, substring) // message containing substring sent to group +expectNotSentToGroup(groupId, substring) // no message containing substring sent to group +expectDmSent(contactId, substring) // DM containing substring sent to contact +expectAnySent(substring) // any message (group or DM) containing substring +expectMemberAdded(groupId, contactId) // apiAddMember called with groupId + contactId +expectCardDeleted(cardItemId) // apiDeleteChatItems called with cardItemId +expectMemberContactCreated(groupId, memberId) // apiCreateMemberContact called +expectMemberContactInvSent(contactId) // apiSendMemberContactInvitation called +``` + +### 20.3 State Setup Helpers + +Each helper reaches a specific state, composing from simpler helpers: + +```typescript +async function reachQueue(groupId?) // send first msg → QUEUE (adds queue msg to history) +async function reachGrok(groupId?) // reachQueue → /grok → simulateGrokJoinSuccess → GROK +async function reachTeamPending(groupId?) // reachQueue → /team → TEAM-PENDING +async function reachTeam(groupId?) // reachTeamPending → add team member to mock → team msg → TEAM +``` + +**`simulateGrokJoinSuccess(mainGroupId?)`** — simulates the async Grok join flow: +1. Waits 10ms (lets `activateGrok` reach `waitForGrokJoin`) +2. Fires `onGrokGroupInvitation` (Grok accepts invite) +3. Fires `onGrokMemberConnected` (Grok fully connected → resolver called) + +Called as: `const p = simulateGrokJoinSuccess(); await bot.onNewChatItems(...); await p;` + +### 20.4 Test Catalog (154 tests, 31 suites) + +#### 1. Welcome & First Message (4 tests) +- first message → queue reply + card created with /join command +- non-text first message → no queue reply, no card +- second message → no duplicate queue reply +- unrecognized /command → treated as normal message (triggers queue) + +#### 2. /grok Activation (5 tests) +- /grok from QUEUE → Grok invited, grokActivatedMessage sent (after join confirms) +- /grok as first message → WELCOME→GROK, no queue message, card created +- /grok in TEAM → rejected with teamLockedMessage +- /grok when grokContactId is null → grokUnavailableMessage +- /grok as first message + Grok join fails → queue message sent as fallback + +#### 3. Grok Conversation (11 tests) +- Grok per-message: reads history, calls API, sends response +- customer non-text → no Grok API call +- Grok API error → grokErrorMessage sent +- Grok ignores bot commands from customer +- Grok ignores non-customer messages +- Grok ignores own messages (groupSnd) +- batch: multiple customer messages in one event → only last triggers Grok API call +- batch: messages from different groups → each group gets one response +- batch: non-customer messages filtered, only customer messages trigger response +- batch: across groups → Grok calls overlap in-flight (parallel `Promise.allSettled` dispatch, proven via gated `MockGrokApi.chat`) + +#### 4. /team Activation (4 tests) +- /team from QUEUE → ALL team members added, teamAddedMessage sent +- /team as first message → WELCOME→TEAM-PENDING, no queue message +- /team when already activated (members present) → teamAlreadyInvitedMessage +- /team with no team members → noTeamMembersMessage + +#### 5. One-Way Gate (5 tests) +- team member first TEXT → Grok removed if present +- team member empty text → Grok NOT removed +- /grok after gate → teamLockedMessage +- customer text in TEAM → no bot reply, card update scheduled +- /grok in TEAM-PENDING → invite Grok if not present + +#### 5b. One-Way Gate with Grok Disabled (2 tests) +- team text removes Grok even when grokApi is null +- Grok does not respond when disabled even if grokContactId is set + +#### 6. Team Member Lifecycle (6 tests) +- team member connected → promoted to Owner +- customer connected → NOT promoted +- Grok connected → NOT promoted +- all team members leave → reverts to QUEUE +- /team after all members left (TEAM-PENDING, no msg sent) → re-adds members +- /team after all members left (TEAM, msg was sent) → re-adds members + +#### 7. Card Dashboard (7 tests) +- first message creates card with customer name + /join +- card final line is `/'join '` (single-quoted, numeric id only, no `:name` suffix) +- card update deletes old, posts new +- apiDeleteChatItems failure → ignored, new card posted +- customData stores cardItemId through flush cycle +- concurrent `mergeCustomData` on same group → both patches survive (per-group `customDataMutex` serializes read-modify-write; without the mutex the second write clobbers the first) +- customer leaves → customData cleared + +#### 8. Card Debouncing (5 tests) +- rapid schedules → single card update on flush +- multiple groups pending → each reposted once +- card create is immediate (not debounced) +- flush with no pending → no-op +- flush on group with no `cardItemId` → `createCard` posts a new card (proves `flushOne` dispatches to create-path so a failed `createCard` retries) + +#### 9. Card Format & State Derivation (6 tests) +- QUEUE state derived (no Grok/team) +- WELCOME state derived (customData has no cardItemId) +- GROK state derived (Grok member present) +- TEAM-PENDING derived (team present, no team message) +- TEAM derived (team present + message sent) +- message count excludes bot's own + +#### 10. /join Command (6 tests) +- /join (the only accepted form) → team member added; `params` from `ciBotCommand` is parsed via `Number.parseInt`, no regex +- /join : (historic suffix) → still parses because `Number.parseInt(":", 10)` stops at the colon — handler does not strip the suffix deliberately; the suffix is never emitted by the card +- /join with non-numeric `params` (e.g. `/join abc`) → error reply in team group, no `apiAddMember` call +- /join non-business group → error +- /join non-existent groupId → error +- customer /join in customer group → treated as normal message + +#### 11. DM Handshake (6 tests) +- team member joins team group → DM with contact ID +- name with spaces → single-quoted +- pending DM delivered on contactConnected +- team member with no DM contact → creates member contact via `apiCreateMemberContact` and sends invitation via `apiSendMemberContactInvitation` +- joinedGroupMember in team group → creates member contact via `apiCreateMemberContact` and sends invitation via `apiSendMemberContactInvitation` +- no duplicate DM when sendTeamMemberDM succeeds AND onMemberContactReceivedInv fires + +#### 12. Direct Messages (3 tests) +- regular DM → business address link reply +- DM without business address → no reply +- non-message DM event (e.g. contactConnected) → no reply (rcvMsgContent guard) + +#### 13. Business Request (1 test) +- acceptingBusinessRequest → enables file uploads + visible history + +#### 14. chatItemUpdated Handler (3 tests) +- business group → card update scheduled +- non-business group → ignored +- wrong user → ignored + +#### 15. Reactions (2 tests) +- reaction added → card update scheduled +- reaction removed → no card update + +#### 16. Customer Leave (4 tests) +- customer leaves → customData cleared +- Grok leaves → maps cleaned, no crash +- team member leaves → logged, no crash +- leftMember in non-business group → ignored + +#### 17. Error Handling (3 tests) +- apiAddMember fails (Grok) → grokUnavailableMessage +- /grok while Grok already present (any non-terminal status, including `Invited`) → pre-check silent-returns, no `apiAddMember` call. Plus race coverage: simulated `groupDuplicateMember` thrown by `apiAddMember` → silent return, no further state change +- /team while team member already present (any non-terminal status, including `Invited`) → `apiAddMember` not called for that member + +#### 18. Profile / Event Filtering (4 tests) +- newChatItems from Grok profile → ignored by main handler +- Grok events from main profile → ignored by Grok handlers +- own messages (groupSnd) → ignored +- non-business group messages → ignored + +#### 19. Grok Join Flow (6 tests) +- receivedGroupInvitation → apiJoinGroup called (full async flow) +- unmatched Grok invitation → buffered (not joined until activateGrok drains) +- buffered invitation drained after pendingGrokJoins set → apiJoinGroup called +- per-message responses suppressed during activateGrok initial response (grokInitialResponsePending gate) +- per-message responses resume after activateGrok completes +- activateGrok `groupDuplicateMember` path → gate cleared by outer `finally` (subsequent per-message event still triggers Grok; proves the outer `try/finally` covers every exit path from the entry-time `gate.add`, not just the initial-response section) + +#### 20. Grok No-History Fallback (1 test) +- Grok joins but sees no customer messages → grokNoHistoryMessage + +#### 21. Non-customer card updates (2 tests) +- Grok response → card update scheduled +- team member message → card update scheduled + +#### 22. End-to-End Flows (3 tests) +- WELCOME → QUEUE → /team → TEAM-PENDING → team msg → TEAM +- WELCOME → /grok first msg → GROK +- multiple concurrent conversations are independent + +#### 23. Message Templates (5 tests) +- welcomeMessage includes/omits group links +- grokActivatedMessage content +- teamLockedMessage content +- queueMessage mentions hours + +#### 24. State persistence in customData (5 tests) +- `deriveState` returns `WELCOME` when `customData.state` is absent +- first customer non-command message → handler writes `customData.state = "QUEUE"` +- `/grok` handler → writes `customData.state = "GROK"` +- `/team` handler → writes `customData.state = "TEAM-PENDING"` immediately (before team member accepts) +- first team-member text message → gate writes `customData.state = "TEAM"`; state persists when team member subsequently leaves (not demoted to `QUEUE`) + +#### 25. Card Preview Sender Prefixes (14 tests) +- single customer message → name prefix +- consecutive same-sender → prefix only on first +- alternating senders → each run gets prefix +- Grok messages → "Grok:" prefix +- team member messages → display name prefix +- bot messages (groupSnd) → excluded +- non-text content → media label ([image], [voice], etc.) +- empty messages → skipped +- truncation at maxTotal and maxPer limits (newest messages kept, oldest truncated) +- customer identified by memberId (not contactId) +- newlines in message text → replaced with spaces +- newlines in customer display name → sanitized in card header (card header is the only place the display name appears; `/join` is numeric id only) + +#### 26. Restart Card Recovery (10 tests) +- refreshAllCards refreshes groups with active cards +- no active cards → no-op +- ignores groups without cardItemId in customData +- orders by cardItemId ascending (oldest first, newest last) +- skips cards marked complete +- deletes old card before reposting +- ignores delete failure (>24h old card) +- card flush writes complete: true for auto-completed conversations +- card flush clears complete flag when conversation becomes active again +- continues on individual card failure + +#### 27. joinedGroupMember Event Filtering (2 tests) +- joinedGroupMember in non-team group → ignored +- joinedGroupMember from wrong user → ignored + +#### 28. parseConfig Validation (6 tests) +- `--complete-hours` non-numeric → throws with message including the flag name and raw value +- `--complete-hours` negative → throws +- `--card-flush-seconds` non-numeric → throws +- `--timezone` invalid IANA → throws (probe `Intl.DateTimeFormat` at parse time) +- `--complete-hours 0` → accepted (disables auto-complete) +- valid IANA timezone → accepted + +#### 29. GrokApiClient HTTP timeout (1 test) +- `chat()` calls `AbortSignal.timeout(60_000)` and passes the signal to `fetch` (spies on `AbortSignal.timeout` and on `globalThis.fetch`; proves the timeout is wired through without waiting 60s of wall-clock) + +#### 30. Command sync in sendToGroup (5 tests) +Covers the lazy per-group commands sync introduced with `updateProfile: false`. `sendToGroup` unconditionally calls `syncGroupCommands(groupId)` before dispatching. That helper reads the group via `apiGetChat` (local-only) and issues `apiUpdateGroupProfile` with the merged `groupPreferences.commands` only if the current list doesn't match `desiredCommands`. Groups are cached in `syncedGroups: Set` per process, so later sends skip the read entirely. +- first send → one `apiUpdateGroupProfile` call with `groupPreferences.commands = desiredCommands`; existing `groupProfile.displayName` / `fullName` preserved in the payload; message still delivered (text content is irrelevant — sync always runs) +- group already has desired commands in DB → no `apiUpdateGroupProfile` call, but `syncedGroups` is still populated (next send with different DB state still skips — cache honored) +- cache: two sends to same group → sync fires only once; both messages delivered +- different groups → each synced independently +- existing `groupPreferences` fields (e.g. `files`, `reactions`) are preserved in the update payload; only `commands` changes + +### 20.5 Conventions + +- **File:** `bot.test.ts` (co-located with source, imports from `./src/*.js`) +- **Framework:** Vitest 1.x (Node 18 compatible) with `describe`/`test`/`beforeEach` +- **Mocking:** Vite resolve aliases (not `vi.mock`) — prevents native addon loading +- **Titles:** plain English, `→` separates action from outcome +- **Assertions:** verify observable effects only — messages, API calls, card content +- **No internal state assertions** — never peek at private fields +- **Each test is self-contained** — `beforeEach(() => setup())` creates fresh mocks +- **State helpers compose** — `reachTeam()` calls `reachTeamPending()` which calls `reachQueue()` +- **Grok join simulation** — `simulateGrokJoinSuccess()` uses 10ms setTimeout to fire events during `waitForGrokJoin` await. Tests call `await bot.flush()` after simulation to await fire-and-forget `activateGrok` completion. +- **No fake timers** — real timers everywhere; flush called explicitly via `cards.flush()` and `bot.flush()`. Suite 29 spies on `AbortSignal.timeout` rather than advancing a fake clock so it does not need fake timers either. + +### 20.6 Test Coverage Notes + +**Covered vs plan catalog:** +- §20.4 suites 1-13, 15, 17-30 plus 5b fully covered (154 tests across 31 suites) +- Weekend detection (`util.isWeekend`) — not unit-tested; depends on `Intl.DateTimeFormat(new Date())`, would need clock mocking. Not present in the §20.4 catalog. +- Profile Mutex serialization — not a standalone suite in §20.4; verified implicitly through all other tests (MockChatApi tracks activeUserId). +- Startup & state persistence (`index.ts` path) — not unit-tested; requires native ChatApi. Integration-test only. Includes `deleteInviteLink` (profileMutex + `apiSetActiveUser` before `apiDeleteGroupLink`), the conditional `apiUpdateGroupProfile` (compare `fullGroupPreferences` before calling), the best-effort `apiCreateGroupLink` (catch + log on SMP relay failure), the predicate-filtered `chat.wait("contactConnected", ...)` used to identify the Grok contact (§4), and the team-group `/join` command registration with `params: "groupId"` (§11). Not in §20.4 catalog. + +**Known plan items NOT implemented (conscious gaps, not test gaps):** +- Per-group Grok API call serialization (plan §10) — not implemented or tested +- Team member replacement on leave after sending — out of MVP scope. No plan section currently asserts it as a requirement; if added later, specify in SPEC §4.2 "Team replies" and implementation plan §15 "Error Handling" together. diff --git a/apps/simplex-support-bot/plans/20260207-support-bot.md b/apps/simplex-support-bot/plans/20260207-support-bot.md new file mode 100644 index 0000000000..6ba1380e07 --- /dev/null +++ b/apps/simplex-support-bot/plans/20260207-support-bot.md @@ -0,0 +1,513 @@ +# SimpleX Support Bot — Product Specification + +## Table of Contents + +1. [What](#1-what) +2. [Why](#2-why) +3. [Principles](#3-principles) +4. [Flows](#4-flows) + - [User flow](#41-user-flow) + - [Team flow](#42-team-flow) +5. [Architecture](#5-architecture) + - [CLI overview](#51-cli-overview) + - [Bot architecture](#52-bot-architecture) + - [Grok integration](#53-grok-integration) + - [Persistent state](#54-persistent-state) + +--- + +## 1. What + +A support bot for SimpleX Chat. Customers connect via a business address and get a private group where they can ask questions. The bot triages inquiries through AI (Grok) or human team members. The team sees all active conversations as cards in a single dashboard group. + +## 2. Why + +- **Instant answers.** Grok handles common questions about SimpleX Chat without team involvement. +- **Organized routing.** Every customer conversation appears as a card in the team group — the team sees everything in one place without joining individual conversations. +- **No external tooling.** Everything runs inside SimpleX Chat. No ticketing system, no separate dashboard. +- **Privacy.** Customers talk to the bot in private groups. Only the team sees the messages. + +--- + +## 3. Principles + +- **Opt-in**: Grok is never used unless the user explicitly chooses it. +- **User in control**: The user can switch to Grok or team before a team member replies. Once a team member sends a message, the conversation stays with the team. The user always knows who they are talking to. +- **Minimal friction**: No upfront choices or setup — the user just sends their question. +- **Ultimate transparency**: The user always knows whether they are talking to a bot, Grok, or a human, and what happens with their messages. + +--- + +## 4. Flows + +### 4.1 User Flow + +#### Step 1 — Welcome (on connect, no choices, no friction) + +When a user scans the support bot's QR code or clicks its address link, SimpleX creates a **business group** — a special group type where the customer is a fixed member identified by a stable `customerId`, and the bot is the host. The bot auto-accepts the connection and enables file uploads and visible history on the group. + +If a user contacts the bot via a regular direct-message address instead of the business address, the bot replies with the business address link and does not continue the conversation. Only actual text messages trigger this reply — system events (e.g. `contactConnected`) on the DM contact are ignored. + +Bot sends the welcome message automatically as part of the connection handshake — not triggered by a message: +> Hello! This is a *SimpleX team* support bot - not an AI. +> Please ask any question about SimpleX Chat. + +#### Step 2 — After user sends first message + +The bot's "first message" detection works by inspecting the group's `customData`. Until the bot has produced its first response (and written `cardItemId` to `customData`), the group is in the welcome state. + +On the customer's first message the bot does two things: +1. Creates a card in the team group (🆕 icon, with `/join` command) +2. Sends the queue message to the customer: + +> The team will reply to your message within 24 hours. +> +> If your question is about SimpleX, click /grok for an *instant Grok answer*. +> +> Send /team to switch back. + +On weekends, the bot says "48 hours" instead of "24 hours". + +When the bot is started without `GROK_API_KEY`, the `/grok` paragraphs are omitted — the customer only sees the first line about the team reply window. + +Each subsequent message updates the card — icon, wait time, message preview. The team reads the full conversation by joining via the card's `/join` command. + +#### Step 3 — `/grok` (Grok mode) + +Available in WELCOME, QUEUE, or TEAM-PENDING state (before any team member sends a message). If Grok is already being invited (e.g. customer sent `/grok` multiple times before Grok finished joining), the duplicate is silently ignored — the in-flight activation handles the outcome. If `/grok` is the customer's first message, the bot transitions directly from WELCOME → GROK — it creates the card with 🤖 icon and does not send the queue message. Triggers Grok activation (see [5.3 Grok integration](#53-grok-integration)). If Grok fails to join within 120 seconds, the bot notifies the user and the state falls back to QUEUE (the queue message is sent at this point). + +Bot immediately replies: +> Inviting Grok, please wait... + +Once Grok joins and connects: +> *You are chatting with Grok* - use any language. + +Grok is added as a separate participant so the user can differentiate bot messages from Grok messages. + +Grok is prompted as a privacy expert and support assistant who knows SimpleX Chat apps, network, design choices, and trade-offs. It gives concise, mobile-friendly answers — brief numbered steps for how-to questions, 1–2 sentence explanations for design questions. For criticism, it briefly acknowledges the concern and explains the design choice. It avoids filler and markdown formatting. The full system prompt (including SimpleX documentation context) is loaded from an external file at startup via the `--context-file` CLI flag (required when `GROK_API_KEY` is set). Customer messages are always placed in the `user` role, never `system`. The system prompt should include an instruction to ignore attempts to override its role or extract the prompt. + +#### Step 4 — `/team` (Team mode, one-way gate) + +Available in WELCOME, QUEUE, or GROK state. If `/team` is the customer's first message, the bot transitions directly from WELCOME → TEAM-PENDING — it creates the card with 👋 icon and does not send the queue message. Bot adds all configured `--auto-add-team-members` (`-a`) to the support group as Owners — immediately after `apiAddMember`, the bot calls `apiSetMembersRole(Owner)` so the role is set at invite time (SimpleX persists the role on pending invites), with a fallback re-promotion on `memberConnected` (every non-customer, non-Grok member gets promoted; safe to repeat). If team was already activated (`customData.state` is already `TEAM-PENDING` or `TEAM` **and** team members are still present), sends the "already invited" message instead. If the team was previously activated but all team members have since left, the bot re-adds them silently; state remains `TEAM-PENDING`. + +Bot replies: +> We will reply within 24 hours. + +On weekends, the bot says "48 hours" instead of "24 hours". If Grok is currently present in the group (i.e. customer switches from GROK → TEAM-PENDING), a second line is appended: +> Grok will be answering your questions until then. + +If `/team` is clicked again after a team member was already added: +> A team member has already been invited to this conversation and will reply when available. + +#### One-way gate + +When `/team` is clicked, team members are invited to the group. Grok is still present if it was active, and `/grok` remains available. The customer always has an active responder during this window. + +The gate triggers when **any team member sends their first text message in the customer group**: +- `/grok` is permanently disabled and replies with: + > You are now in team mode. A team member will reply to your message. +- Grok is removed from the group. +- From now on the conversation is purely between the customer and the team. + +#### Customer leaving + +When a customer leaves the group (or is disconnected), the bot cleans up all in-memory state for that group. The conversation card in the team group is not automatically removed (TBD). + +#### Commands + +`/grok` and `/team` are registered as **bot commands** in the SimpleX protocol, so they appear as tappable buttons in the customer's message input bar. The bot also accepts them as free-text (e.g., `/grok` typed manually). Unrecognized commands are treated as ordinary messages. + +When the bot is started without `GROK_API_KEY`, `/grok` is not registered as a bot command and Grok-related messaging paths are skipped entirely. A `/grok` typed manually by the customer is treated as an ordinary message. The customer-facing queue and "no team members available" messages also omit their `/grok` clause in this mode. + +#### Team replies + +When a team member sends a text message or reaction in the customer group, the bot resends the card (subject to debouncing). A conversation auto-completes (✅ icon, "done" wait time) when `completeHours` (default 3h, configurable via `--complete-hours`) pass after the last team/Grok message without any customer reply. The card flush cycle (`--card-flush-seconds`, default 300) checks elapsed time and transitions to ✅ when the threshold is met. If the customer sends a new message — including after ✅ — the conversation reverts to incomplete: the icon is derived from current state (👋 vs 💬 vs ⏰) and wait time counts from the customer's new message. + +### 4.2 Team Flow + +#### Setup + +The team group is created automatically on first run. Its name is set via the `--team-group` CLI argument. The group ID is written to the state file; subsequent runs reuse the same group. Group preferences (direct messages enabled, delete for everyone enabled, team commands registered as tappable buttons) are applied at creation time. On subsequent startups, the bot compares the existing `fullGroupPreferences` with the desired ones and only calls `apiUpdateGroupProfile` if they differ — avoiding unnecessary network round-trips to SMP relays. + +On every startup the bot attempts to generate a fresh invite link for the team group, prints it to stdout, and deletes it after 10 minutes (or on graceful shutdown). Any stale link from a previous run is deleted first. Link creation is best-effort — if the SMP relay is temporarily unreachable, the error is logged and the bot continues without an invite link. + +The operator shares the link with team members. They must join within the 10-minute window. When a team member joins, the bot automatically establishes a direct-message contact with them and sends: + +> Added you to be able to invite you to customer chats later, keep this contact. Your contact ID is `N:name` + +This ID is needed for `--auto-add-team-members` (`-a`) config. The DM is sent as soon as the member joins the team group — the bot proactively creates a DM contact via `apiCreateMemberContact` and delivers the message with the invitation via `apiSendMemberContactInvitation`. If the contact already exists, the message is sent directly. Multiple delivery paths ensure the DM arrives regardless of connection timing. + +Team members are configured as a single comma-separated `--auto-add-team-members` flag (shortcut `-a`; e.g., `--auto-add-team-members "42:alice,55:bob"` or `-a "42:alice,55:bob"`), using the IDs from the DMs above. The bot validates every configured member against its contact list at startup and exits if any ID is missing or the display name does not match. + +Until team members are configured, `/team` commands from customers cannot add anyone to a conversation. The bot logs an error and notifies the customer. + +#### Dashboard — card-based live view + +The team group is **not a conversation stream**. It is a live dashboard of all active support conversations. The bot maintains exactly one message (a "card") per active conversation. Whenever anything changes — a new customer message, a state transition, an agent joining — the bot **deletes the existing card and posts a new one**. The group's message list is therefore always a current snapshot: scroll up to see everything open right now. + +**Trust assumption:** All team group members see all card previews, including customer message content. The team group is a trusted space — only authorized team members should be given access. + +#### Card format + +Each card is **one** message with five parts (the join command is the final line of the card text, not a separate message): + +``` +[ICON] *[Customer Name]* · [wait] · [N msgs] +[STATE][· agent1, agent2, ...] +"[last message(s), truncated]" +/'join [id]' +``` + +**Icon / urgency signal** + +| Icon | Condition | +|------|-----------| +| 🆕 | QUEUE — first message arrived < 5 min ago | +| 🟡 | QUEUE — waiting for team response < 2 h | +| 🔴 | QUEUE — waiting > 2 h with no team response | +| 🤖 | GROK — Grok is handling the conversation | +| 👋 | TEAM — team member added, no reply yet | +| 💬 | TEAM — team member has replied; conversation active | +| ⏰ | TEAM — customer sent a follow-up, team hasn't replied in > 2 h | +| ✅ | Done — no customer reply for `completeHours` (default 3h) after last team/Grok message | + +**Wait time** — time since the customer's last unanswered message. For ✅ (auto-completed) conversations, the wait field shows the literal string "done". For conversations where the team has replied and the customer hasn't followed up, time since last message from either side. + +**State label** + +| Value | Meaning | +|-------|---------| +| `Queue` | No agent or Grok yet | +| `Grok` | Grok is the active responder | +| `Team – pending` | Team member added, hasn't replied yet (takes priority over `Grok` if both are present) | +| `Team` | Team member engaged | + +**Agents** — comma-separated display names of all team members currently in the group. Omitted when no team member has joined. + +**Message preview** — the last several messages, most recent last, separated by a blue `/` (rendered via SimpleX markdown `!3 /!`). Newlines in message text are replaced with spaces to prevent card layout bloat. Newest messages are prioritized — when the total preview exceeds ~500 characters, the oldest messages are truncated (with `[truncated]` prepended) while the newest are always shown. Each message is prefixed with the sender's name (`Name: message`) on the first message in a consecutive run from that sender — subsequent messages from the same sender omit the prefix until a different sender's message appears. Sender identification: Grok is labeled "Grok"; the customer is labeled with their display name (newlines replaced with spaces for display); team members use their display name. The bot's own messages are excluded. Each individual message is truncated to ~200 characters with `[truncated]` appended. Media-only messages show a type label: `[image]`, `[file]`, `[voice]`, `[video]`. + +**Markdown escaping in previews** — SimpleX markdown interprets `!N` (where N is `1`–`6`, `r`, `g`, `b`, `y`, `c`, `m`, or `-`) as styled-text markup, closing at the next `!`. There is no escape mechanism in the parser. To prevent customer/agent message text from triggering false color formatting or interfering with the blue `/` separator, the bot inserts a zero-width space (U+200B) between `!` and any color-trigger character in preview text before joining with the separator. This is invisible to the user but breaks the parser trigger pattern. + +**Join command** — the final line of the card is `/'join '`. The single quotes around `join ` make the whole token clickable in SimpleX clients; when tapped, the client sends `/join ` back to the team group. The bot does not pattern-match the message text — it asks the framework for the structured command (`util.ciBotCommand` returns `{keyword: "join", params: ""}`) and converts `params` to a number with `Number.parseInt`. The numeric form is the only accepted form: there is no `/join :` legacy syntax and no regex fallback. + +The icon in line 1 is the sole urgency indicator — no reactions are used. + +#### Card examples + +--- + +**1. Brand new conversation** + +``` +🆕 *Alice Johnson* · just now · 1 msg +Queue +"Alice Johnson: I can't connect to my contacts after updating to 6.3." +/'join 42' +``` + +--- + +**2. Queue — short wait, two short messages combined in preview** + +``` +🟡 *Emma Webb* · 20m · 2 msgs +Queue +"Emma Webb: Hi" / "Is anyone there? I have an urgent question about my keys" +/'join 88' +``` + +Second message has no prefix because it's the same sender as the first. + +--- + +**3. Queue — urgent, no response in over 2 hours** + +``` +🔴 *Maria Santos* · 3h 20m · 6 msgs +Queue +"Maria Santos: I reset my phone and now all conversations are gone" / "I tried reinstalling but nothing changed" / "Please help, I've lost access to all my conversations after resetting my phone…" +/'join 38' +``` + +--- + +**4. Grok mode — alternating senders** + +``` +🤖 *David Kim* · 1h 5m · 8 msgs +Grok +"David Kim: Which encryption algorithm does SimpleX use for messages?" / "Grok: SimpleX uses double ratchet with NaCl crypto_box for end-to-end encryption…[truncated]" / "David Kim: And what about metadata protection?" +/'join 29' +``` + +Each sender change triggers a new name prefix. David and Grok alternate, so every message gets a prefix. + +--- + +**5. Team invited — no reply yet** + +``` +👋 *Sarah Miller* · 2h 10m · 5 msgs +Team – pending · evan +"Sarah Miller: Notifications completely stopped working after I updated my phone OS. I'm on Android 14…" +/'join 55' +``` + +--- + +**6. Team active — two agents, name with spaces** + +``` +💬 *François Dupont* · 30m · 14 msgs +Team · evan, alex +"François Dupont: OK merci, I will try this and let you know." +/'join 61' +``` + +--- + +**7. Team overdue — customer follow-up unanswered > 2 h** + +``` +⏰ *Wang Fang* · 4h · 19 msgs +Team · alex +"Wang Fang: The app crashes when I open large groups" / "I tried what you suggested but it still doesn't work. Any other ideas?" +/'join 73' +``` + +--- + +#### Card lifecycle + +**Tracking: group customData.** The bot stores the current card's team group message ID (`cardItemId`) in the customer group's `customData` via `apiSetGroupCustomData(groupId, {cardItemId})`. This is the single source of truth for which team group message is the card for a given customer. It survives restarts because `customData` is in the database. + +**Create** — when the customer sends their first message (triggering the Step 2 queue message) or `/grok` as their first message (WELCOME → GROK, skipping Step 2): +1. Bot composes the card as a single message (🆕 for first message, 🤖 for `/grok` as first message; customer name, message preview, `/'join '` as the final line) +2. Bot posts it to the team group via `apiSendTextMessage` → receives back the `chatItemId` +3. Bot writes `{cardItemId: chatItemId}` into the customer group's `customData` + +**Update** (delete + repost) — on every subsequent event: new customer message, team member reply in the customer group, state change (QUEUE → GROK, GROK → TEAM, GROK → QUEUE on join timeout, etc.), agent joining. Card updates are debounced globally — the bot collects all pending card changes and flushes them in a single batch at a configurable interval (default 300 seconds, set via `--card-flush-seconds`). Within a batch, each customer group's card is reposted at most once with the latest state. +1. Bot reads `cardItemId` from the customer group's `customData` +2. Bot deletes the old card in the team group via `apiDeleteChatItem(teamGroupId, cardItemId, "broadcast")` (delete for everyone) +3. Bot composes the new card (updated icon, wait time, message count, preview) +4. Bot posts new card to the team group → receives new `chatItemId` +5. Bot overwrites `customData` with the new `{cardItemId: newChatItemId}` + +If `apiDeleteChatItem` fails (e.g., card was already deleted due to a prior crash), the bot ignores the error and proceeds to post the new card. The new `cardItemId` overwrites `customData`, recovering the lifecycle. + +Because the old card is deleted and the new one is posted at the bottom, the most recently updated conversations always appear last in the team group. + +**Cleanup** — when the customer leaves the group: +1. Bot reads `cardItemId` from `customData` +2. Card is **not deleted** — it remains in the team group until a retention policy is added (resolved state TBD) +3. Bot clears the `cardItemId` from `customData` + +**Completion tracking:** When a card is composed with the ✅ icon (auto-completed), the bot writes `complete: true` into the group's `customData` alongside `cardItemId`. When a customer sends a new message and the card is recomposed as non-✅, the `complete` flag is omitted from the new `customData` (self-healing). This allows the bot to skip completed conversations on restart without re-reading chat history for every group. + +**Restart recovery** — on startup, the bot refreshes existing cards to update wait times, icons, and auto-complete status. It lists all groups, finds those with `customData.cardItemId` set and `customData.complete` not set, sorts by `cardItemId` ascending (higher IDs = more recently updated cards), and re-posts them oldest-first. This ensures the most recently active cards appear at the bottom of the team group (newest position). Completed cards are skipped — they remain as-is until a new customer message triggers the normal event-driven update. Old/pre-bot groups without `customData` are also skipped. The bot attempts to delete the old card message before reposting; deletion failures (e.g., card older than 24h) are silently ignored. Subsequent events resume the normal delete-repost cycle via `customData`. + +#### Team commands + +Team members use these commands in the team group: + +| Command | Effect | +|---------|--------| +| `/join ` | Join the specified customer group as Owner. Card emits the clickable form `/'join '`; the handler reads `groupId` from the framework's structured command (`util.ciBotCommand → {keyword, params}`), not from regex over the message text. | + +`/join` is **team-only** — it is registered as a bot command only in the team group. If a customer sends `/join` in a customer group, the bot treats it as an ordinary message (per the existing rule: unrecognized commands are treated as normal messages). + +#### Joining a customer group + +When a team member taps `/join`, the bot first verifies that the target `groupId` is a business group hosted by the main profile (i.e., has a `businessChat` property). If not, the bot replies with an error in the team group and does nothing. If valid, the bot adds the team member to the customer group (via the shared `addOrFindTeamMember` helper, which promotes to Owner at invite time via `apiSetMembersRole(Owner)`, with a fallback re-promotion on connect). From within the customer group, the team member chats directly with the customer. Their messages trigger card updates in the team group (icon change, wait time reset). The customer sees the team member as a real group participant. + +#### Edge cases + +| Situation | What happens | +|-----------|-------------| +| All team members leave before any sends a message | State stays `TEAM-PENDING` (customer is still waiting for a response). Next `/team` re-adds them silently. | +| Customer leaves | All in-memory state cleaned up; card remains (TBD) | +| No `--auto-add-team-members` (`-a`) configured | `/team` tells customer "no team members available yet" | +| Team member already in customer group | `apiListMembers` lookup finds existing member — no error | + +--- + +## 5. Architecture + +### 5.1 CLI Overview + +``` +GROK_API_KEY=... node dist/index.js --team-group "Support Team" [options] +``` + +**Environment variables:** + +| Var | Required | Purpose | +|-----|----------|---------| +| `GROK_API_KEY` | No | xAI API key for Grok. If unset or empty, the bot starts with Grok API disabled: it logs `"No GROK_API_KEY provided, disabling Grok support"`, the `/grok` command is not registered, customer-facing messages (`queueMessage`, `noTeamMembersMessage`) drop the `/grok` clause, and any `/grok` the customer types is treated as an unrecognized command. Note: `config.grokContactId` is still restored from the state file even when the API is disabled, so the one-way gate can identify and remove Grok members from groups when team takes over. When `GROK_API_KEY` is set, `--context-file` must also be provided — startup fails otherwise. | + +**CLI flags:** + +| Flag | Required | Default | Format | Purpose | +|------|----------|---------|--------|---------| +| `--db-prefix` | No | `./data/simplex` | path | Database file prefix (both profiles share it) | +| `--team-group` | Yes | — | `name` | Team group display name (auto-created if absent, resolved by persisted ID on restarts) | +| `--auto-add-team-members` / `-a` | No | `""` | `ID:name,...` | Comma-separated team member contacts. Validated at startup — exits on mismatch. Without this, `/team` tells customers no members available. | +| `--context-file` | Required when `GROK_API_KEY` set | — | path | Path to the Grok system-prompt / SimpleX documentation context file. Loaded at startup and passed as the `system` message on every Grok API call. Required when `GROK_API_KEY` is set — startup fails otherwise. When missing at runtime (file unreadable), a warning is logged and Grok runs with an empty system prompt. | +| `--timezone` | No | `"UTC"` | IANA tz | For weekend detection (24h vs 48h). Weekend is Saturday 00:00 through Sunday 23:59 in this timezone. | +| `--complete-hours` | No | `3` | number | Hours of customer inactivity after last team/Grok reply before auto-completing a conversation (✅ icon, "done" wait time). | +| `--card-flush-seconds` | No | `300` | number | Seconds between card dashboard update flushes. Lower values give faster updates; higher values reduce message churn. | + +**Why `--auto-add-team-members` (`-a`) uses `ID:name`:** Contact IDs are local to the bot's database — not discoverable externally. The bot DMs each team member their ID when they join the team group. The name is validated at startup to catch stale IDs pointing to the wrong contact. + +**Customer commands** (available as tappable buttons in customer business chats; see implementation plan §7 for the per-group lazy sync): + +| Command | Available | Effect | +|---------|-----------|--------| +| `/grok` | Before any team member sends a message, and only if `GROK_API_KEY` is set | Enter Grok mode | +| `/team` | QUEUE or GROK state | Add team members, permanently enter Team mode once any replies | + +**Unrecognized commands** are treated as normal messages in the current mode. When Grok is disabled (no `GROK_API_KEY`), `/grok` is not registered in the bot command list and, if typed manually, falls into this "unrecognized" path. + +**Team commands** (registered in team group via `groupPreferences`): + +| Command | Effect | +|---------|--------| +| `/join ` | Join the specified customer group as Owner. Card emits the clickable form `/'join '`; the handler reads `groupId` from the framework's structured command (`util.ciBotCommand → {keyword, params}`), not from regex over the message text. | + +### 5.2 Bot Architecture + +The bot process runs a single `ChatApi` instance with **two user profiles**: + +- **Main profile** — the support bot's account ("Ask SimpleX Team"). Owns the business address, hosts all business groups, communicates with customers, communicates with the team group, and controls group membership. On startup the bot checks the main profile for an existing business address via `apiGetUserAddress`; if none exists (first run), it creates one via `apiCreateBusinessAddress`. The address is stored in the SimpleX database as part of the profile — it survives restarts and state file loss without re-creation. The business address link is printed to stdout on every startup. +- **Grok profile** — the Grok agent's account (display name "Grok"). Is invited into customer groups as a Member. Sends Grok's responses so they appear to come from the Grok identity. The Grok user is created by the bot on first run via `apiCreateActiveUser` and its `userId` is persisted to `state.json` as `grokUserId`; subsequent runs look it up by ID (never by name — a renamed profile would silently break name-based matching). On startup, if the profile already exists, the bot compares its current profile (display name, image) against the desired values and calls `apiUpdateProfile()` if anything changed — this pushes the update to all Grok contacts so profile picture changes take effect immediately. + +``` +┌─────────────────────────────────────────────────┐ +│ Support Bot Process (Node.js) │ +│ │ +│ chat: ChatApi ← ChatApi.init("./data/simplex") │ +│ Single database, two user profiles │ +│ │ +│ mainUserId ← "Ask SimpleX Team" profile │ +│ • Business address, event routing, state mgmt │ +│ • Controls group membership │ +│ │ +│ grokUserId ← "Grok" profile │ +│ • Joins customer groups as Member │ +│ • Sends Grok responses into groups │ +│ │ +│ profileMutex: serialize apiSetActiveUser + call │ +│ GrokApiClient → api.x.ai/v1/chat/completions │ +└─────────────────────────────────────────────────┘ +``` + +Before each SimpleX API call, the bot switches to the appropriate profile via `apiSetActiveUser(userId)`. All profile-switching and SimpleX API calls are serialized through a mutex to prevent interleaving. The Grok HTTP API call (external network request to xAI) is made **outside** the mutex — only the profile switch + SimpleX read/send calls need serialization. This prevents a slow Grok response from blocking all other bot operations. + +**Event delivery is profile-independent.** ChatApi delivers events for all user profiles in the database, not just the active one. Every event includes a `user` field identifying which profile it belongs to. `apiSetActiveUser` only affects the context for write/send API calls — it does not filter event subscription. The bot routes events by checking `event.user`: main profile events go to the main handler, Grok profile events go to the Grok handler. + +The Grok profile is self-contained: it watches its own events (`newChatItems`, `receivedGroupInvitation`), calls the Grok HTTP API, and sends responses — all using group IDs from its own events. The main profile only controls Grok's group membership (invite/remove) and reflects Grok's responses in the team group card. + +### 5.3 Grok Integration + +Grok is not a service call hidden behind the bot's account. It is a **second user profile** within the same SimpleX Chat process and database. The customer sees messages from "Grok" as a real group participant — not from the support bot. This is what makes Grok transparent to the user. + +The Grok profile is **self-contained**: it watches its own events, reads group history through its own view, calls the Grok HTTP API, and sends responses — all using its own local group IDs from its own events. No cross-profile ID mapping is needed. + +#### Startup: establishing the bot↔Grok contact + +On first run (no state file), the bot must establish a SimpleX contact between the main and Grok profiles: + +1. Main profile creates a one-time invite link +2. Grok profile connects to it +3. The bot waits up to 60 seconds for `contactConnected` to fire +4. The resulting `grokContactId` is written to the state file + +On subsequent runs, the bot always looks up `grokContactId` from the state file and verifies it still exists in the main profile's contact list — even when `GROK_API_KEY` is not set. This ensures the one-way gate can identify and remove Grok members from groups when a team member sends a text message, preventing "phantom" Grok members that would cause dual responses if Grok is later re-enabled. If the contact is not found and Grok is enabled, it is re-established. + +#### Per-conversation: how Grok joins a group + +When a customer sends `/grok`: + +**Main profile side (failure detection):** +1. Bot sends "Inviting Grok, please wait..." to the customer group +2. Main profile: `apiAddMember(groupId, grokContactId, Member)` — invites the Grok contact to the customer's business group. If `groupDuplicateMember` (customer sent `/grok` again before join completed), the duplicate activation returns silently — the in-flight one handles the outcome. +3. The `member.memberId` is stored in an in-memory map `pendingGrokJoins: memberId → mainGroupId`. Any invitation event that arrived during the `apiAddMember` await (race condition) is drained from the buffer and processed immediately. +4. Main profile receives `connectedToGroupMember` for any member connecting in the group. The bot checks the event's `memberId` against `pendingGrokJoins` — only a match resolves the 120-second promise. This promise is only for failure detection — if it times out, the bot notifies the customer and falls back to QUEUE. + +**Grok profile side (independent, triggered by its own events):** +5. Grok profile receives a `receivedGroupInvitation` event. If a matching `pendingGrokJoins` entry exists, auto-accepts via `apiJoinGroup(groupId)`. If not (race: event arrived before step 3), buffers the event for the main profile to drain. +5. Grok profile reads visible history from the group — the last 100 messages — to build the initial Grok API context (customer messages → `user` role) +6. Grok profile calls the Grok HTTP API with this context +7. Grok profile sends the response into the group via `apiSendTextMessage([Group, groupId], response)` — visible to the customer as a message from "Grok" + +**Initial response gating:** When Grok joins a group, the message backlog may trigger per-message responses (via `newChatItems`) at the same time `activateGrok` is sending the initial combined response. To prevent duplicate replies, per-message responses are suppressed (via `grokInitialResponsePending`) until the initial combined response completes. The flag is set before `waitForGrokJoin` and cleared after the initial response is sent (or fails). Without this gate, customers would receive both individual per-message replies AND a combined initial reply — e.g. 3 replies for 2 messages. + +**Card update:** Main profile sees Grok's response as `groupRcv` and updates the team group card (same mechanism as ongoing Grok messages). + +**Visible history** must be enabled on customer groups (the bot enables it alongside file uploads in the business request handler). This allows Grok to read the full conversation history after joining, rather than only seeing messages sent after it joined. If Grok reads history and finds no customer messages (e.g., visible history was disabled or the API call failed), it sends a generic greeting asking the customer to repeat their question. + +#### Per-message: ongoing Grok conversation + +After the initial response, the Grok profile watches its own `newChatItems` events. It only triggers a Grok API call for `groupRcv` messages from the customer — identified via `businessChat.customerId` on the group's `groupInfo` (accessible to all members). Messages from the bot (main profile), from Grok itself (`groupSnd`), and from team members are ignored. Non-text messages (images, files, voice) do not trigger Grok API calls but still trigger a card update in the team group. + +**Batch deduplication:** When multiple customer messages arrive in a single `newChatItems` event (e.g., rapid messages delivered as a batch), only the last customer message per group triggers a Grok API call. Earlier messages are included in the history context via `apiGetChat`, so the single response addresses all messages in the batch. Without this, each message in the batch would trigger a separate API call, and the earlier calls would include later messages in their history — producing incoherent responses that reference messages "from the future." + +Every subsequent customer text message in a group where Grok is a member: +1. Triggers a card update in the team group (via the main profile, which sees the customer message as `groupRcv`) +2. Grok profile receives the message via its own event, rebuilds history by reading the last 100 messages from its own view of the group (Grok's messages → `assistant` role, customer's messages → `user` role) +3. Grok profile calls the Grok HTTP API and sends the response into the group using the group ID from its own event +4. Main profile sees Grok's response as `groupRcv` and updates the team group card + +In Grok mode, each customer message triggers two card updates — one on receipt (reflecting the new message and updated wait time) and one after Grok responds. This gives the team real-time visibility into active Grok conversations. + +If the Grok HTTP API call fails or times out for a per-message request, the Grok profile sends an error message into the group: "Sorry, I couldn't process that. Please try again or send /team for a human team member." Grok remains in the group and the state stays GROK — the customer can retry by sending another message. + +Grok API calls are NOT serialized per customer group in the MVP. If a new customer message arrives while a Grok API call is in flight, a second call runs concurrently — `apiGetChat` is re-read at the start of each call so history converges eventually, but two rapid messages in the same group can produce interleaved context. Cross-group calls run concurrently by design (see implementation plan §10 "Cross-group Grok parallelism"). Per-group serialization is a planned future improvement. + +#### Grok removal + +Grok is removed from the group (via main profile `apiRemoveMembers`) in three cases: +1. Team member sends their first text message in the customer group +2. Grok join fails (120-second timeout) — graceful fallback to QUEUE, bot notifies the customer +3. Customer leaves the group + +### 5.4 Persistent State + +The bot writes a single JSON file (`{dbPrefix}_state.json`) that survives restarts. It uses the same `--db-prefix` as the SimpleX database files, so the state file is always co-located with the database (e.g. `./data/simplex_state.json` alongside `./data/simplex_chat.db` and `./data/simplex_agent.db`). This ensures backups and migrations that copy the database directory also capture the bot state. + +#### Why a state file at all? + +SimpleX Chat's own database stores the full message history and group membership, but it does not store the bot's derived knowledge — things like which team group was created on first run, or which contact is the established bot↔Grok link. Per-conversation state (QUEUE/GROK/TEAM-PENDING/TEAM) is written into the customer group's `customData` at the moment the bot handles each transition — it observes its own `/grok` invite, `/team` add, team message, first customer message. Only display data (message counts, timestamps, sender names) is re-derived from chat history on demand. + +#### What is persisted and why + +| Key | Type | Why persisted | What breaks without it | +|-----|------|---------------|------------------------| +| `teamGroupId` | number | The bot creates the team group on first run; subsequent runs must find the same group | Bot creates a new empty team group on every restart; all team members lose their dashboard | +| `grokContactId` | number | Establishing a bot↔Grok contact takes up to 60 seconds and is a one-time setup | Every restart requires a 60-second re-connection; if it fails the bot exits | +| `grokUserId` | number | The bot creates the Grok user on first run; subsequent runs identify it by ID so a renamed profile cannot be silently mistaken for the main user | Startup restore (active-user recovery) and Grok profile resolution would fall back to display-name matching — fragile to any rename of the Grok profile | + +The `mainUserId` is **not** persisted — it is resolved at startup from `bot.run()`, which creates the main profile on a fresh DB and returns the user object. + +#### What is NOT persisted and why + +Per-group state (`state`, `cardItemId`, `complete`) lives in SimpleX's database as the group's `customData` — persisted there rather than in the bot's state file. + +| State | Where it lives instead | +|-------|----------------------| +| `state, cardItemId, complete` (per group) | Stored in the group's customData — conversation state, card message ID, auto-completed flag. `state` is written at event time (first customer message, `/grok`, `/team`, team's first message); the bot never re-derives it by scanning chat history. | +| Last customer message time | Derived from most recent customer message in chat history | +| Message count | Derived from message count in chat history (all messages except the bot's own) | +| Customer name | Always available from the group's display name | +| Who sent last message | Derived from recent chat history | +| `pendingGrokJoins` | In-flight during the 120-second join window only | +| Owner role promotion | Not tracked — the bot promotes team members to Owner at two idempotent points: (1) at invite time, immediately after `apiAddMember` in `addOrFindTeamMember` (skipped if the member is already in the group); (2) on every `memberConnected` in a customer group (unless the member is the customer or Grok). Survives restarts. | +| `pendingTeamDMs` | Messages queued to greet team members — simply not sent if lost | +| `grokJoinResolvers`, `grokFullyConnected` | Pure async synchronization primitives — always empty at startup | + +#### Failure modes + +If the state file is deleted or corrupted: +- A new team group is created. Team members must re-join it. +- The bot↔Grok contact is re-established (60-second startup delay). +- Grok remains in any groups it was already a member of. Since the Grok profile watches its own events, it will continue responding to customer messages in those groups without any additional recovery — no cross-profile state needs to be rebuilt. diff --git a/apps/simplex-support-bot/src/bot.ts b/apps/simplex-support-bot/src/bot.ts new file mode 100644 index 0000000000..a25c05ce8a --- /dev/null +++ b/apps/simplex-support-bot/src/bot.ts @@ -0,0 +1,920 @@ +import {api, util} from "simplex-chat" +import {T, CEvt} from "@simplex-chat/types" +import {Config} from "./config.js" +import {GrokMessage, GrokApiClient} from "./grok.js" +import {CardManager, ConversationState} from "./cards.js" +import { + queueMessage, grokInvitingMessage, grokActivatedMessage, teamAddedMessage, + teamAlreadyInvitedMessage, teamLockedMessage, noTeamMembersMessage, + grokUnavailableMessage, grokErrorMessage, grokNoHistoryMessage, +} from "./messages.js" +import {profileMutex, log, logError} from "./util.js" + +// True for any non-terminal status — invited but not yet accepted, through +// connected. Used to decide whether a contact is already in the group so we +// don't trigger a re-invite (the SimpleX API resends the invitation for a +// member in GSMemInvited). +function isInGroup(m: T.GroupMember): boolean { + switch (m.memberStatus) { + case T.GroupMemberStatus.Rejected: + case T.GroupMemberStatus.Removed: + case T.GroupMemberStatus.Left: + case T.GroupMemberStatus.Deleted: + case T.GroupMemberStatus.Unknown: + return false + default: + return true + } +} + +export class SupportBot { + // Card manager + cards: CardManager + + // Grok group mapping: memberId → mainGroupId (for pending joins) + private pendingGrokJoins = new Map() + // Buffered invitations that arrived before pendingGrokJoins was set (race condition) + private bufferedGrokInvitations = new Map() + // mainGroupId → grokLocalGroupId + private grokGroupMap = new Map() + // grokLocalGroupId → mainGroupId + private reverseGrokMap = new Map() + // mainGroupId → resolve fn for grok join + private grokJoinResolvers = new Map void>() + // mainGroupIds where Grok connectedToGroupMember fired + private grokFullyConnected = new Set() + // Suppress per-message Grok responses while activateGrok sends the initial combined response + private grokInitialResponsePending = new Set() + + // Pending DMs for team group members (contactId → message) + private pendingTeamDMs = new Map() + // Contacts that already received the team DM (dedup) + private sentTeamDMs = new Set() + + // Tracked fire-and-forget operations (for testing) + private _pendingOps: Promise[] = [] + + // Bot's business address link + businessAddress: string | null = null + + // Groups whose groupPreferences.commands we've already verified/synced + // in this process. Populated lazily by syncGroupCommands() on the first + // send to each group. + private syncedGroups = new Set() + + constructor( + private chat: api.ChatApi, + private grokApi: GrokApiClient | null, + private config: Config, + private mainUserId: number, + private grokUserId: number | null, + private desiredCommands: T.ChatBotCommand[], + ) { + this.cards = new CardManager(chat, config, mainUserId, config.cardFlushSeconds * 1000) + } + + private get grokEnabled(): boolean { + return this.grokApi !== null + } + + // Wait for all fire-and-forget operations to settle (for testing) + async flush(): Promise { + while (this._pendingOps.length > 0) { + const ops = this._pendingOps.splice(0) + await Promise.allSettled(ops) + } + } + + private fireAndForget(op: Promise): void { + const tracked = op.catch(err => logError("async operation error", err)) + this._pendingOps.push(tracked) + tracked.finally(() => { + const idx = this._pendingOps.indexOf(tracked) + if (idx >= 0) this._pendingOps.splice(idx, 1) + }) + } + + // --- Profile-switching helpers --- + + private async withMainProfile(fn: () => Promise): Promise { + return profileMutex.runExclusive(async () => { + await this.chat.apiSetActiveUser(this.mainUserId) + return fn() + }) + } + + // Ensure this group's groupPreferences.commands match desiredCommands, + // so commands in outgoing messages render as clickable for members of + // this group. Scoped to the group (apiUpdateGroupProfile broadcasts + // XGrpInfo/XGrpPrefs to group members only), and cached so we don't + // re-check on every send. Pre-checks local state via apiGetChat so we + // don't issue a no-op broadcast when the group already has the + // commands. + private async syncGroupCommands(groupId: number): Promise { + if (this.syncedGroups.has(groupId)) return + const desiredJSON = JSON.stringify(this.desiredCommands) + const chat = await this.chat.apiGetChat(T.ChatType.Group, groupId, 0) + const info = chat.chatInfo + if (info.type !== "group") return + const gp = info.groupInfo.groupProfile + const currentPrefs = gp.groupPreferences ?? {} + if (JSON.stringify(currentPrefs.commands ?? []) !== desiredJSON) { + await this.chat.apiUpdateGroupProfile(groupId, { + ...gp, + groupPreferences: {...currentPrefs, commands: this.desiredCommands}, + }) + log(`Pushed commands to group ${groupId}`) + } + this.syncedGroups.add(groupId) + } + + private async withGrokProfile(fn: () => Promise): Promise { + if (this.grokUserId === null) throw new Error("Grok is disabled (no GROK_API_KEY)") + const grokUserId = this.grokUserId + return profileMutex.runExclusive(async () => { + await this.chat.apiSetActiveUser(grokUserId) + return fn() + }) + } + + // --- Main profile event handlers --- + + async onBusinessRequest(evt: CEvt.AcceptingBusinessRequest): Promise { + const groupId = evt.groupInfo.groupId + try { + const profile = evt.groupInfo.groupProfile + await this.withMainProfile(() => + this.chat.apiUpdateGroupProfile(groupId, { + displayName: profile.displayName, + fullName: profile.fullName, + groupPreferences: { + ...profile.groupPreferences, + files: {enable: T.GroupFeatureEnabled.On}, + history: {enable: T.GroupFeatureEnabled.On}, + }, + }) + ) + // file uploads + history enabled + } catch (err) { + logError(`Failed to update business group ${groupId} preferences`, err) + } + } + + async onNewChatItems(evt: CEvt.NewChatItems): Promise { + // Only process events for main profile + if (evt.user.userId !== this.mainUserId) return + for (const ci of evt.chatItems) { + try { + await this.processMainChatItem(ci) + } catch (err) { + logError("Error processing chat item", err) + } + } + } + + async onChatItemUpdated(evt: CEvt.ChatItemUpdated): Promise { + if (evt.user.userId !== this.mainUserId) return + const {chatInfo} = evt.chatItem + if (chatInfo.type !== "group") return + const groupInfo = chatInfo.groupInfo + if (!groupInfo.businessChat) return + this.cards.scheduleUpdate(groupInfo.groupId) + } + + async onChatItemReaction(evt: CEvt.ChatItemReaction): Promise { + if (evt.user.userId !== this.mainUserId) return + if (!evt.added) return + const chatInfo = evt.reaction.chatInfo + if (chatInfo.type !== "group") return + const groupInfo = chatInfo.groupInfo + if (!groupInfo.businessChat) return + this.cards.scheduleUpdate(groupInfo.groupId) + } + + async onLeftMember(evt: CEvt.LeftMember): Promise { + if (evt.user.userId !== this.mainUserId) return + const groupId = evt.groupInfo.groupId + const member = evt.member + const bc = evt.groupInfo.businessChat + if (!bc) return + + if (member.memberId === bc.customerId) { + log(`Customer left group ${groupId}`) + this.cleanupGrokMaps(groupId) + try { await this.cards.clearCustomData(groupId) } catch {} + return + } + + if (this.config.grokContactId !== null && member.memberContactId === this.config.grokContactId) { + log(`Grok left group ${groupId}`) + this.cleanupGrokMaps(groupId) + return + } + + if (this.config.teamMembers.some(tm => tm.id === member.memberContactId)) { + log(`Team member left group ${groupId}`) + } + } + + async onJoinedGroupMember(evt: CEvt.JoinedGroupMember): Promise { + if (evt.user.userId !== this.mainUserId) return + if (evt.groupInfo.groupId === this.config.teamGroup.id) { + await this.sendTeamMemberDM(evt.member) + } + } + + async onMemberConnected(evt: CEvt.ConnectedToGroupMember): Promise { + if (evt.user.userId !== this.mainUserId) return + const groupId = evt.groupInfo.groupId + + // Team group → send DM (if not already sent by onJoinedGroupMember) + if (groupId === this.config.teamGroup.id) { + await this.sendTeamMemberDM(evt.member, evt.memberContact) + return + } + + // Customer group → promote to Owner (unless customer or Grok). Idempotent per plan §11. + const bc = evt.groupInfo.businessChat + if (bc) { + const isCustomer = evt.member.memberId === bc.customerId + const isGrok = this.config.grokContactId !== null + && evt.member.memberContactId === this.config.grokContactId + if (!isCustomer && !isGrok) { + try { + await this.withMainProfile(() => + this.chat.apiSetMembersRole(groupId, [evt.member.groupMemberId], T.GroupMemberRole.Owner) + ) + log(`Promoted member ${evt.member.groupMemberId} to Owner in group ${groupId}`) + } catch (err) { + logError(`Failed to promote member in group ${groupId}`, err) + } + } + } + } + + async onMemberContactReceivedInv(evt: CEvt.NewMemberContactReceivedInv): Promise { + if (evt.user.userId !== this.mainUserId) return + const {contact, groupInfo, member} = evt + if (groupInfo.groupId === this.config.teamGroup.id) { + if (this.sentTeamDMs.has(contact.contactId)) return + log(`DM contact from team group member: ${contact.contactId}:${member.memberProfile.displayName}`) + const name = member.memberProfile.displayName + const formatted = name.includes(" ") ? `'${name}'` : name + const msg = `Added you to be able to invite you to customer chats later, keep this contact. Your contact ID is ${contact.contactId}:${formatted}` + // Try sending immediately — contact may already be usable + try { + await this.withMainProfile(() => + this.chat.apiSendTextMessage([T.ChatType.Direct, contact.contactId], msg) + ) + this.sentTeamDMs.add(contact.contactId) + log(`Sent DM to team member ${contact.contactId}:${name}`) + } catch { + // Not ready yet — queue for contactConnected / contactSndReady + this.pendingTeamDMs.set(contact.contactId, msg) + log(`Queued DM for team member ${contact.contactId}:${name}`) + } + } + } + + async onContactConnected(evt: CEvt.ContactConnected): Promise { + if (evt.user.userId !== this.mainUserId) return + await this.deliverPendingDM(evt.contact.contactId) + } + + async onContactSndReady(evt: CEvt.ContactSndReady): Promise { + if (evt.user.userId !== this.mainUserId) return + await this.deliverPendingDM(evt.contact.contactId) + } + + private async deliverPendingDM(contactId: number): Promise { + if (this.sentTeamDMs.has(contactId)) { + this.pendingTeamDMs.delete(contactId) + return + } + const pendingMsg = this.pendingTeamDMs.get(contactId) + if (pendingMsg === undefined) return + this.pendingTeamDMs.delete(contactId) + try { + await this.withMainProfile(() => + this.chat.apiSendTextMessage([T.ChatType.Direct, contactId], pendingMsg) + ) + this.sentTeamDMs.add(contactId) + log(`Sent DM to team member ${contactId}`) + } catch (err) { + logError(`Failed to send DM to team member ${contactId}`, err) + } + } + + // --- Grok profile event handlers --- + + async onGrokGroupInvitation(evt: CEvt.ReceivedGroupInvitation): Promise { + if (evt.user.userId !== this.grokUserId) return + const memberId = evt.groupInfo.membership.memberId + const mainGroupId = this.pendingGrokJoins.get(memberId) + if (mainGroupId === undefined) { + // Buffer: invitation may arrive before pendingGrokJoins is set (race with apiAddMember) + this.bufferedGrokInvitations.set(memberId, evt) + return + } + this.pendingGrokJoins.delete(memberId) + this.bufferedGrokInvitations.delete(memberId) + await this.processGrokInvitation(evt, mainGroupId) + } + + private async processGrokInvitation(evt: CEvt.ReceivedGroupInvitation, mainGroupId: number): Promise { + log(`Grok joining group: mainGroupId=${mainGroupId}, grokGroupId=${evt.groupInfo.groupId}`) + try { + await this.withGrokProfile(() => this.chat.apiJoinGroup(evt.groupInfo.groupId)) + } catch (err) { + logError(`Grok failed to join group ${evt.groupInfo.groupId}`, err) + return + } + this.grokGroupMap.set(mainGroupId, evt.groupInfo.groupId) + this.reverseGrokMap.set(evt.groupInfo.groupId, mainGroupId) + } + + async onGrokMemberConnected(evt: CEvt.ConnectedToGroupMember): Promise { + if (evt.user.userId !== this.grokUserId) return + const grokGroupId = evt.groupInfo.groupId + const mainGroupId = this.reverseGrokMap.get(grokGroupId) + if (mainGroupId === undefined) return + this.grokFullyConnected.add(mainGroupId) + const resolver = this.grokJoinResolvers.get(mainGroupId) + if (resolver) { + this.grokJoinResolvers.delete(mainGroupId) + log(`Grok fully connected: mainGroupId=${mainGroupId}, grokGroupId=${grokGroupId}`) + resolver() + } + } + + async onGrokNewChatItems(evt: CEvt.NewChatItems): Promise { + if (evt.user.userId !== this.grokUserId) return + // When multiple customer messages arrive in one batch, only respond to the + // last per group — earlier messages are included in its history context. + const lastPerGroup = new Map() + for (const ci of evt.chatItems) { + const {chatInfo, chatItem} = ci + if (chatInfo.type !== "group") continue + if (chatItem.chatDir.type !== "groupRcv") continue + if (!util.ciContentText(chatItem)?.trim()) continue + if (util.ciBotCommand(chatItem)) continue + const bc = chatInfo.groupInfo.businessChat + if (!bc) continue + if (chatItem.chatDir.groupMember.memberId !== bc.customerId) continue + lastPerGroup.set(chatInfo.groupInfo.groupId, ci) + } + // Groups are independent — avoid serializing one group's xAI latency across the others. + await Promise.allSettled( + [...lastPerGroup.values()].map((ci) => this.processGrokChatItem(ci)), + ) + } + + // --- Main profile message routing --- + + private async processMainChatItem(ci: T.AChatItem): Promise { + const {chatInfo, chatItem} = ci + + // 1. Direct text message → reply with business address + if (chatInfo.type === "direct" && chatItem.chatDir.type === "directRcv" + && (chatItem.content as any).type === "rcvMsgContent") { + if (this.businessAddress) { + const contactId = chatInfo.contact.contactId + try { + await this.withMainProfile(() => + this.chat.apiSendTextMessage( + [T.ChatType.Direct, contactId], + `Please use my business address to ask questions: ${this.businessAddress}`, + ) + ) + } catch (err) { + logError(`Failed to reply to direct message from contact ${contactId}`, err) + } + } + return + } + + if (chatInfo.type !== "group") return + const groupInfo = chatInfo.groupInfo + const groupId = groupInfo.groupId + + // 2. Team group → handle /join + if (groupId === this.config.teamGroup.id) { + await this.processTeamGroupMessage(chatItem) + return + } + + // 3. Skip non-business groups + if (!groupInfo.businessChat) return + + // 4. Skip own messages + if (chatItem.chatDir.type === "groupSnd") return + if (chatItem.chatDir.type !== "groupRcv") return + + const sender = chatItem.chatDir.groupMember + const bc = groupInfo.businessChat + const isCustomer = sender.memberId === bc.customerId + + // 6. Non-customer message → one-way gate check + card update + if (!isCustomer) { + const isTeam = this.config.teamMembers.some(tm => tm.id === sender.memberContactId) + + if (isTeam && util.ciContentText(chatItem)?.trim()) { + // One-way gate: first team text → transition to TEAM + remove Grok + const data = await this.cards.getRawCustomData(groupId) + if (data?.state !== "TEAM") { + await this.cards.mergeCustomData(groupId, {state: "TEAM"}) + const {grokMember} = await this.cards.getGroupComposition(groupId) + if (grokMember) { + log(`One-way gate: team message in group ${groupId}, removing Grok`) + try { + await this.withMainProfile(() => + this.chat.apiRemoveMembers(groupId, [grokMember.groupMemberId]) + ) + } catch { + // may have already left + } + this.cleanupGrokMaps(groupId) + } + } + } + // Schedule card update for any non-customer message (team or Grok) + this.cards.scheduleUpdate(groupId) + return + } + + // 8. Customer message → derive state and dispatch + const state = await this.cards.deriveState(groupId) + const rawCmd = util.ciBotCommand(chatItem) + // When Grok is disabled, ignore /grok so it behaves like an unknown command + const cmd = rawCmd?.keyword === "grok" && !this.grokEnabled ? null : rawCmd + const text = util.ciContentText(chatItem)?.trim() || null + + switch (state) { + case "WELCOME": + if (cmd?.keyword === "grok") { + // WELCOME → GROK (skip queue msg). Write state optimistically so the + // card renders with GROK icon/label; activateGrok will revert via + // setStateOnFail if activation fails. + // Fire-and-forget: activateGrok awaits future events (waitForGrokJoin) + // which would deadlock the sequential event loop if awaited here. + await this.cards.mergeCustomData(groupId, {state: "GROK"}) + await this.cards.createCard(groupId, groupInfo) + this.fireAndForget(this.activateGrok(groupId, {sendQueueOnFail: true, setStateOnFail: "QUEUE"})) + return + } + if (cmd?.keyword === "team") { + // activateTeam writes state=TEAM-PENDING before the add loop + await this.activateTeam(groupId) + await this.cards.createCard(groupId, groupInfo) + return + } + // First regular message → QUEUE + if (text) { + await this.cards.mergeCustomData(groupId, {state: "QUEUE"}) + await this.sendToGroup(groupId, queueMessage(this.config.timezone, this.grokEnabled)) + await this.cards.createCard(groupId, groupInfo) + } + break + + case "QUEUE": + if (cmd?.keyword === "grok") { + // Write state optimistically; activateGrok reverts to QUEUE on failure + await this.cards.mergeCustomData(groupId, {state: "GROK"}) + this.fireAndForget(this.activateGrok(groupId, {setStateOnFail: "QUEUE"})) + } else if (cmd?.keyword === "team") { + await this.activateTeam(groupId) + } + this.cards.scheduleUpdate(groupId) + break + + case "GROK": + if (cmd?.keyword === "team") { + await this.activateTeam(groupId) + } else if (cmd?.keyword === "grok") { + // Already in grok mode — ignore + } else if (text) { + // Customer text → Grok responds (handled by Grok profile's onGrokNewChatItems) + // Just schedule card update for the customer message + } + this.cards.scheduleUpdate(groupId) + break + + case "TEAM-PENDING": + if (cmd?.keyword === "grok") { + // Invite Grok if not present; state stays TEAM-PENDING + const {grokMember} = await this.cards.getGroupComposition(groupId) + if (!grokMember) { + this.fireAndForget(this.activateGrok(groupId)) + } + // else: already present, ignore + } else if (cmd?.keyword === "team") { + // activateTeam handles "already invited" reply (team still present) + // or silent re-add (team has all left) + await this.activateTeam(groupId) + } + this.cards.scheduleUpdate(groupId) + break + + case "TEAM": + if (cmd?.keyword === "grok") { + await this.sendToGroup(groupId, teamLockedMessage) + } else if (cmd?.keyword === "team") { + // Team still present → "already invited"; team all left → silent re-add + await this.activateTeam(groupId) + } + this.cards.scheduleUpdate(groupId) + break + } + } + + // --- Grok profile message processing --- + + private async processGrokChatItem(ci: T.AChatItem): Promise { + if (!this.grokApi) return + const grokApi = this.grokApi + const {chatInfo, chatItem} = ci + if (chatInfo.type !== "group") return + const groupInfo = chatInfo.groupInfo + const grokGroupId = groupInfo.groupId + + // Skip while activateGrok is sending the initial combined response + const mainGroupId = this.reverseGrokMap.get(grokGroupId) + if (mainGroupId !== undefined && this.grokInitialResponsePending.has(mainGroupId)) return + + // Only process received text messages from customer + if (chatItem.chatDir.type !== "groupRcv") return + const text = util.ciContentText(chatItem)?.trim() + if (!text) return // ignore non-text + + // Ignore bot commands + if (util.ciBotCommand(chatItem)) return + + // Only respond in business groups (survives restart without in-memory maps) + const bc = groupInfo.businessChat + if (!bc) return + + // Only respond to customer messages, not bot or team messages + if (chatItem.chatDir.groupMember.memberId !== bc.customerId) return + + // Read history from Grok's own view + try { + const chat = await this.withGrokProfile(() => + this.chat.apiGetChat(T.ChatType.Group, grokGroupId, 100) + ) + const history: GrokMessage[] = [] + for (const histCi of chat.chatItems) { + const histText = util.ciContentText(histCi)?.trim() + if (!histText) continue + if (histCi.chatDir.type === "groupSnd") { + history.push({role: "assistant", content: histText}) + } else if (histCi.chatDir.type === "groupRcv" + && histCi.chatDir.groupMember.memberId === bc.customerId + && !util.ciBotCommand(histCi)) { + history.push({role: "user", content: histText}) + } + } + + // Don't include the current message in history — it's the userMessage + if (history.length > 0 && history[history.length - 1].role === "user" + && history[history.length - 1].content === text) { + history.pop() + } + + // Call Grok API (outside mutex) + const response = await grokApi.chat(history, text) + + // Send response via Grok profile + await this.withGrokProfile(() => + this.chat.apiSendTextMessage([T.ChatType.Group, grokGroupId], response) + ) + } catch (err) { + logError(`Grok per-message error for grokGroup ${grokGroupId}`, err) + try { + await this.withGrokProfile(() => + this.chat.apiSendTextMessage([T.ChatType.Group, grokGroupId], grokErrorMessage) + ) + } catch {} + } + + // Card update scheduled by main profile seeing the groupRcv events + } + + // --- Grok activation --- + + private async activateGrok( + groupId: number, + opts: {sendQueueOnFail?: boolean; setStateOnFail?: ConversationState} = {}, + ): Promise { + if (!this.grokApi) return + const grokApi = this.grokApi + const revertStateOnFail = async () => { + if (!opts.setStateOnFail) return + const current = await this.cards.getRawCustomData(groupId) + if (current?.state !== "GROK") return + await this.cards.mergeCustomData(groupId, {state: opts.setStateOnFail}) + } + if (this.config.grokContactId === null) { + await revertStateOnFail() + await this.sendToGroup(groupId, grokUnavailableMessage) + if (opts.sendQueueOnFail) await this.sendToGroup(groupId, queueMessage(this.config.timezone, this.grokEnabled)) + this.cards.scheduleUpdate(groupId) + return + } + + // Pre-check: silent return if Grok is already in the group in any + // non-terminal status. The apiAddMember/groupDuplicateMember catch below + // handles Connected/etc. but the SimpleX API resends the invitation for + // GSMemInvited (no error thrown), so without this check a /grok issued + // while a previous activation is still pending would re-trigger the invite. + const grokMembers = await this.withMainProfile(() => this.chat.apiListMembers(groupId)) + if (grokMembers.some(m => m.memberContactId === this.config.grokContactId && isInGroup(m))) { + return + } + + // Gate MUST be up before apiAddMember / pendingGrokJoins / reverseGrokMap — + // any later and onGrokNewChatItems can fire a duplicate per-message reply. + this.grokInitialResponsePending.add(groupId) + try { + await this.sendToGroup(groupId, grokInvitingMessage) + + let member: T.GroupMember + try { + member = await this.withMainProfile(() => + this.chat.apiAddMember(groupId, this.config.grokContactId!, T.GroupMemberRole.Member) + ) + } catch (err: unknown) { + const chatErr = err as {chatError?: {errorType?: {type?: string}}} + if (chatErr?.chatError?.errorType?.type === "groupDuplicateMember") { + // Grok already in group (e.g. customer sent /grok again before join completed) — + // the in-flight activation will handle the outcome, just return silently + return + } + logError(`Failed to invite Grok to group ${groupId}`, err) + await revertStateOnFail() + await this.sendToGroup(groupId, grokUnavailableMessage) + if (opts.sendQueueOnFail) await this.sendToGroup(groupId, queueMessage(this.config.timezone, this.grokEnabled)) + this.cards.scheduleUpdate(groupId) + return + } + + this.pendingGrokJoins.set(member.memberId, groupId) + + // Drain buffered invitation that arrived during the apiAddMember await + const buffered = this.bufferedGrokInvitations.get(member.memberId) + if (buffered) { + this.bufferedGrokInvitations.delete(member.memberId) + this.pendingGrokJoins.delete(member.memberId) + await this.processGrokInvitation(buffered, groupId) + } + + const joined = await this.waitForGrokJoin(groupId, 120_000) + if (!joined) { + this.pendingGrokJoins.delete(member.memberId) + try { + await this.withMainProfile(() => + this.chat.apiRemoveMembers(groupId, [member.groupMemberId]) + ) + } catch {} + this.cleanupGrokMaps(groupId) + await revertStateOnFail() + await this.sendToGroup(groupId, grokUnavailableMessage) + if (opts.sendQueueOnFail) await this.sendToGroup(groupId, queueMessage(this.config.timezone, this.grokEnabled)) + this.cards.scheduleUpdate(groupId) + return + } + + await this.sendToGroup(groupId, grokActivatedMessage) + + // Grok joined — send initial response based on customer's accumulated messages + try { + const grokLocalGId = this.grokGroupMap.get(groupId) + if (grokLocalGId === undefined) { + await this.sendToGroup(groupId, grokUnavailableMessage) + return + } + + // Read history from Grok's own view — only customer messages. + // The previous `grokBc && ...` short-circuit let bot and team + // messages through when Grok's view had no businessChat; require + // grokBc.customerId to be present and match strictly. + const chat = await this.withGrokProfile(() => + this.chat.apiGetChat(T.ChatType.Group, grokLocalGId, 100) + ) + const grokBc = chat.chatInfo.type === "group" ? chat.chatInfo.groupInfo.businessChat : null + const customerMessages: string[] = [] + for (const ci of chat.chatItems) { + if (ci.chatDir.type !== "groupRcv") continue + if (!grokBc || ci.chatDir.groupMember.memberId !== grokBc.customerId) continue + const t = util.ciContentText(ci)?.trim() + if (t && !util.ciBotCommand(ci)) customerMessages.push(t) + } + + if (customerMessages.length === 0) { + await this.withGrokProfile(() => + this.chat.apiSendTextMessage([T.ChatType.Group, grokLocalGId], grokNoHistoryMessage) + ) + return + } + + const initialMsg = customerMessages.join("\n") + const response = await grokApi.chat([], initialMsg) + + await this.withGrokProfile(() => + this.chat.apiSendTextMessage([T.ChatType.Group, grokLocalGId], response) + ) + } catch (err) { + logError(`Grok initial response failed for group ${groupId}`, err) + await this.sendToGroup(groupId, grokUnavailableMessage) + } + } finally { + this.grokInitialResponsePending.delete(groupId) + } + } + + // --- Team activation --- + + private async activateTeam(groupId: number): Promise { + if (this.config.teamMembers.length === 0) { + await this.sendToGroup(groupId, noTeamMembersMessage(this.grokEnabled)) + return + } + + const data = await this.cards.getRawCustomData(groupId) + const alreadyActivated = data?.state === "TEAM-PENDING" || data?.state === "TEAM" + if (alreadyActivated) { + const {teamMembers} = await this.cards.getGroupComposition(groupId) + if (teamMembers.length > 0) { + await this.sendToGroup(groupId, teamAlreadyInvitedMessage) + return + } + // Team previously activated but all team members have since left — + // re-add silently (no teamAddedMessage). State stays TEAM-PENDING/TEAM. + for (const tm of this.config.teamMembers) { + try { + await this.addOrFindTeamMember(groupId, tm.id) + } catch (err) { + logError(`Failed to add team member ${tm.id} to group ${groupId}`, err) + } + } + return + } + + // First activation — write state BEFORE add loop so concurrent customer + // events observing mid-flight see TEAM-PENDING rather than stale state. + await this.cards.mergeCustomData(groupId, {state: "TEAM-PENDING"}) + + for (const tm of this.config.teamMembers) { + try { + await this.addOrFindTeamMember(groupId, tm.id) + } catch (err) { + logError(`Failed to add team member ${tm.id} to group ${groupId}`, err) + } + } + + const {grokMember} = await this.cards.getGroupComposition(groupId) + await this.sendToGroup(groupId, teamAddedMessage(this.config.timezone, !!grokMember)) + } + + // --- Team group commands --- + + private async processTeamGroupMessage(chatItem: T.ChatItem): Promise { + if (chatItem.chatDir.type !== "groupRcv") return + const senderContactId = chatItem.chatDir.groupMember.memberContactId + if (!senderContactId) return + + const cmd = util.ciBotCommand(chatItem) + if (cmd?.keyword !== "join") return + + const targetGroupId = Number.parseInt(cmd.params, 10) + if (Number.isNaN(targetGroupId) || targetGroupId <= 0) { + await this.sendToGroup(this.config.teamGroup.id, `Error: invalid group id "${cmd.params}"`) + return + } + await this.handleJoinCommand(targetGroupId, senderContactId) + } + + private async handleJoinCommand(targetGroupId: number, senderContactId: number): Promise { + // Validate target is a business group + const groups = await this.withMainProfile(() => + this.chat.apiListGroups(this.mainUserId) + ) + const targetGroup = groups.find(g => g.groupId === targetGroupId) + if (!targetGroup?.businessChat) { + await this.sendToGroup(this.config.teamGroup.id, `Error: group ${targetGroupId} is not a business chat`) + return + } + + try { + const member = await this.addOrFindTeamMember(targetGroupId, senderContactId) + if (member) { + log(`Team member ${senderContactId} joined group ${targetGroupId} via /join`) + } + } catch (err) { + logError(`/join failed for group ${targetGroupId}`, err) + await this.sendToGroup(this.config.teamGroup.id, `Error joining group ${targetGroupId}`) + } + } + + // --- Helpers --- + + private async addOrFindTeamMember(groupId: number, teamContactId: number): Promise { + // Pre-check membership: skip apiAddMember entirely if the contact is in + // the group in any non-terminal status. The SimpleX API resends the + // invitation for a member in GSMemInvited, so calling apiAddMember on a + // pending invitee would re-trigger an invite notification. + const members = await this.withMainProfile(() => this.chat.apiListMembers(groupId)) + const existing = members.find(m => m.memberContactId === teamContactId && isInGroup(m)) + if (existing) return existing + const member = await this.withMainProfile(() => + this.chat.apiAddMember(groupId, teamContactId, T.GroupMemberRole.Member) + ) + try { + await this.withMainProfile(() => + this.chat.apiSetMembersRole(groupId, [member.groupMemberId], T.GroupMemberRole.Owner) + ) + } catch { + // Not yet connected — will be promoted in onMemberConnected + } + return member + } + + async sendToGroup(groupId: number, text: string): Promise { + try { + await this.withMainProfile(async () => { + await this.syncGroupCommands(groupId) + await this.chat.apiSendTextMessage([T.ChatType.Group, groupId], text) + }) + } catch (err) { + logError(`Failed to send message to group ${groupId}`, err) + } + } + + private waitForGrokJoin(groupId: number, timeout: number): Promise { + if (this.grokFullyConnected.has(groupId)) return Promise.resolve(true) + return new Promise((resolve) => { + const timer = setTimeout(() => { + this.grokJoinResolvers.delete(groupId) + resolve(false) + }, timeout) + this.grokJoinResolvers.set(groupId, () => { + clearTimeout(timer) + resolve(true) + }) + }) + } + + private async sendTeamMemberDM(member: T.GroupMember, memberContact?: T.Contact): Promise { + const name = member.memberProfile.displayName + const formatted = name.includes(" ") ? `'${name}'` : name + + let contactId = memberContact?.contactId ?? member.memberContactId + if (!contactId) { + // No DM contact yet — create one and send invitation with message + try { + const contact = await this.withMainProfile(() => + this.chat.apiCreateMemberContact(this.config.teamGroup.id, member.groupMemberId) + ) + contactId = contact.contactId as number + log(`Created DM contact ${contactId} for team member ${name}`) + } catch (err) { + logError(`Failed to create member contact for ${name}`, err) + return + } + if (this.sentTeamDMs.has(contactId)) return + const msg = `Added you to be able to invite you to customer chats later, keep this contact. Your contact ID is ${contactId}:${formatted}` + try { + await this.withMainProfile(() => + this.chat.apiSendMemberContactInvitation(contactId!, msg) + ) + this.sentTeamDMs.add(contactId) + this.pendingTeamDMs.delete(contactId) + log(`Sent DM invitation to team member ${contactId}:${name}`) + } catch { + this.pendingTeamDMs.set(contactId, msg) + } + return + } + // Contact already exists — send via normal DM + if (this.sentTeamDMs.has(contactId)) return + const msg = `Added you to be able to invite you to customer chats later, keep this contact. Your contact ID is ${contactId}:${formatted}` + try { + await this.withMainProfile(() => + this.chat.apiSendTextMessage([T.ChatType.Direct, contactId], msg) + ) + this.sentTeamDMs.add(contactId) + this.pendingTeamDMs.delete(contactId) + log(`Sent DM to team member ${contactId}:${name}`) + } catch { + this.pendingTeamDMs.set(contactId, msg) + } + } + + private cleanupGrokMaps(groupId: number): void { + const grokLocalGId = this.grokGroupMap.get(groupId) + this.grokFullyConnected.delete(groupId) + this.grokInitialResponsePending.delete(groupId) + if (grokLocalGId === undefined) return + this.grokGroupMap.delete(groupId) + this.reverseGrokMap.delete(grokLocalGId) + } +} diff --git a/apps/simplex-support-bot/src/cards.ts b/apps/simplex-support-bot/src/cards.ts new file mode 100644 index 0000000000..3d27c036e9 --- /dev/null +++ b/apps/simplex-support-bot/src/cards.ts @@ -0,0 +1,473 @@ +import {T} from "@simplex-chat/types" +import {api, util} from "simplex-chat" +import {Mutex} from "async-mutex" +import {Config} from "./config.js" +import {profileMutex, log, logError} from "./util.js" + +// State derivation types +export type ConversationState = "WELCOME" | "QUEUE" | "GROK" | "TEAM-PENDING" | "TEAM" + +function isConversationState(x: unknown): x is ConversationState { + return x === "WELCOME" || x === "QUEUE" || x === "GROK" || x === "TEAM-PENDING" || x === "TEAM" +} + +export interface GroupComposition { + grokMember: T.GroupMember | undefined + teamMembers: T.GroupMember[] +} + +interface CardData { + state?: ConversationState + cardItemId?: number + complete?: boolean +} + +function isActiveMember(m: T.GroupMember): boolean { + return m.memberStatus === T.GroupMemberStatus.Connected + || m.memberStatus === T.GroupMemberStatus.Complete + || m.memberStatus === T.GroupMemberStatus.Announced +} + +// Prevent ! from triggering SimpleX markdown styled text (color/small). +// The parser treats !N as color markup (N: 1-6, r, g, b, y, c, m, -) +// and closes at the next !. No escape mechanism exists in the parser, +// so we insert a zero-width space to break the trigger pattern. +function escapeStyledMarkdown(text: string): string { + return text.replace(/!([1-6rgbycm-])/g, "!\u200B$1") +} + +// Truncate a single message to ~maxChars, appending [truncated] if needed +function truncateMsg(text: string, maxChars: number): string { + if (text.length <= maxChars) return text + return text.slice(0, maxChars) + "… [truncated]" +} + +// Describe non-text content types +function contentTypeLabel(ci: T.ChatItem): string | null { + const content = ci.content as T.CIContent + if (content.type !== "rcvMsgContent" && content.type !== "sndMsgContent") return null + const mc = content.msgContent + switch (mc.type) { + case "image": return "[image]" + case "video": return "[video]" + case "voice": return "[voice]" + case "file": return "[file]" + default: return null + } +} + +export class CardManager { + private pendingUpdates = new Set() + private flushInterval: NodeJS.Timeout + // Outer lock; profileMutex (via withMainProfile) is the inner lock. + private customDataMutexes = new Map() + + constructor( + private chat: api.ChatApi, + private config: Config, + private mainUserId: number, + flushIntervalMs = 300 * 1000, + ) { + this.flushInterval = setInterval(() => this.flush(), flushIntervalMs) + this.flushInterval.unref() + } + + private async withMainProfile(fn: () => Promise): Promise { + return profileMutex.runExclusive(async () => { + await this.chat.apiSetActiveUser(this.mainUserId) + return fn() + }) + } + + private getCustomDataMutex(groupId: number): Mutex { + let m = this.customDataMutexes.get(groupId) + if (!m) { + m = new Mutex() + this.customDataMutexes.set(groupId, m) + } + return m + } + + scheduleUpdate(groupId: number): void { + this.pendingUpdates.add(groupId) + } + + async createCard(groupId: number, groupInfo: T.GroupInfo): Promise { + const {text} = await this.composeCard(groupId, groupInfo) + const chatRef: T.ChatRef = {chatType: T.ChatType.Group, chatId: this.config.teamGroup.id} + const items = await this.withMainProfile(() => + this.chat.apiSendMessages(chatRef, [ + {msgContent: {type: "text", text}, mentions: {}}, + ]) + ) + await this.mergeCustomData(groupId, {cardItemId: items[0].chatItem.meta.itemId}) + } + + async flush(): Promise { + const groups = [...this.pendingUpdates] + this.pendingUpdates.clear() + for (const groupId of groups) { + try { + await this.flushOne(groupId) + } catch (err) { + logError(`Card flush failed for group ${groupId}`, err) + } + } + } + + // Dispatches to create-path when cardItemId is absent so a failed createCard retries. + private async flushOne(groupId: number): Promise { + const groups = await this.withMainProfile(() => this.chat.apiListGroups(this.mainUserId)) + const groupInfo = groups.find(g => g.groupId === groupId) + if (!groupInfo) return + const data = groupInfo.customData as Record | undefined + if (typeof data?.cardItemId === "number") { + await this.updateCard(groupId) + } else { + await this.createCard(groupId, groupInfo) + } + } + + async refreshAllCards(): Promise { + const groups = await this.withMainProfile(() => this.chat.apiListGroups(this.mainUserId)) + const activeCards: {groupId: number; cardItemId: number}[] = [] + for (const group of groups) { + const customData = group.customData as Record | undefined + if (customData && typeof customData.cardItemId === "number" && !customData.complete) { + activeCards.push({groupId: group.groupId, cardItemId: customData.cardItemId}) + } + } + if (activeCards.length === 0) return + + // Sort ascending by cardItemId — higher ID = more recently updated card. + // Oldest-updated cards refresh first; newest-updated refresh last, + // so the most recent cards end up at the bottom of the team group. + activeCards.sort((a, b) => a.cardItemId - b.cardItemId) + + log(`Startup: refreshing ${activeCards.length} card(s)`) + + for (const {groupId} of activeCards) { + try { + await this.updateCard(groupId) + } catch (err) { + logError(`Startup card refresh failed for group ${groupId}`, err) + } + } + } + + destroy(): void { + clearInterval(this.flushInterval) + } + + // --- State derivation --- + + async getGroupComposition(groupId: number): Promise { + const members = await this.withMainProfile(() => this.chat.apiListMembers(groupId)) + return { + grokMember: members.find(m => + this.config.grokContactId !== null + && m.memberContactId === this.config.grokContactId + && isActiveMember(m)), + teamMembers: members.filter(m => + this.config.teamMembers.some(tm => tm.id === m.memberContactId) + && isActiveMember(m)), + } + } + + async deriveState(groupId: number): Promise { + const data = await this.getRawCustomData(groupId) + return data?.state ?? "WELCOME" + } + + async getLastCustomerMessageTime(groupId: number, customerId: string): Promise { + const chat = await this.getChat(groupId, 20) + for (let i = chat.chatItems.length - 1; i >= 0; i--) { + const ci = chat.chatItems[i] + if (ci.chatDir.type === "groupRcv" && ci.chatDir.groupMember.memberId === customerId) { + return new Date(ci.meta.createdAt).getTime() + } + } + return undefined + } + + async getLastTeamOrGrokMessageTime(groupId: number): Promise { + const chat = await this.getChat(groupId, 20) + for (let i = chat.chatItems.length - 1; i >= 0; i--) { + const ci = chat.chatItems[i] + if (ci.chatDir.type === "groupRcv") { + const contactId = ci.chatDir.groupMember.memberContactId + const isTeam = this.config.teamMembers.some(tm => tm.id === contactId) + const isGrok = this.config.grokContactId !== null && contactId === this.config.grokContactId + if (isTeam || isGrok) return new Date(ci.meta.createdAt).getTime() + } + if (ci.chatDir.type === "groupSnd") { + // Bot's own messages don't count + } + } + return undefined + } + + // --- Custom data --- + + async getRawCustomData(groupId: number): Promise | null> { + const groups = await this.withMainProfile(() => this.chat.apiListGroups(this.mainUserId)) + const group = groups.find(g => g.groupId === groupId) + if (!group?.customData) return null + const data = group.customData as Record + const result: Partial = {} + if (isConversationState(data.state)) result.state = data.state + if (typeof data.cardItemId === "number") result.cardItemId = data.cardItemId + if (data.complete === true) result.complete = true + return result + } + + async mergeCustomData(groupId: number, patch: Partial): Promise { + return this.getCustomDataMutex(groupId).runExclusive(async () => { + const current = (await this.getRawCustomData(groupId)) ?? {} + const merged: Partial = {...current, ...patch} + for (const key of Object.keys(merged) as (keyof CardData)[]) { + if (merged[key] === undefined) delete merged[key] + } + await this.withMainProfile(() => this.chat.apiSetGroupCustomData(groupId, merged)) + }) + } + + async clearCustomData(groupId: number): Promise { + return this.getCustomDataMutex(groupId).runExclusive(() => + this.withMainProfile(() => this.chat.apiSetGroupCustomData(groupId)) + ) + } + + // --- Chat history access --- + + async getChat(groupId: number, count: number): Promise { + return this.withMainProfile(() => this.chat.apiGetChat(T.ChatType.Group, groupId, count)) + } + + // --- Internal --- + + private async updateCard(groupId: number): Promise { + // Read customData and groupInfo in one apiListGroups call + const groups = await this.withMainProfile(() => this.chat.apiListGroups(this.mainUserId)) + const groupInfo = groups.find(g => g.groupId === groupId) + if (!groupInfo) return + + const customData = groupInfo.customData as Record | undefined + const cardItemId = customData?.cardItemId + if (typeof cardItemId !== "number") return + + try { + await this.withMainProfile(() => + this.chat.apiDeleteChatItems( + T.ChatType.Group, this.config.teamGroup.id, [cardItemId], T.CIDeleteMode.Broadcast + ) + ) + } catch { + // card may already be deleted + } + + const {text, complete} = await this.composeCard(groupId, groupInfo) + const chatRef: T.ChatRef = {chatType: T.ChatType.Group, chatId: this.config.teamGroup.id} + const items = await this.withMainProfile(() => + this.chat.apiSendMessages(chatRef, [ + {msgContent: {type: "text", text}, mentions: {}}, + ]) + ) + const patch: Partial = { + cardItemId: items[0].chatItem.meta.itemId, + complete: complete ? true : undefined, + } + await this.mergeCustomData(groupId, patch) + } + + private async composeCard(groupId: number, groupInfo: T.GroupInfo): Promise<{text: string, complete: boolean}> { + const rawName = groupInfo.groupProfile.displayName || `group-${groupId}` + const customerName = rawName.replace(/\n+/g, " ") + const bc = groupInfo.businessChat + const customerId = bc?.customerId + + const state = await this.deriveState(groupId) + const {teamMembers} = await this.getGroupComposition(groupId) + + const icon = await this.computeIcon(groupId, state, customerId ?? undefined) + const waitStr = await this.computeWaitTime(groupId, state, customerId ?? undefined) + + const chat = await this.getChat(groupId, 100) + const msgCount = chat.chatItems.filter((ci: T.ChatItem) => ci.chatDir.type !== "groupSnd").length + + const stateLabel = this.stateLabel(state) + + const agentNames = teamMembers.map(m => m.memberProfile.displayName) + const agentStr = agentNames.length > 0 ? ` · ${agentNames.join(", ")}` : "" + + const preview = this.buildPreview(chat.chatItems, customerName, customerId) + + // Final line uses /'join ' quoting so SimpleX clients render the full + // command (including the argument) as a single clickable token. + const joinCmd = `/'join ${groupId}'` + + const line1 = `${icon} *${customerName}* · ${waitStr} · ${msgCount} msgs` + const line2 = `${stateLabel}${agentStr}` + return {text: `${line1}\n${line2}\n${preview}\n${joinCmd}`, complete: icon === "✅"} + } + + private async computeIcon( + groupId: number, state: ConversationState, customerId?: string, + ): Promise { + const now = Date.now() + const completeMs = this.config.completeHours * 3600_000 + + // Check auto-complete: last team/Grok message time vs customer silence + const lastTeamGrokTime = await this.getLastTeamOrGrokMessageTime(groupId) + if (lastTeamGrokTime) { + const lastCustTime = customerId + ? await this.getLastCustomerMessageTime(groupId, customerId) + : undefined + // Auto-complete if team/grok replied and customer hasn't responded since, for completeHours + if (!lastCustTime || lastCustTime < lastTeamGrokTime) { + if (now - lastTeamGrokTime >= completeMs) return "✅" + } + } + + switch (state) { + case "QUEUE": { + const lastCustTime = customerId + ? await this.getLastCustomerMessageTime(groupId, customerId) + : undefined + if (!lastCustTime) return "🟡" + const waitMs = now - lastCustTime + if (waitMs < 5 * 60_000) return "🆕" + if (waitMs < 2 * 3600_000) return "🟡" + return "🔴" + } + case "GROK": + return "🤖" + case "TEAM-PENDING": + return "👋" + case "TEAM": { + // Check if customer follow-up unanswered > 2h + const lastCustTime = customerId + ? await this.getLastCustomerMessageTime(groupId, customerId) + : undefined + if (lastCustTime && lastTeamGrokTime && lastCustTime > lastTeamGrokTime) { + return (now - lastCustTime > 2 * 3600_000) ? "⏰" : "💬" + } + return "💬" + } + default: + return "🟡" + } + } + + private async computeWaitTime( + groupId: number, _state: ConversationState, customerId?: string, + ): Promise { + const now = Date.now() + const completeMs = this.config.completeHours * 3600_000 + + const lastTeamGrokTime = await this.getLastTeamOrGrokMessageTime(groupId) + if (lastTeamGrokTime) { + const lastCustTime = customerId + ? await this.getLastCustomerMessageTime(groupId, customerId) + : undefined + if (!lastCustTime || lastCustTime < lastTeamGrokTime) { + if (now - lastTeamGrokTime >= completeMs) return "done" + } + } + + const lastCustTime = customerId + ? await this.getLastCustomerMessageTime(groupId, customerId) + : undefined + if (!lastCustTime) return "<1m" + return this.formatDuration(now - lastCustTime) + } + + private stateLabel(state: ConversationState): string { + switch (state) { + case "QUEUE": return "Queue" + case "GROK": return "Grok" + case "TEAM-PENDING": return "Team – pending" + case "TEAM": return "Team" + default: return "Queue" + } + } + + private buildPreview(chatItems: T.ChatItem[], customerName: string, customerId?: string): string { + const maxTotal = 500 + const maxPer = 200 + + // Collect entries in chronological order (oldest first) + const entries: {senderId: string; name: string; text: string}[] = [] + for (const ci of chatItems) { + if (ci.chatDir.type === "groupSnd") continue + + let text = (util.ciContentText(ci)?.trim() || "").replace(/\n+/g, " ") + const mediaLabel = contentTypeLabel(ci) + if (mediaLabel && !text) text = mediaLabel + else if (mediaLabel) text = `${mediaLabel} ${text}` + if (!text) continue + + let senderId = "" + let name = "" + if (ci.chatDir.type === "groupRcv") { + const member = ci.chatDir.groupMember + const contactId = member.memberContactId + senderId = member.memberId + if (this.config.grokContactId !== null && contactId === this.config.grokContactId) { + name = "Grok" + } else if (customerId && member.memberId === customerId) { + name = customerName + } else { + name = member.memberProfile.displayName + } + } + + entries.push({senderId, name, text: truncateMsg(text, maxPer)}) + } + + // Compute prefixed lines in chronological order (sender prefix on first msg of each run) + const lines: {line: string; senderId: string; name: string}[] = [] + let lastSenderId = "" + for (const entry of entries) { + let line = entry.text + if (entry.senderId !== lastSenderId && entry.name) { + line = `${entry.name}: ${line}` + lastSenderId = entry.senderId + } + lines.push({line, senderId: entry.senderId, name: entry.name}) + } + + // Take from the end (newest) until maxTotal exceeded — oldest messages are truncated + const selected: string[] = [] + let totalLen = 0 + let firstSelectedIdx = lines.length + for (let i = lines.length - 1; i >= 0; i--) { + if (totalLen + lines[i].line.length > maxTotal && selected.length > 0) { + break + } + selected.push(lines[i].line) + totalLen += lines[i].line.length + firstSelectedIdx = i + } + selected.reverse() + + // If truncation happened, ensure the first visible message has a sender prefix + if (firstSelectedIdx > 0 && selected.length > 0) { + const first = lines[firstSelectedIdx] + if (first.name && !selected[0].startsWith(`${first.name}: `)) { + selected[0] = `${first.name}: ${selected[0]}` + } + selected.unshift("[truncated]") + } + + const preview = selected.map(escapeStyledMarkdown).join(" !3 /! ") + return preview ? `"${preview}"` : '""' + } + + private formatDuration(ms: number): string { + if (ms < 60_000) return "<1m" + if (ms < 3_600_000) return `${Math.floor(ms / 60_000)}m` + if (ms < 86_400_000) return `${Math.floor(ms / 3_600_000)}h` + return `${Math.floor(ms / 86_400_000)}d` + } +} diff --git a/apps/simplex-support-bot/src/config.ts b/apps/simplex-support-bot/src/config.ts new file mode 100644 index 0000000000..8fbd006aef --- /dev/null +++ b/apps/simplex-support-bot/src/config.ts @@ -0,0 +1,144 @@ +import {Command} from "commander" +import {api} from "simplex-chat" + +export interface IdName { + id: number + name: string +} + +export type Backend = "sqlite" | "postgres" + +export interface Config { + 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 + timezone: string + completeHours: number + cardFlushSeconds: number + contextFile: string | null + 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}"`) + const id = parseInt(s.slice(0, i), 10) + if (isNaN(id)) throw new Error(`Invalid ID:name format (non-numeric ID): "${s}"`) + return {id, name: s.slice(i + 1)} +} + +function parseNonNegativeInt(flag: string) { + return (raw: string): number => { + const n = parseInt(raw, 10) + if (!Number.isFinite(n) || n < 0) { + throw new Error(`${flag} must be a non-negative integer, got "${raw}"`) + } + return n + } +} + +function buildCommand(): Command { + return new Command() + .name("simplex-chat-support-bot") + .description("business-address triage bot") + .requiredOption("--team-group ", "team group display name") + .option("--state-file ", "state JSON path", "./data/state.json") + .option("--sqlite-file-prefix ", "SQLite DB file prefix", "./data/simplex") + .option("--sqlite-key ", "SQLCipher encryption key (default: unencrypted)") + .option("--pg-conn ", "PostgreSQL connection string (required for postgres)") + .option("--pg-schema ", "PostgreSQL schema prefix (default: simplex_v1)") + .option("-a, --auto-add-team-members ", "comma-separated ID:name pairs (e.g. 1:Alice,2:Bob)") + .option("--timezone ", "IANA timezone for weekend detection", "UTC") + .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)") + .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") +} + +interface RawOpts { + teamGroup: string + stateFile: string + sqliteFilePrefix: string + sqliteKey?: string + pgConn?: string + pgSchema?: string + autoAddTeamMembers?: string + timezone: string + completeHours: number + cardFlushSeconds: number + contextFile?: string +} + +export function parseConfig(args: string[]): Config { + const cmd = buildCommand().exitOverride() + try { + cmd.parse(args, {from: "user"}) + } catch (err) { + const code = (err as {code?: string}).code + if (code === "commander.helpDisplayed" || code === "commander.version") process.exit(0) + throw err + } + const opts = cmd.opts() + + const grokApiKey = process.env.GROK_API_KEY || null + + const backend = detectBackend() + let db: api.DbConfig + if (backend === "sqlite") { + db = opts.sqliteKey + ? {type: "sqlite", filePrefix: opts.sqliteFilePrefix, encryptionKey: opts.sqliteKey} + : {type: "sqlite", filePrefix: opts.sqliteFilePrefix} + } else { + if (!opts.pgConn) { + throw new Error("--pg-conn is required when backend is postgres (PostgreSQL connection string)") + } + db = opts.pgSchema + ? {type: "postgres", connectionString: opts.pgConn, schemaPrefix: opts.pgSchema} + : {type: "postgres", connectionString: opts.pgConn} + } + + const teamGroup: IdName = {id: 0, name: opts.teamGroup} + + const teamMembersRaw = opts.autoAddTeamMembers ?? "" + const teamMembers = teamMembersRaw + ? teamMembersRaw.split(",").map(parseIdName) + : [] + + try { + new Intl.DateTimeFormat("en-US", {timeZone: opts.timezone, weekday: "short"}) + } catch (err) { + throw new Error(`--timezone "${opts.timezone}" is not a valid IANA time zone: ${(err as Error).message}`) + } + + const contextFile = opts.contextFile ?? null + if (grokApiKey && !contextFile) { + throw new Error("GROK_API_KEY is set but --context-file is not provided. Grok requires a context file.") + } + + return { + stateFile: opts.stateFile, + db, + teamGroup, + teamMembers, + grokContactId: null, + timezone: opts.timezone, + completeHours: opts.completeHours, + cardFlushSeconds: opts.cardFlushSeconds, + contextFile, + grokApiKey, + } +} diff --git a/apps/simplex-support-bot/src/grok.ts b/apps/simplex-support-bot/src/grok.ts new file mode 100644 index 0000000000..b03108439a --- /dev/null +++ b/apps/simplex-support-bot/src/grok.ts @@ -0,0 +1,55 @@ +import {log, logError} from "./util.js" + +export interface GrokMessage { + role: "system" | "user" | "assistant" + content: string +} + +export class GrokApiClient { + private readonly apiKey: string + private readonly systemPrompt: string + + constructor(apiKey: string, systemPrompt: string) { + this.apiKey = apiKey + this.systemPrompt = systemPrompt + } + + async chatRaw(messages: GrokMessage[]): Promise { + const response = await fetch("https://api.x.ai/v1/chat/completions", { + method: "POST", + headers: { + "Content-Type": "application/json", + "Authorization": `Bearer ${this.apiKey}`, + }, + body: JSON.stringify({ + model: "grok-3-mini", + messages, + temperature: 0.3, + max_tokens: 1024, + }), + signal: AbortSignal.timeout(60_000), + }) + + if (!response.ok) { + const body = await response.text() + logError(`Grok API HTTP ${response.status}`, body) + throw new Error(`Grok API error: HTTP ${response.status}`) + } + + const data = await response.json() as {choices: {message: {content: string}}[]} + const content = data.choices?.[0]?.message?.content + if (!content) throw new Error("Grok API returned empty response") + + log(`Grok API response: ${content.length} chars`) + return content + } + + async chat(history: GrokMessage[], userMessage: string): Promise { + log(`Grok API call: ${history.length} history msgs, user msg ${userMessage.length} chars`) + return this.chatRaw([ + {role: "system", content: this.systemPrompt}, + ...history, + {role: "user", content: userMessage}, + ]) + } +} diff --git a/apps/simplex-support-bot/src/index.ts b/apps/simplex-support-bot/src/index.ts new file mode 100644 index 0000000000..0d0c667750 --- /dev/null +++ b/apps/simplex-support-bot/src/index.ts @@ -0,0 +1,372 @@ +import {readFileSync, writeFileSync, existsSync} from "fs" +import {api, bot, util} from "simplex-chat" +import {T} from "@simplex-chat/types" +import {parseConfig} from "./config.js" +import {SupportBot} from "./bot.js" +import {GrokApiClient} from "./grok.js" +import {welcomeMessage} from "./messages.js" +import {profileMutex, log, logError} from "./util.js" + +interface BotState { + teamGroupId?: number + grokContactId?: number + grokUserId?: number +} + +function readState(path: string): BotState { + if (!existsSync(path)) return {} + try { return JSON.parse(readFileSync(path, "utf-8")) } catch { return {} } +} + +function writeState(path: string, state: BotState): void { + writeFileSync(path, JSON.stringify(state), "utf-8") +} + +async function main(): Promise { + const config = parseConfig(process.argv.slice(2)) + // Do not log config.db.connectionString — typically contains credentials. + log("Config parsed", { + stateFile: config.stateFile, + backend: config.db.type, + teamGroup: config.teamGroup, + teamMembers: config.teamMembers, + timezone: config.timezone, + completeHours: config.completeHours, + }) + const grokEnabled = config.grokApiKey !== null + if (!grokEnabled) log("No GROK_API_KEY provided, disabling Grok support") + + const stateFilePath = config.stateFile + const state = readState(stateFilePath) + + // Forward-reference for event handlers during init + let supportBot: SupportBot | undefined + + // On restart, the active user may be Grok (if the previous run was killed + // mid-profile-switch). bot.run() uses apiGetActiveUser() and would then + // operate against the Grok userId as if it were the main user. Restore + // the main user as active before bot.run(). Grok is identified by the + // 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(config.db) + try { + const activeUser = await preChat.apiGetActiveUser() + if (activeUser && activeUser.userId === state.grokUserId) { + const users = await preChat.apiListUsers() + const mainCandidates = users.filter(u => u.user.userId !== state.grokUserId) + if (mainCandidates.length === 0) { + throw new Error( + `DB has only the Grok user (userId=${state.grokUserId}); no main user to restore. ` + + `Likely a corrupted migration or partial restore.` + ) + } + if (mainCandidates.length > 1) { + const names = mainCandidates.map(u => `${u.user.userId}:${u.user.profile.displayName}`).join(", ") + throw new Error( + `Ambiguous DB state: multiple non-Grok users [${names}]. ` + + `Refusing to guess which is main — remove extras manually.` + ) + } + const mainUserInfo = mainCandidates[0] + await preChat.apiSetActiveUser(mainUserInfo.user.userId) + log(`Restored active user to ${mainUserInfo.user.profile.displayName} (userId=${mainUserInfo.user.userId})`) + } + } finally { + await preChat.close() + } + } + + // Profile images (base64-encoded JPEG) + const supportImage = "data:image/jpg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAYEBQYFBAYGBQYHBwYIChAKCgkJChQODwwQFxQYGBcUFhYaHSUfGhsjHBYWICwgIyYnKSopGR8tMC0oMCUoKSj/2wBDAQcHBwoIChMKChMoGhYaKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCj/wAARCACAAIADASIAAhEBAxEB/8QAHwAAAQUBAQEBAQEAAAAAAAAAAAECAwQFBgcICQoL/8QAtRAAAgEDAwIEAwUFBAQAAAF9AQIDAAQRBRIhMUEGE1FhByJxFDKBkaEII0KxwRVS0fAkM2JyggkKFhcYGRolJicoKSo0NTY3ODk6Q0RFRkdISUpTVFVWV1hZWmNkZWZnaGlqc3R1dnd4eXqDhIWGh4iJipKTlJWWl5iZmqKjpKWmp6ipqrKztLW2t7i5usLDxMXGx8jJytLT1NXW19jZ2uHi4+Tl5ufo6erx8vP09fb3+Pn6/8QAHwEAAwEBAQEBAQEBAQAAAAAAAAECAwQFBgcICQoL/8QAtREAAgECBAQDBAcFBAQAAQJ3AAECAxEEBSExBhJBUQdhcRMiMoEIFEKRobHBCSMzUvAVYnLRChYkNOEl8RcYGRomJygpKjU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6goOEhYaHiImKkpOUlZaXmJmaoqOkpaanqKmqsrO0tba3uLm6wsPExcbHyMnK0tPU1dbX2Nna4uPk5ebn6Onq8vP09fb3+Pn6/9oADAMBAAIRAxEAPwD6pooooAKKKKACiignAyelABRQCGAIIIPIIooAKKKKACikjdZEDxsGU8gqcg0tAk01dBRRRQMKKKKACiiigAooooAK898ZeKftBew058Qj5ZZR/H7D29+9ehVxHjTwt5++/wBMT9996WFR9/8A2h7+3f69e/LnRVZe1+Xa587xNTxtTBNYP/t627Xl+vVr8c/wf4oNkyWWoPm1PCSH/ln7H/Z/lXo6kMAVIIPIIrwTdiuw8GeKjYsljqDk2h4SQ/8ALP2P+z/KvSzDLua9WkteqPmOGeJHQtg8Y/d+zLt5Py7Pp6bel1wXjHxRv32GmyfJ92WZT97/AGV9vU1H4z8ViTfYaZJ+7+7LMp+9/sqfT1NcOGqMvy61qtVeiNeJuJea+Dwb02lJfkv1Z1PhTxI+lSiC5JeyY8jqYz6j29RXp6MHRWU5VhkGuG8F+F8eXqGpx8/eihYdP9ph/IV3VcWZTpSq/u9+p7fCdDG0cHbFP3X8Ke6X+XZdAooorzj6kKKKKACiikYhVJYgAckmgBTxRXzJ8dPi6dUNx4d8LXGNPGY7u8jP+v8AVEP9z1P8XQcddL4E/F7/AI9/Dfiu49I7K+kbr2Ech/QN+B7Gu95dWVH2tvl1scqxdN1OQ+iaKKK4DqOG8b+FPPEmoaYn7770sKj7/wDtD39u/wBevnAas346/F77X9o8N+FLj/R+Y7y+jb/WdjHGf7vYt36DjJPnvgPxibXy9M1aT/R+FhnY/wCr9FY/3fQ9vp0+ty32qpJVvl3sfnPEmS051HiMItftJfmv1PVN1eheCPCvEeo6mmScNDC36M39BXm+6u18EeLTYMljqTk2h4jkP/LL2P8As/yrTMIVnRfsfn3t5Hh8PPB08ZF4xadOyfn/AF6nqNFIrBlDKQQeQR3pa+OP2IKRHV1DIwZT0IORXn/jjxdt8zTtLk+b7s0ynp6qp/maxPB3il9HmFvdFnsHPI6mM+o9vUV6cMqrTo+169F5HzNfinCUcYsM9Y7OXRP/AC7voeuUU2KRZY0kjIZGAZSO4NOrzD6VO+qCkZQylWAKkYIPelooGfMHxz+EZ0Zp/EPheAnTDl7q0jH/AB7eroP7nqP4fp08Lr9EmUMpVgCDwQa+Yfjn8Im0dp/EPhe3LaaSXurOMZNue7oP7nqP4fp09/L8w5rUqr16M8vF4S3vwNb4FfF7/j38N+K7jniOyvpG69hHIT+QY/Q9jVb47fF03RufDfhS4xbjMd7exn/WdjHGf7vYt36DjJPz/RXZ/Z9H23tbfLpfuc/1up7PkE6D0FfRnwK+EOw2/iTxXb/PxJZ2Mi/d7iSQevcL26nnAB8C/hD5Zt/Efiy3xJxJZ2Mq/d7iSQHv3C9up5wB9D1wZhmG9Kk/VnVhMJ9uZwPjvwj9o8zUtKj/AH33poVH3/8AaX39R3+vXzLdX0XXn3j3wd9o8zUtJj/f/emgUff/ANpR6+o7/XrpleZ2tRrPTo/0Z8xxFw5z3xeEWvVd/NfqjL8DeLzp7JYam5NmTiOQ/wDLL2P+z/KtDx14xAD6dpEuT0mnQ9P9lT/M15nu5pd1etLLKMq3tmvl0v3Pm4Z9jIYP6mpad+qXYn3V6D4E8ImXy9S1WP8Ad/ehgYfe9GYenoKj8A+EPOEWp6tH+74aCBh970Zh6eg716ZXl5nmVr0aL9X+iPe4d4cvbF4tecY/q/0QUUUV86ffhRRRQAV82/HX4vfa/tHhvwpcf6NzHeX0bf6zsY4z/d7Fu/QcZJPjr8XvtRuPDfhS4/0fmO8vo2/1nYxxkfw9i3foOMk/P/8AKvdy/L7Wq1V6I8zF4v7EBOn0pa+i/gX8INot/Efiy2+fiSzsZV+76SSA9/RT06nnAGP8dPhGdHa48Q+F4CdMJL3Vogybc93Qf3PUfw/Tp3rH0XV9lf59L9jleFqKn7Q1vgV8Xjm38N+LLnJ4js76VuvYRyE/kGP0PY19E1+dlfRXwJ+L3Nv4b8V3HPEdlfSN17COQn8g34Hsa8/MMv3q0l6o68Ji/sTPomvNfiB412mTS9Hl+blZ7hT09VU+vqaj+InjfYZdK0eX5uVnuFPT1VT6+p/CvMN1dOVZTe1euvRfqz5riDP98LhX6v8ARfqybdS7q9E+HngszeVqmsRfu+Ggt2H3vRmHp6DvVz4heC/tAk1PR4v3/wB6aBR9/wD2lHr6jv8AXr6TzTDqv7C/z6X7Hgx4dxcsJ9aS/wC3etu//AMrwD4zOnMmn6pITZE4jlY5MXsf9n+X0r1pWDKGUgqRkEd6+Zd2K7z4f+NDprR6dqrk2JOI5T/yx9j/ALP8vpXFmuU8961Ba9V3815/mevw/n7o2wuKfu9H28n5fl6bev0UisGUMpBUjII70tfKn3wVHdQRXVtLb3CCSGVCjoejKRgg/hUlFAHx98Z/hbceCrttQ0tXm8PTNhWPLWrHojn09G/A89e7+BXwh8v7P4k8V2/z8SWdjIv3e4kkB79wvbqecAfQc0Mc8TRzRpJG3VXUEH8DT69GeZVZ0vZ9e5yRwcI1Of8AAKRlDKVYAg8EGlorzjrPmD45/CM6O0/iHwvATphJe6tIx/x7+roP7nqP4fp04Hwh4aB2X+pR8feihYdf9ph/IV9EfErx2B52kaLKCeUuLhT09UU/zP4V5Tur7jKaFaVFTxHy728z4LPcxgpujhX6v9F+pPur074c+CDN5Wq6zF+64aC3cfe9GYenoO9eV7q9d+G/joXXlaVrUv8ApHCwXDH/AFnorH+96Hv9eumb/WI4duh8+9vI87IaeFeKX1n5dr+f6HptFFFfBn6ceb/ETwT9pEuqaNH/AKR96eBR/rPVlH971Hf69c34d+CTdmPU9ZiIth80MDj/AFn+0w/u+g7/AE6+tUV6kc2rxw/sE/n1t2PEnkGEnivrTXy6X7/8AAAAABgCiiivLPbCiiigAooooAK8n+Jnj7YZdI0OX5uUuLlD09UU+vqfwFerSossbxuMowKkeoNeBfETwTL4cuDd2QaTSpG4PUwk/wALe3ofwPPX2sjpYepiLVnr0XRv+uh4Wf1cTTw37hadX1S/rdnG7q9U+GngPzxFq2uRfueGt7Zx9/0dh6eg79TTPhj4B87ytY1yL91w9vbOPv8Ao7D09B36mvYK9POc4tfD4d+r/RHlZJkV7YnEr0X6v/I8U+JPgZtKaTVNIjLaeTuliXkwH1H+z/L6V52GxX1c6q6lWAKkYIIyDXiXxL8CNpLSapo8ZbTyd0sK9YPcf7P8vpV5PnHtLYfEPXo+/k/P8/XfLO8i9nfE4ZadV2815fl6bb/w18eC68rSdbl/0j7sFw5/1norH+96Hv8AXr6fXjXwy8Bm9MWr61ERajDQW7D/AFvozD+76Dv9OvsteLnMcPHENYf59r+R72RyxMsMnifl3t5/oFFFFeSeyFFFFABRRRQAUUUUAFMmijmjaOZFkjYYZXGQR7in0UJ2Bq+4UUUUAFIyh1KsAVIwQRwaWigAAAAAGAKKKKACiiigAooooA//2Q==" + const grokImage = "data:image/jpg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/4gKgSUNDX1BST0ZJTEUAAQEAAAKQbGNtcwQwAABtbnRyUkdCIFhZWiAAAAAAAAAAAAAAAABhY3NwQVBQTAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA9tYAAQAAAADTLWxjbXMAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAtkZXNjAAABCAAAADhjcHJ0AAABQAAAAE53dHB0AAABkAAAABRjaGFkAAABpAAAACxyWFlaAAAB0AAAABRiWFlaAAAB5AAAABRnWFlaAAAB+AAAABRyVFJDAAACDAAAACBnVFJDAAACLAAAACBiVFJDAAACTAAAACBjaHJtAAACbAAAACRtbHVjAAAAAAAAAAEAAAAMZW5VUwAAABwAAAAcAHMAUgBHAEIAIABiAHUAaQBsAHQALQBpAG4AAG1sdWMAAAAAAAAAAQAAAAxlblVTAAAAMgAAABwATgBvACAAYwBvAHAAeQByAGkAZwBoAHQALAAgAHUAcwBlACAAZgByAGUAZQBsAHkAAAAAWFlaIAAAAAAAAPbWAAEAAAAA0y1zZjMyAAAAAAABDEoAAAXj///zKgAAB5sAAP2H///7ov///aMAAAPYAADAlFhZWiAAAAAAAABvlAAAOO4AAAOQWFlaIAAAAAAAACSdAAAPgwAAtr5YWVogAAAAAAAAYqUAALeQAAAY3nBhcmEAAAAAAAMAAAACZmYAAPKnAAANWQAAE9AAAApbcGFyYQAAAAAAAwAAAAJmZgAA8qcAAA1ZAAAT0AAACltwYXJhAAAAAAADAAAAAmZmAADypwAADVkAABPQAAAKW2Nocm0AAAAAAAMAAAAAo9cAAFR7AABMzQAAmZoAACZmAAAPXP/bAEMABQMEBAQDBQQEBAUFBQYHDAgHBwcHDwsLCQwRDxISEQ8RERMWHBcTFBoVEREYIRgaHR0fHx8TFyIkIh4kHB4fHv/bAEMBBQUFBwYHDggIDh4UERQeHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHv/AABEIAIAAgAMBIgACEQEDEQH/xAAdAAAABgMBAAAAAAAAAAAAAAAAAQQHCAkCAwUG/8QAOxAAAQIEBAQDBgUCBgMAAAAAAQIDAAQFEQYHITEIEkFRE2FxFCJCgZGhFTJSgrEJIzNDYnKS4VOy8P/EABYBAQEBAAAAAAAAAAAAAAAAAAABAv/EABsRAQEBAAMBAQAAAAAAAAAAAAABEQISIRMx/9oADAMBAAIRAxEAPwCINoAv3gCD3BjTIhpprA3gdTB2F9NoAE9oGm194IWMAQB/WB3gD7QNIAbwBtvA2PnBjtAF08oHe/ygDUwfTf6QA6wRsNYMX84NKSYDEHXYQY1jc5Kvty7Uw42pDTpUG1H4+U2JHcA6X76RpSn7bwBQB1gesADXvAD0gAaX0gDY2gWgCEGBcHSM2m1LNrR7LLrLvE+NqsKbhukTE++LFwpHK20D8S1n3Uj1OvS8B45DK1dI3JknSNEk/KJoZfcH0gyy3MY1xA666RdUpTEhKU+RdWCT8kiHbpHD3lHTmUtpwhLTShu5NvOPKPrdVvtE1cVrGReA/IfpGlcstF7pMWeTOReUswjkXgWkpHdtKmz9UkGPBYx4Tcv6o04uhTdSoUwfyAOe0Mj9q/e+ihDTFfikLSSCLQBa58+gh684uH7G+X8u7UH5NFVpCNTPyIKktju4gjmR66p84Z1EupblgmKjUy2pZ01hy8s8u2qjQ6hjjFSn5HBtII9odQeV2fe+GVl7/Go2BVskEnfb1vDVkXPZj1NNRqQeksMyrlpmYAsqYUN2mj3/AFK+H1hfxfYzkZrEUtl7hppqTwzhYezty7As2qZtZZ035fyi/XnPWIpjsW1h+t1l6oOsMSyFAIYlWE2almU6IabHRKRp3JuTckmOMBvGx1dyTbaMQSTfYHvFRqG0DWDtpA9IAvWM2Wys2jG149blphSoYuxbTcPUxF5uffS0gkXCBupZ8kpBUfIQHvuHHJepZmVwqWpySoUmoe2zoTrffwm76FZHySNT0BsBwVhSg4PoLFEw9TmZGTZGiUD3lq6qWrdSj1J1jXl9hSk4KwlIYcozIblZRvl5iPedWdVOK7qUbk/9Q2PExnhL5cyP4LRCzM4lmW+YBfvIk0G9nFjqo/Cn5nTfLRyscY6wngqRE3iauSlOSr/DQtV3HP8AagXUr5CGSxDxd4QlHy3RsPVapJH+Y84iXSfQe8r6gRC7FmKarXqs/U6tPzE9Ovqu4++sqWryv0HkNB0jgLmnCd4uJqbklxi0lbwTN4Km22ydVM1BCyB6FI/mPZJ4psrjQXKh49VTNIICZBUmfGWSOir8lvMqiu9Mw4NeYwol3XlmwJ1hhqRuanE7jTFCXqfh0Iw3TnAUnwD4kytJ6KcIsn9oHqY5PDdkfPZjVb8TqSXZTDUs5aZmBoqYUN2mj3/Ur4fWC4ackqjmNVBUakHpTDMq5aYmALKmFDdpo9/1K+H1ifFEpdPotJlqVSpRmTkpVsNsMtJslCR0H/2u8BwcUTNMy+yvqU1S5RiSkqLTXFyzDSLITyJPIkDzVb1vFXdemnpibdefcLjziytxZ3Uom5J9STFknFEVjIPFhRv7IkfLxUX+0Vp1TV5R13hAh6awBB2Fu5gCxiow+UEN9TB9ILQ9TAZsJ5nABEwv6fuEW3alXMYzDXMZRCZGVKhstfvOEefKED9xiIMgLvA+cWJ8E1PTJZEST4A5p2dmX1H0X4Y+yBEWHQzAxJKYQwVVsSzo5mafLKd5P1q2Sj9yiB84q+x7iSpYhxDP1mqTBfnZ19Tzyyd1HoOwAsAOgAibXHfWVyOVUhS21lJqNSQF2P5kNoUu3/Ll+kQEnlFTh1trCBOpRUreCFzA0vGxlsrVFRlLsqcVoIffhmyOn8xqt+IVAOyeGZRwCZmQLKfUP8lo9+6tkjzsI0cNGSU9mPVfxGoeJI4YknLTc2PdU8oalponrb8ytkjzsIezOLiFw7gajpwRlSxJKXJN+zicaSFS0oBpytDZ1d/iPu31PMbxFOxmJmPgXJvDMrSkNMpeaZCJCjydgvkGxP6E33Urc33MYZC5wyGaDFRaFONMqEhyKWx43ihbargLSbA6EWII007xXRX8QVGr1R+oVCcfnJyYWVvPvLKlrV3JO8PfwRVp6TzrkZXnIbqMpMSyx3sjxB92/vATMzmpBruVGKKUnVb9Lf8ADFt1hBUn7gRVxVB/cJI31EW5OJSttSFpCkqFiD1BiqPH0imn4lqkgkWTLTjzIHklxSR/EIPNA2Gt4I6bWN/tAG+kFtFQUA2gDaAOusAokD/eGoixrgym0TOQdJbQdZaYmWVeR8ZSv4UIrhl1crmneJt/0/sToeolfwk64A4y8ifl090qAQ59ClH/ACiLHT4/pB17L+gVBAJblqkptZ7c7Srf+kQSmxZ0xaTnrg046yurGH2kpM4toPSZV0fbPMj625f3RWPXZF6Vm3WnmlNOtqKVoULFKgbEEdCDpCDlNpufKHSyay6k68y9inGFSFBwTTnOWcn1aLmXN/ZpcbrcPWwPKPOwhvaEKaidS9Vg+5Kt+8phhXKt/sgK1CAeqrEgbAm0dfF+MKriVyWTOKal5GRb8Gn0+VSUSsk3+ltF9L7lRupR1USYqHSzgz2ma7R0YMwRJHDGC5VsMMybJ5XphA/8pB0B35AdSTzFRhjn5lTht0jUtalExjy7xFBIJVoYf3gqkXZvPOjLSklEozMPuEdAGlJH3WIYiTaUt0C0TZ4CsDOyNKqmN5xko9sAkpEkfmbSq7ix5FQSn9pgRKTpFVGZs0icxlW5ps3S/UZhxNuxdUR/MWW5vYkawllnX6+4vlXKyS/B11Lqhytj5qUmKtas4Vum6iT1N94Qc/vbeBt5QQMH6jSKgbwAL6wYFxeM0pBMASLhUPtwc1GYkM8aCGlK5ZrxZZ1I+JCmlHX5pSflDIMNErAF4k7wMYOfqWYLuKHGiJKisKCVkaKmHU8qU/JJUfp3gqb3w6xBHjepuCmMw/aMPTgNbfuqsSrSQWm19FlXRxXxJ9CbE6uxxH8QjVITM4VwLNpcnxducqbZBTL9Cho7FfdWyelztFGh0Ku4vrPsVIp07VZ51XMpDKC4o3OqlHprupR+cJDXiCyq5sD6wXgq1uDEucAcI9TnZMzOMK0ikrWj+3KyaA84k9OdR93Tsm/qIS17hExQw6s0au0ifav7vjhbC/mLKH3i+CKCWVfpjexKLWqwB17RJaQ4T8fuupS/MUKWRfVaptavsEQ6OX3ClhulvtzeLKq7WlpIPsrCCwxfso3K1D5pieCPPD3kvWMw662stuytCl3B7bPFNhbq23+pZ+idz0BsHodLkKJRpSk0yWblZKTZSyw0gWCEJFgP++sZ0qnSFJpzMhTZRiTlGEcjTLKAhCE9gBoIZ7iPzukMB0t+iUN9qaxM8jlABCkyQI/Ov/V+lHzOm8/Q1HHPmS1NzjGAKZMBbMksTFSUg3BeseRr9oJUfMjtEQ5lXOsnXeOxXJ5+em3pmYeceedWVuOOKupaibkk9STrHHKd4uDRbvpBRmU77aQQGveCM0J1jey2VaRggXjuYWos3Wqo1T5INBxV1LceXyNMtpF1OOKOiUJGpJ+5sDYOvlpgyr4zxLL0SjtJLzl1uvOaNS7Q/M64rolI+uw1MPLmDmnS8K4MRlflXNOIpTAUmpVpPuu1B0/4hQRsknQq6iwHui5b+v4skKVhp3BWCFuJpT1jVKmpBQ/WHB3G7cuPhb3O6tTYeHJKusWQ0TzqnDoIczJHN3E2W77jNMUxN0uYc55iQmE+4s7cyVD3kqt11HcGG0Si+to2oBTtGsY1PfA3EVl/X2EIqU07QJsgczc6Lt38nU6W9eWHRpldotTbDlNq8hOIOymJlDgP0MVgMzDiNlfO8KWZ9aNUmx7jQxn5tTktAfmpZhBW/MMtJHVawkfePG4rzay+w02v8QxPIuPJF/AlV+O6fLlRe3ztFerlTfcFlrUr/cSf5hM5NrUCL2HlDod0jc2OJ2q1Jl6m4LlnKRLKukzrpBmVD/SBdLfrqfSIzVWcenHnHnnVuuOKKlrWoqUpROpJOpJ7mMnFKXurbaNC081o1OOJ2c5xF7nWE6kWBjprb3hM63oTa0TFlc1aI1KBF4WuotftCVabEiM2K2N6bWjqSc0+1KuyrbikNPlJdSk25+U3APcA622vr0Ecxu28KmCBbpFgXNnmNjG9sX1hKyrvClCtI3GK3oAAt1gwBGKVXg0nS14qMgB1+0HbcQQMAGwMAYAtqNYHKTrGN/rBBWkAZA20jEp3tB30OojFStb94DW4nS94SuiFKyLbwldVeIsJHQLGEi0+kKnlXuN4SOG+/wBoxW4//9k=" + + const desiredCommands: T.ChatBotCommand[] = [ + ...(grokEnabled ? [{type: "command" as const, keyword: "grok", label: "Ask Grok"}] : []), + {type: "command", keyword: "team", label: "Switch to team"}, + ] + + // Step 1: Init main bot via bot.run() + log("Initializing main bot...") + const [chat, mainUser, mainAddress] = await bot.run({ + profile: {displayName: "Ask SimpleX Team", fullName: "", image: supportImage}, + dbOpts: config.db, + options: { + addressSettings: { + businessAddress: true, + autoAccept: true, + welcomeMessage, + }, + commands: desiredCommands, + useBotProfile: true, + updateProfile: false, + }, + events: { + acceptingBusinessRequest: (evt) => supportBot?.onBusinessRequest(evt), + newChatItems: (evt) => supportBot?.onNewChatItems(evt), + chatItemUpdated: (evt) => supportBot?.onChatItemUpdated(evt), + chatItemReaction: (evt) => supportBot?.onChatItemReaction(evt), + leftMember: (evt) => supportBot?.onLeftMember(evt), + joinedGroupMember: (evt) => supportBot?.onJoinedGroupMember(evt), + connectedToGroupMember: (evt) => supportBot?.onMemberConnected(evt), + newMemberContactReceivedInv: (evt) => supportBot?.onMemberContactReceivedInv(evt), + contactConnected: (evt) => supportBot?.onContactConnected(evt), + contactSndReady: (evt) => supportBot?.onContactSndReady(evt), + }, + }) + log(`Main bot user: ${mainUser.profile.displayName} (userId=${mainUser.userId})`) + + // Step 2: Resolve Grok profile from same ChatApi instance. + // Identify Grok strictly by the persisted userId in state.json. If no ID + // is persisted, this is a first-time run — create the user and persist. + let grokUser: T.User | null = null + if (grokEnabled) { + log("Resolving Grok profile...") + if (state.grokUserId !== undefined) { + const users = await chat.apiListUsers() + grokUser = users.find(u => u.user.userId === state.grokUserId)?.user ?? null + if (!grokUser) { + throw new Error( + `Persisted Grok userId=${state.grokUserId} not found in DB. ` + + `Either restore the user or delete state.json to re-create Grok.` + ) + } + } else { + log("Creating Grok profile...") + grokUser = await chat.apiCreateActiveUser({displayName: "Grok", fullName: "", image: grokImage}) + // apiCreateActiveUser sets Grok as active — switch back to main + await chat.apiSetActiveUser(mainUser.userId) + state.grokUserId = grokUser.userId + writeState(stateFilePath, state) + log(`Persisted Grok userId=${grokUser.userId}`) + } + + // Refresh Grok's profile if it has drifted from the canonical values. + const grokProfile: T.Profile = {displayName: "Grok", fullName: "", image: grokImage} + const currentProfile = util.fromLocalProfile(grokUser.profile) + if (currentProfile.image !== grokProfile.image || currentProfile.displayName !== grokProfile.displayName || currentProfile.fullName !== grokProfile.fullName) { + log("Grok profile changed, updating...") + await chat.apiSetActiveUser(grokUser.userId) + const summary = await chat.apiUpdateProfile(grokUser.userId, grokProfile) + await chat.apiSetActiveUser(mainUser.userId) + if (summary) { + log(`Grok profile updated: ${summary.updateSuccesses} contact(s) updated, ${summary.updateFailures} failed`) + } else { + log("Unexpected: Grok profile did not change") + } + } + log(`Grok profile: ${grokUser.profile.displayName} (userId=${grokUser.userId})`) + } + + // Step 3: Read state file + // Step 4: Enable auto-accept DM contacts + await chat.apiSetAutoAcceptMemberContacts(mainUser.userId, true) + log("Auto-accept member contacts enabled") + + // Step 5: List contacts, resolve Grok contact + const contacts = await chat.apiListContacts(mainUser.userId) + log(`Contacts: ${contacts.map(c => `${c.contactId}:${c.profile.displayName}`).join(", ") || "(none)"}`) + + // Always restore grokContactId so the one-way gate can find and remove + // Grok members even when Grok API is disabled. + if (typeof state.grokContactId === "number") { + const found = contacts.find(c => c.contactId === state.grokContactId) + if (found) { + config.grokContactId = found.contactId + log(`Grok contact from state: ID=${config.grokContactId}`) + } else { + log(`Persisted Grok contact ID=${state.grokContactId} not found`) + } + } + + if (grokEnabled) { + if (config.grokContactId === null) { + log("Establishing bot↔Grok contact...") + const invLink = await chat.apiCreateLink(mainUser.userId) + // Switch to Grok profile to connect + await profileMutex.runExclusive(async () => { + await chat.apiSetActiveUser(grokUser!.userId) + await chat.apiConnectActiveUser(invLink) + await chat.apiSetActiveUser(mainUser.userId) + }) + log("Grok connecting...") + + const grokProfileName = grokUser!.profile.displayName + const evt = await chat.wait( + "contactConnected", + (e) => + e.user.userId === mainUser.userId + && e.contact.profile.displayName === grokProfileName, + 60_000, + ) + if (!evt) { + console.error(`Timeout waiting for Grok contact (60s, displayName="${grokProfileName}"). Exiting.`) + process.exit(1) + } + config.grokContactId = evt.contact.contactId + state.grokContactId = config.grokContactId + writeState(stateFilePath, state) + log(`Grok contact established: ID=${config.grokContactId}`) + } + } + + // Step 6: Resolve team group + log("Resolving team group...") + const groups = await chat.apiListGroups(mainUser.userId) + + let existingGroup: T.GroupInfo | undefined + + if (typeof state.teamGroupId === "number") { + existingGroup = groups.find(g => g.groupId === state.teamGroupId) + if (existingGroup) { + config.teamGroup.id = existingGroup.groupId + log(`Team group from state: ${config.teamGroup.id}:${existingGroup.groupProfile.displayName}`) + } else { + log(`Persisted team group ID=${state.teamGroupId} not found, will create`) + } + } + + const teamGroupPreferences: T.GroupPreferences = { + directMessages: {enable: T.GroupFeatureEnabled.On}, + fullDelete: {enable: T.GroupFeatureEnabled.On}, + commands: [ + {type: "command", keyword: "join", label: "Join customer chat", params: "groupId"}, + ], + } + + if (config.teamGroup.id === 0) { + log(`Creating team group "${config.teamGroup.name}"...`) + const newGroup = await chat.apiNewGroup(mainUser.userId, { + displayName: config.teamGroup.name, + fullName: "", + groupPreferences: teamGroupPreferences, + }) + config.teamGroup.id = newGroup.groupId + state.teamGroupId = config.teamGroup.id + writeState(stateFilePath, state) + log(`Team group created: ${config.teamGroup.id}:${config.teamGroup.name}`) + } else if (existingGroup) { + // Only update profile if preferences or name changed + const prefs = existingGroup.fullGroupPreferences + const needsUpdate = + existingGroup.groupProfile.displayName !== config.teamGroup.name || + prefs.directMessages?.enable !== T.GroupFeatureEnabled.On || + prefs.fullDelete?.enable !== T.GroupFeatureEnabled.On || + JSON.stringify(prefs.commands) !== JSON.stringify(teamGroupPreferences.commands) + if (needsUpdate) { + await chat.apiUpdateGroupProfile(config.teamGroup.id, { + displayName: config.teamGroup.name, + fullName: "", + groupPreferences: teamGroupPreferences, + }) + log("Team group profile updated") + } + } + + // Step 7: Ensure direct messages enabled (done via groupPreferences above) + + // Step 8: Create team group invite link (best-effort — bot works without it) + let inviteLinkCreated = false + try { + try { await chat.apiDeleteGroupLink(config.teamGroup.id) } catch {} + const teamGroupInviteLink = await chat.apiCreateGroupLink( + config.teamGroup.id, T.GroupMemberRole.Member + ) + inviteLinkCreated = true + log("Team group invite link created") + console.log(`\nTeam group invite link (expires in 10 min):\n${teamGroupInviteLink}\n`) + } catch (err) { + logError("Failed to create team group invite link (SMP relay may be unreachable). Bot will continue without it.", err) + } + + let inviteLinkDeleted = false + async function deleteInviteLink(): Promise { + if (inviteLinkDeleted) return + inviteLinkDeleted = true + try { + await profileMutex.runExclusive(async () => { + await chat.apiSetActiveUser(mainUser.userId) + await chat.apiDeleteGroupLink(config.teamGroup.id) + }) + log("Team group invite link deleted") + } catch (err) { + logError("Failed to delete invite link", err) + } + } + let inviteLinkTimer: ReturnType | undefined + if (inviteLinkCreated) { + inviteLinkTimer = setTimeout(async () => { + log("10 minutes elapsed, deleting invite link...") + await deleteInviteLink() + }, 10 * 60 * 1000) + inviteLinkTimer.unref() + } + + // Step 9: Validate team members + if (config.teamMembers.length > 0) { + log("Validating team members...") + for (const member of config.teamMembers) { + const contact = contacts.find(c => c.contactId === member.id) + if (!contact) { + console.error(`Team member not found: ID=${member.id}. Available: ${contacts.map(c => `${c.contactId}:${c.profile.displayName}`).join(", ") || "(none)"}`) + process.exit(1) + } + if (contact.profile.displayName !== member.name) { + console.error(`Team member name mismatch: expected "${member.name}", got "${contact.profile.displayName}" (ID=${member.id})`) + process.exit(1) + } + log(`Team member validated: ${member.id}:${member.name}`) + } + } + + // Load Grok context and build API client only if enabled + let grokApi: GrokApiClient | null = null + if (grokEnabled) { + let contextFile = "" + if (config.contextFile) { + try { + contextFile = readFileSync(config.contextFile, "utf-8") + log(`Loaded Grok context: ${contextFile.length} chars from ${config.contextFile}`) + } catch { + log(`Warning: context file not found: ${config.contextFile}`) + } + } + grokApi = new GrokApiClient(config.grokApiKey!, contextFile) + } + + // Create SupportBot + supportBot = new SupportBot(chat, grokApi, config, mainUser.userId, grokUser?.userId ?? null, desiredCommands) + + if (mainAddress) { + supportBot.businessAddress = util.contactAddressStr(mainAddress.connLinkContact) + log(`Business address: ${supportBot.businessAddress}`) + } + + // Step 10: Register Grok event handlers (filtered by profile in handler) + if (grokEnabled) { + chat.on("receivedGroupInvitation", (evt) => supportBot?.onGrokGroupInvitation(evt)) + chat.on("connectedToGroupMember", (evt) => supportBot?.onGrokMemberConnected(evt)) + chat.on("newChatItems", (evt) => supportBot?.onGrokNewChatItems(evt)) + } + + // Step 10b: Refresh stale cards from before restart + await supportBot.cards.refreshAllCards() + + log("SupportBot initialized. Bot running.") + + // Step 11: Graceful shutdown + async function shutdown(signal: string): Promise { + log(`Received ${signal}, shutting down...`) + clearTimeout(inviteLinkTimer) + supportBot?.cards.destroy() + await deleteInviteLink() + process.exit(0) + } + process.on("SIGINT", () => shutdown("SIGINT")) + process.on("SIGTERM", () => shutdown("SIGTERM")) +} + +main().catch(err => { + logError("Fatal error", err) + process.exit(1) +}) diff --git a/apps/simplex-support-bot/src/messages.ts b/apps/simplex-support-bot/src/messages.ts new file mode 100644 index 0000000000..9952ccf8ce --- /dev/null +++ b/apps/simplex-support-bot/src/messages.ts @@ -0,0 +1,43 @@ +import {isWeekend} from "./util.js" + +export const welcomeMessage = `Hello! This is a *SimpleX team* support bot - not an AI. +Please ask any question about SimpleX Chat.` + +export function queueMessage(timezone: string, grokEnabled: boolean): string { + const hours = isWeekend(timezone) ? "48" : "24" + const base = `The team will reply to your message within ${hours} hours.` + if (!grokEnabled) return base + return `${base} + +If your question is about SimpleX, click /grok for an *instant Grok answer*. + +Send /team to switch back.` +} + +export const grokActivatedMessage = `*You are chatting with Grok* - use any language.` + +export function teamAddedMessage(timezone: string, grokPresent: boolean): string { + const hours = isWeekend(timezone) ? "48" : "24" + const base = `We will reply within ${hours} hours.` + if (!grokPresent) return base + return `${base} +Grok will be answering your questions until then.` +} + +export const teamAlreadyInvitedMessage = "A team member has already been invited to this conversation and will reply when available." + +export const teamLockedMessage = "You are now in team mode. A team member will reply to your message." + +export function noTeamMembersMessage(grokEnabled: boolean): string { + return grokEnabled + ? "No team members are available yet. Please try again later or click /grok." + : "No team members are available yet. Please try again later." +} + +export const grokInvitingMessage = "Inviting Grok, please wait..." + +export const grokUnavailableMessage = "Grok is temporarily unavailable. Please try again later or send /team for a human team member." + +export const grokErrorMessage = "Sorry, I couldn't process that. Please try again or send /team for a human team member." + +export const grokNoHistoryMessage = "I just joined but couldn't see your earlier messages. Could you repeat your question?" diff --git a/apps/simplex-support-bot/src/util.ts b/apps/simplex-support-bot/src/util.ts new file mode 100644 index 0000000000..288a48d673 --- /dev/null +++ b/apps/simplex-support-bot/src/util.ts @@ -0,0 +1,22 @@ +import {Mutex} from "async-mutex" + +export const profileMutex = new Mutex() + +export function isWeekend(timezone: string): boolean { + const day = new Intl.DateTimeFormat("en-US", {timeZone: timezone, weekday: "short"}).format(new Date()) + return day === "Sat" || day === "Sun" +} + +export function log(msg: string, ...args: unknown[]): void { + const ts = new Date().toISOString() + if (args.length > 0) { + console.log(`[${ts}] ${msg}`, ...args) + } else { + console.log(`[${ts}] ${msg}`) + } +} + +export function logError(msg: string, err: unknown): void { + const ts = new Date().toISOString() + console.error(`[${ts}] ERROR: ${msg}`, err) +} diff --git a/apps/simplex-support-bot/test/__mocks__/simplex-chat-types.js b/apps/simplex-support-bot/test/__mocks__/simplex-chat-types.js new file mode 100644 index 0000000000..29fc3d01a4 --- /dev/null +++ b/apps/simplex-support-bot/test/__mocks__/simplex-chat-types.js @@ -0,0 +1,12 @@ +// Mock for @simplex-chat/types — lightweight stubs + +const ChatType = {Direct: "direct", Group: "group", Local: "local"} +const GroupMemberRole = {Member: "member", Owner: "owner", Admin: "admin", Relay: "relay", Observer: "observer", Author: "author", Moderator: "moderator"} +const GroupMemberStatus = {Connected: "connected", Complete: "complete", Announced: "announced", Left: "left", Removed: "removed", Invited: "invited"} +const GroupFeatureEnabled = {On: "on", Off: "off"} +const CIDeleteMode = {Broadcast: "broadcast", Internal: "internal"} + +module.exports = { + T: {ChatType, GroupMemberRole, GroupMemberStatus, GroupFeatureEnabled, CIDeleteMode}, + CEvt: {}, +} diff --git a/apps/simplex-support-bot/test/__mocks__/simplex-chat.js b/apps/simplex-support-bot/test/__mocks__/simplex-chat.js new file mode 100644 index 0000000000..64c9246f27 --- /dev/null +++ b/apps/simplex-support-bot/test/__mocks__/simplex-chat.js @@ -0,0 +1,26 @@ +// Mock for simplex-chat — prevents native addon from loading + +function ciContentText(chatItem) { + const c = chatItem.content + if (c.type === "sndMsgContent" || c.type === "rcvMsgContent") return c.msgContent.text + return undefined +} + +function ciBotCommand(chatItem) { + const text = ciContentText(chatItem)?.trim() + if (text) { + const r = text.match(/\/([^\s]+)(.*)/) + if (r && r.length >= 3) return {keyword: r[1], params: r[2].trim()} + } + return undefined +} + +function contactAddressStr(link) { + return link.connShortLink || link.connFullLink +} + +module.exports = { + api: {ChatApi: {}}, + bot: {}, + util: {ciContentText, ciBotCommand, contactAddressStr}, +} diff --git a/apps/simplex-support-bot/tsconfig.json b/apps/simplex-support-bot/tsconfig.json new file mode 100644 index 0000000000..821fa663e3 --- /dev/null +++ b/apps/simplex-support-bot/tsconfig.json @@ -0,0 +1,23 @@ +{ + "include": ["src"], + "compilerOptions": { + "declaration": true, + "forceConsistentCasingInFileNames": true, + "lib": ["ES2022"], + "module": "Node16", + "moduleResolution": "Node16", + "noFallthroughCasesInSwitch": true, + "noImplicitAny": true, + "noImplicitReturns": true, + "noImplicitThis": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noEmitOnError": true, + "outDir": "dist", + "sourceMap": true, + "strict": true, + "strictNullChecks": true, + "target": "ES2022", + "types": ["node"] + } +} diff --git a/apps/simplex-support-bot/vitest.config.ts b/apps/simplex-support-bot/vitest.config.ts new file mode 100644 index 0000000000..c143572a87 --- /dev/null +++ b/apps/simplex-support-bot/vitest.config.ts @@ -0,0 +1,22 @@ +import {defineConfig} from "vitest/config" +import path from "path" + +export default defineConfig({ + test: { + globals: true, + testTimeout: 10000, + // Clear backend signals — .npmrc next to package.json otherwise injects + // npm_config_simplex_backend into every test's env, breaking sqlite-default + // assumptions in parseConfig tests. + env: { + SIMPLEX_BACKEND: "", + npm_config_simplex_backend: "", + }, + }, + resolve: { + alias: { + "simplex-chat": path.resolve(__dirname, "test/__mocks__/simplex-chat.js"), + "@simplex-chat/types": path.resolve(__dirname, "test/__mocks__/simplex-chat-types.js"), + }, + }, +})