97 KiB
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 ← "Ask SimpleX Team" profile │
│ • Business address, event routing, state mgmt │
│ • Controls group membership │
│ │
│ grokUserId ← "Grok" profile │
│ • Joins customer groups as Member │
│ • Sends Grok responses into groups │
│ │
│ profileMutex: serialize apiSetActiveUser + call │
│ GrokApiClient → api.x.ai/v1/chat/completions │
└─────────────────────────────────────────────────┘
- Single Node.js process, single
ChatApiinstance via native NAPI - Two user profiles in one database — resolved at startup via
apiListUsers()by display name profileMutexserializesapiSetActiveUser(userId)+ the subsequent SimpleX API call. Grok HTTP API calls run outside the mutex.- Events delivered for all profiles — routed by
event.userfield (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 (130 tests, 28 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:
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:
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.
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):
{"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):
- Read
grokContactIdfrom state file → validate viaapiListContacts→ setconfig.grokContactId(this always runs, even whengrokApiKey === null, so the one-way gate can identify and remove Grok members from groups) - If not found and
grokEnabled: main profile creates one-time invite link, Grok profile connects, wait for acontactConnectedevent filtered by profile identity (60s — see "Grok contact identification" below), persist the resultingcontactIdatomically before proceeding. - If unavailable (with Grok otherwise enabled), bot runs but
/grokreturns "temporarily unavailable" - If
grokApiKey === null: the Grok profile is not resolved or created, no invite link is issued — butconfig.grokContactIdis 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:
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:
const grokProfileName = grokUser.profile.displayName // "Grok" or "Grok AI"
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.userIdselects the main profile's view of the handshake. Both profiles observe the handshake (the Grok-side event describes the main profile as thecontact); only the main-side event carries thecontactIdwe need for subsequentapiAddMembercalls.e.contact.profile.displayName === grokProfileNameaccepts 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 fromevt.contact.profile, which isLocalProfile(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" (or "Grok AI") 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):
- Read
teamGroupIdfrom state file → validate via group list - If not found: create with
apiNewGroup, persist new group ID - If found: compare
fullGroupPreferences(directMessages, fullDelete, commands) and displayName with desired values. Only callapiUpdateGroupProfileif something differs — avoids unnecessary SMP relay round-trips on every restart.
Team group invite link lifecycle:
- 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.
- Delete after 10 minutes. On SIGINT/SIGTERM, delete before exit. Deletion must go through
profileMutexwithapiSetActiveUser(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:
/teamtells 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}fromapiListMembers— used for card rendering and the/teamduplicate-invite guard.deriveState(groupId)→ readscustomData.state. ReturnsWELCOMEiffcustomData.stateis 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):
- Compose card text (including the
/'join <groupId>'final line) - Post it via
apiSendMessages(chatRef, [{msgContent: {type: "text", text}, mentions: {}}])→ get onechatItemId. 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. - Write
{cardItemId}to customer group'scustomData
Update (delete + repost) — on every subsequent event (new customer msg, team/Grok reply, state change, agent join):
- Read
{cardItemId}fromcustomData - Delete old card via
apiDeleteChatItems([Group, teamGroupId], [cardItemId], "broadcast"). Persimplex-chat/src/api.ts:436-445the call either returnsT.ChatItemDeletion[](possibly empty if the item no longer exists) or throwsChatCommandError. Both outcomes are acceptable: the surroundingtry { ... } catch { /* log and continue */ }allows execution to proceed whether the item was still present, already gone, or the server returned a transient error. - Post new card as a single message via
apiSendMessages→ get newcardItemId. On failure the partial-failure policy below applies: log, re-queue this groupId intopendingUpdates, return without writingcustomData. - Write
{cardItemId, complete?}tocustomDataviamergeCustomData. 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 returnsnewChatItemsonce 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 fromapiSendMessages. A thrownChatCommandErrormeans 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
logErrorwith{groupId, step, err}so the operator can diagnose the underlying cause (permission change, DB corruption, bot removed from team group, etc). - Re-add
groupIdtopendingUpdatesviathis.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
cardItemIdand 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:
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
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:
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,
},
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 registered as customer commands via bot.run(). /join 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:
const users = await chat.apiListUsers()
// Accept the legacy "Grok AI" display name for profiles created before the rename.
let grokUser = users.find(u => u.displayName === "Grok" || u.displayName === "Grok AI")
if (!grokUser) {
grokUser = await chat.apiCreateActiveUser({displayName: "Grok", fullName: "", image: grokImage})
// apiCreateActiveUser sets Grok as active — switch back to main
await chat.apiSetActiveUser(mainUser.userId)
} else {
// If profile changed (e.g. new image or legacy "Grok AI" → "Grok"), update and push to contacts
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:
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:
processMainChatItemwritingstatetransitions (WELCOME→QUEUE, WELCOME→GROK, QUEUE→GROK, one-way gate →TEAM)activateGrok'srevertStateOnFail(fire-and-forget) racing with subsequent customer messagesactivateTeamwritingTEAM-PENDINGracing with/grokor another/teamon the same groupCardManager.flush → updateCardwriting{cardItemId, complete}racing with dispatch writingstatecreateCardwriting{cardItemId}immediately after dispatch writesstate
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:
// 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: On restart, the active user may be Grok (if the previous run was killed mid-profile-switch). bot.run() uses apiGetActiveUser() and would rename Grok → duplicateName error. Fix: pre-init the DB with a temporary ChatApi, check active user, if not "Ask SimpleX Team" then startChat() + find the main user via apiListUsers() + apiSetActiveUser(), then close(). This ensures bot.run() always finds the correct active user.
bot.run()→ init ChatApi, create/resolve main profile (with profile image), business address. Print business address link to stdout.- Resolve Grok profile via
apiListUsers()(create with profile image if missing; if existing, compare profile and update viaapiUpdateProfile()if changed — pushes to contacts) - Read
{dbPrefix}_state.jsonforteamGroupIdandgrokContactId - Enable auto-accept DM contacts:
apiSetAutoAcceptMemberContacts(mainUser.userId, true) - List contacts, resolve Grok contact (from state or auto-establish)
- Resolve team group (from state or auto-create)
- Ensure direct messages + delete for everyone enabled on team group (conditional — only updates profile if preferences or name differ from desired)
- Create team group invite link (best-effort), schedule 10min deletion if created
- Validate
--auto-add-team-members(-a) if provided - Register Grok event handlers on
chat(filtered byevent.user === grokUserId) 10b. Refresh stale cards:CardManager.refreshAllCards()— lists all groups, skips those withcustomData.completeor nocustomData.cardItemId, sorts remaining bycardItemIdascending, re-posts oldest-first so newest cards land at the bottom of team group - On SIGINT/SIGTERM →
clearTimeout(inviteLinkTimer)(noop if already deleted),cards.destroy()(stops the card-flush interval),deleteInviteLink()(profileMutex-gatedapiDeleteGroupLink),process.exit(0). Signal handler is reentrant-safe: aninviteLinkDeletedflag prevents double-deletion;clearTimeout/clearIntervalare no-op on undefined.
Grok event registration (same ChatApi, filtered by profile):
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):
// 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.
- User sends
/team→ ALL configured--auto-add-team-members(-a) added to group (promoted to Owner on connect) → Grok stays if present → TEAM-PENDING - Repeat
/team→ detected viacustomData.state ∈ {TEAM-PENDING, TEAM}and team members still present → reply withteamAlreadyInvitedMessage. If team members have since left, re-add them silently (state staysTEAM-PENDING). /grokstill works in TEAM-PENDING (if Grok not present, invite it; if present, ignore — Grok responds to customer messages)- Any team member sends first text message in customer group → gate triggers:
- Remove Grok from group (
apiRemoveMembers) /grokpermanently disabled → replies: "You are now in team mode. A team member will reply to your message."- State =
TEAM(written ascustomData.state = 'TEAM'at observation time)
- Remove Grok from group (
- Detection: in
onNewChatItems, when sender is a team member andcustomData.state !== 'TEAM', trigger the gate and writestate: 'TEAM'viamergeCustomData.
Edge cases:
- All team members leave before sending → state stays
TEAM-PENDING(customer is still waiting for a response); sending/teamre-adds them without the "already invited" reply. - Team member leaves after sending → state stays
TEAM(customData.statepersists). Customer can send/teamagain 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.grokContactIdis still restored from the state file (the lookup runs unconditionally before theif (grokEnabled)block). This ensuresgetGroupCompositioncan 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/apiCreateActiveUserfor "Grok"; no invite link issued). GrokApiClientis not instantiated.SupportBotreceivesgrokApi = nullandgrokUserId = null.- Bot command list registered at startup contains only
/team—/grokis not advertised. - Grok event handlers (
receivedGroupInvitation,connectedToGroupMember, Grok-sidenewChatItems) are not registered. Handlers that are shared with the main profile (e.g.onMemberConnected) remain correct because their Grok checks are guarded bythis.config.grokContactId !== null. - Customer-facing messages (
queueMessage,noTeamMembersMessage) accept agrokEnabledflag and drop the/grokclause when false. - If the customer still types
/grokmanually,processMainChatItemrewritescmdtonullwhenrawCmd?.keyword === "grok" && !this.grokEnabled, so the dispatcher treats it as an unrecognized command (same as any other plain text). - Defense in depth:
activateGrokandprocessGrokChatItemshort-circuit on entry whenthis.grokApi === null;withGrokProfilethrows if called withgrokUserId === null.
Type signatures affected:
Config.grokApiKey: string | nullSupportBotconstructor:grokApi: GrokApiClient | null, grokUserId: number | nullqueueMessage(timezone: string, grokEnabled: boolean): stringnoTeamMembersMessage(grokEnabled: boolean): string(was a plainconst 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...")
- Set
grokInitialResponsePending.add(groupId)FIRST — the gate must be raised before any operation that could make Grok recognizable toonGrokNewChatItems. Specifically: beforeapiAddMember, beforependingGrokJoinsis set, and beforebufferedGrokInvitationsis drained (which populatesreverseGrokMap). Without this ordering, the sequenceapiAddMember → pendingGrokJoins.set → drain → reverseGrokMap.set → gate.addcontains a window wherereverseGrokMapidentifies 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. apiAddMember(groupId, grokContactId, Member)→ getmember.memberId. OngroupDuplicateMember(customer sent/grokagain before join completed), clear the gate and silent return — the in-flight activation handles the outcome. On any other error, clear the gate, revert state, sendgrokUnavailableMessage.- Store
pendingGrokJoins.set(memberId, mainGroupId) - Drain
bufferedGrokInvitations— if thereceivedGroupInvitationevent arrived during step 2's await (race condition), process it now. (The gate is already up from step 1, soonGrokNewChatItemssuppresses any per-message responses during drain and the subsequent join.) waitForGrokJoin(120s)— awaits resolver from Grok profile'sconnectedToGroupMember(step 8 below)- Timeout → notify customer (
grokUnavailableMessage), send queue message if was WELCOME→GROK, fall back to QUEUE (CAS-guarded: only ifcustomData.stateis stillGROK— a concurrent/teamthat switched toTEAM-PENDINGis respected), cleargrokInitialResponsePending
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.
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:
- Batch deduplication: When multiple customer messages arrive in a single
newChatItemsevent (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 viaapiGetChat. 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. - Initial response gate: Skip groups where
grokInitialResponsePendingis set (checked viareverseGrokMapto translate Grok's local groupId to mainGroupId). This prevents per-message responses from racing with the initial combined response inactivateGrok. - Only trigger for
groupRcvtext messages from customer (identified viabusinessChat.customerId) - Ignore: non-text messages (images, files, voice — card update handled by main profile), bot messages, own messages (
groupSnd), team member messages - Read last 100 messages from own view (customer →
user, own →assistant) - 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).
- 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:
- Team member sends first text message in customer group (one-way gate)
- Grok join timeout (120s) — fallback to QUEUE
- 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:
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:
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:
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→ sendsgrokErrorMessageto the customer group, conversation stays GROK.activateGrokinitial-response path → logs, sendsgrokUnavailableMessage, lets thefinallyblock cleargrokInitialResponsePending.
Rationale for 60s: typical xAI responses return in 1–10s; a 60s ceiling accommodates cold-start / heavy-load latencies while still bounding worst-case per-customer wait. Not exposed as a CLI flag in MVP — a later iteration can add --grok-timeout-seconds if operator tuning is needed.
Cross-group Grok parallelism
onGrokNewChatItems MUST dispatch per-group work concurrently. A naïve for (const ci of lastPerGroup.values()) { await this.processGrokChatItem(ci) } serializes calls across unrelated customer groups — if xAI takes 3s per call and five customers message in one event batch, customer #5 waits ~15s instead of ~3s. This is pure latency amplification with no ordering benefit (the groups are independent; within-group order is already preserved by batch deduplication picking the last message).
Implementation:
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 againstactivateGrok'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:
- Extract
{keyword, params}from the chat item withutil.ciBotCommand(chatItem). The framework already parses the leading/keywordand returns the trimmed remainder asparams— 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>, whichciBotCommandreturns as{keyword: "join", params: "<groupId>"}. - Convert
paramsto a number withconst targetGroupId = Number.parseInt(params, 10). IfNumber.isNaN(targetGroupId) || targetGroupId <= 0, reply in the team group withError: invalid group id "${params}"and return. No regex, nosplit(":"), no legacy fallback — operators must use the numeric form (which is what the card always emits). - Validate target is a business group (has
businessChatproperty) — error in team group if not. - Add requesting team member to customer group via
apiAddMember. - Member promoted to Owner on
connectedToGroupMember(see §8).
Team member promotion: On every connectedToGroupMember in a customer group, promote to Owner unless 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:
onJoinedGroupMember— fires when ANY member joins the team group via invite link (joinedGroupMemberevent). CallssendTeamMemberDMwithout amemberContact. Since link-joiners typically have no existing DM contact, this creates the contact viaapiCreateMemberContact(groupId, groupMemberId), then sends the invitation with message viaapiSendMemberContactInvitation(contactId, msg).onMemberConnected—sendTeamMemberDMcalled withmemberContactfrom the event. If not already sent by path 1:- If
contactIdexists: sends DM viaapiSendTextMessage. - If
contactIdis null: uses the sameapiCreateMemberContact+apiSendMemberContactInvitationpath as path 1.
- If
onMemberContactReceivedInv— fires when the member initiates a DM first. Sends the contact ID message immediately. If send fails, queues forcontactConnected/contactSndReady.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
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:
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 — only two keys:
| Key | Type | Why persisted |
|---|---|---|
teamGroupId |
number | Team group created once on first run |
grokContactId |
number | Bot↔Grok contact takes 60s to establish |
Not persisted:
| State | Where it lives |
|---|---|
state, cardItemId, complete |
Customer group's customData |
| User profile IDs | Resolved via apiListUsers() by display name |
| 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 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" |
groupDuplicateMember |
Catch, apiListMembers to find existing member |
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 |
| 21 | Promote to Owner | main | apiSetMembersRole(gId, [memberId], Owner) |
connectedToGroupMember |
| 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 dispatchcards.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)
/grokactivation: invite, wait join, Grok reads history + responds independently/grokas 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 /joincommand 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 whenGROK_API_KEYis set) - End-to-end test all flows
18. Self-Review Requirement
Each code artifact must undergo adversarial self-review/fix loop:
- Write/edit code
- Self-review against this plan: correctness, completeness, all state transitions, all API calls, all error cases
- Fix issues found
- Repeat until 2 consecutive zero-issue passes
- Report completion → user reviews → if changes needed, restart from step 1
19. Verification
Startup:
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:
- Connect → verify welcome message, business address link printed to stdout
- Send question → verify card appears in team group (🆕), queue reply received
/grok→ verify Grok joins, responses from "Grok", card updates to 🤖/grokas first message → verify WELCOME→GROK, no queue message, card 🤖/teamin GROK → verify team added, Grok stays, card 👋 Team-pending/grokin TEAM-PENDING → verify Grok still responds- Team member sends text → verify Grok removed,
/grokrejected, card → 💬 /grokin TEAM → verify "team mode" rejection/teamwhen already invited → verify "already invited" message- Card debouncing: multiple rapid events → verify single card update per 300s flush (default)
/joinfrom team group → verify team member added to customer group, promoted to Owner/joinwith non-business group → verify error- Weekend → verify "48 hours"
- Customer leaves → verify cleanup, card remains
- Grok timeout → verify fallback to QUEUE, queue message sent
- Grok API error (per-message) → verify error in group, stays GROK
- Grok no-history fallback → verify generic greeting sent
- Non-text message in GROK mode → verify no Grok API call, card updated
- Team/Grok reaction → verify card auto-complete (✅ icon, "done")
- DM contact text message → verify business address link reply
- DM contact non-message event (e.g. contactConnected) → verify no reply (rcvMsgContent guard)
- DM handshake via
joinedGroupMember→ team member joins team group via link → verifyapiCreateMemberContact+apiSendMemberContactInvitationcalled, contact ID message sent - DM handshake via
connectedToGroupMember→ verify contact ID message sent (dedup with #22) - Restart → verify same team group + Grok contact from state file, cards resume via
customData - No
--auto-add-team-members(-a) →/team→ verify "no team members available" groupDuplicateMember→ verifyapiListMembersfallback- Team member leaves (no message sent) → verify revert to QUEUE
- Team member leaves (message sent), customer sends
/team→ verify re-adds team members - Card preview sender prefixes → verify first message in each consecutive sender run gets
Name:prefix, subsequent same-sender messages do not /teamafter 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 |
// 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) apiSendTextMessagereturns[{chatItem: {meta: {itemId: N}}}]— auto-incrementing IDsapiGetChatreturns fromchatItemsmap withchatInfo.groupInfofromgroupsmapapiCreateMemberContact(groupId, groupMemberId)— returns a contact object with auto-incrementingcontactId. Tracks calls inmemberContactsarray.apiSendMemberContactInvitation(contactId, msg)— returns a contact object. Tracks calls inmemberContactInvitationsarray.
MockGrokApi — inline class:
callsarray tracks{history, message}for eachchat()callwillRespond(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:
// 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:
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:
- Waits 10ms (lets
activateGrokreachwaitForGrokJoin) - Fires
onGrokGroupInvitation(Grok accepts invite) - Fires
onGrokMemberConnected(Grok fully connected → resolver called)
Called as: const p = simulateGrokJoinSuccess(); await bot.onNewChatItems(...); await p;
20.4 Test Catalog (130 tests, 28 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 (10 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
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 (6 tests)
- first message creates card with customer name + /join
- card final line is
/'join <groupId>'(single-quoted, numeric id only, no:namesuffix) - card update deletes old, posts new
- apiDeleteChatItems failure → ignored, new card posted
- customData stores cardItemId through flush cycle
- customer leaves → customData cleared
8. Card Debouncing (4 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
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 (5 tests)
- /join (the only accepted form) → team member added;
paramsfromciBotCommandis parsed viaNumber.parseInt, no regex - /join with non-numeric
params(e.g./join abc) → error reply in team group, noapiAddMembercall - /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
apiCreateMemberContactand sends invitation viaapiSendMemberContactInvitation - joinedGroupMember in team group → creates member contact via
apiCreateMemberContactand sends invitation viaapiSendMemberContactInvitation - 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
- groupDuplicateMember on Grok invite → only inviting message, no result (in-flight activation handles outcome)
- groupDuplicateMember on /team → apiListMembers fallback
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 (5 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
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)
deriveStatereturnsWELCOMEwhencustomData.stateis absent- first customer non-command message → handler writes
customData.state = "QUEUE" /grokhandler → writescustomData.state = "GROK"/teamhandler → writescustomData.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 toQUEUE)
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;
/joinis 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
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()callsreachTeamPending()which callsreachQueue() - Grok join simulation —
simulateGrokJoinSuccess()uses 10ms setTimeout to fire events duringwaitForGrokJoinawait. Tests callawait bot.flush()after simulation to await fire-and-forgetactivateGrokcompletion. - No fake timers — real timers everywhere; flush called explicitly via
cards.flush()andbot.flush()
20.6 Test Coverage Notes
Covered vs plan catalog:
- §20.4 suites 1-13, 15, 17-27 plus 5b fully covered (130 tests across 28 suites)
- Weekend detection (
util.isWeekend) — not unit-tested; depends onIntl.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.tspath) — not unit-tested; requires native ChatApi. Integration-test only. IncludesdeleteInviteLink(profileMutex +apiSetActiveUserbeforeapiDeleteGroupLink), the conditionalapiUpdateGroupProfile(comparefullGroupPreferencesbefore calling), and the best-effortapiCreateGroupLink(catch + log on SMP relay failure). 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.