Files
simplex-chat/apps/simplex-support-bot/plans/20260207-support-bot-implementation.md
T
Narasimha-sc 5a3dfdd2b4 SimpleX support bot (#6625)
* 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 <github.shum@liber.li>
Co-authored-by: sh <37271604+shumvgolove@users.noreply.github.com>
2026-04-27 09:12:42 +01:00

1472 lines
108 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 <path>` (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: <invalid>, ...})` 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<K extends CEvt.Tag>(
event: K,
predicate: ((event: ChatEvent & {type: K}) => boolean) | undefined,
timeout: number,
): Promise<ChatEvent & {type: K} | undefined>
```
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 <groupId>'` where `<groupId>` is the customer group's numeric ID. The outer single quotes around `join <groupId>` are rendered by SimpleX clients as a clickable quoted command; tapping it sends `/join <groupId>` 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: "<groupId>"}` 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 <groupId>:<name>` 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 <groupId>'` final line)
2. Post it via `apiSendMessages(chatRef, [{msgContent: {type: "text", text}, mentions: {}}])` → get one `chatItemId`. The card is a single message; the `/'join <id>'` 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<void> {
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<void> {
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<number>() // 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<void> {
const {text} = await this.composeCard(groupId, groupInfo)
// Single-message card — the `/'join <id>'` 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<void> {
const groups = [...this.pendingUpdates]
this.pendingUpdates.clear()
for (const groupId of groups) {
await this.updateCard(groupId)
}
}
async refreshAllCards(): Promise<void> {
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<void> {
// 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 <id>' — per spec format
// The final line of `text` is `/'join <groupId>'` — 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<number>` 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<T>(userId: number, fn: () => Promise<T>): Promise<T> {
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<number, Mutex>()
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<CardData>): Promise<void> {
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<void> {
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<number>` 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<string, number>() // memberId → mainGroupId
const bufferedGrokInvitations = new Map<string, CEvt.ReceivedGroupInvitation>() // memberId → buffered event
const grokGroupMap = new Map<number, number>() // mainGroupId → grokLocalGroupId
const reverseGrokMap = new Map<number, number>() // grokLocalGroupId → mainGroupId
const grokJoinResolvers = new Map<number, () => void>() // mainGroupId → resolve fn
const grokInitialResponsePending = new Set<number>() // 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 <path>` 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<string> {
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 110s; 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<void> {
const lastPerGroup = new Map<number, T.AChatItem>()
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 <groupId>` | 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 <groupId>'`; a team-member tap delivers a chat item whose text is `/join <groupId>`, which `ciBotCommand` returns as `{keyword: "join", params: "<groupId>"}`.
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 <id>' 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 <path>` 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 <groupId>'` (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 <groupId> (the only accepted form) → team member added; `params` from `ciBotCommand` is parsed via `Number.parseInt`, no regex
- /join <groupId>:<name> (historic suffix) → still parses because `Number.parseInt("<groupId>:<name>", 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<number>` 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.