plans: Update 20260207-support-bot-implementation.md

This commit is contained in:
Narasimha-sc
2026-02-10 11:34:48 +02:00
committed by shum
parent 891658d57e
commit bcaa2add9c

View File

@@ -1,301 +1,362 @@
# SimpleX Support Bot — Implementation Plan
## Context
## 1. Executive Summary
SimpleX Chat needs a support bot that handles customer inquiries via business chat, optionally routes to Grok AI, and escalates to human team members. The bot implements the product spec in `apps/multiplatform/plans/20260207-support-bot.md`. Codebase at `apps/simplex-chat-support-bot/`.
SimpleX Chat support bot — standalone Node.js app using `simplex-chat-nodejs` native NAPI binding. Two `ChatApi` instances (main bot + Grok agent identity) in one process, each with own SQLite database. No external CLI processes. Implements 4-step flow: Welcome → TeamQueue → GrokMode/TeamPending → TeamLocked.
## Table of Contents
1. [Architecture](#1-architecture)
2. [Project Structure](#2-project-structure)
3. [Configuration](#3-configuration)
4. [State Machine](#4-state-machine)
5. [Two-ChatClient Coordination](#5-two-chatclient-coordination)
6. [Bot Initialization](#6-bot-initialization)
7. [Event Processing](#7-event-processing)
8. [Message Routing](#8-message-routing)
9. [Team Forwarding](#9-team-forwarding)
10. [Grok API Integration](#10-grok-api-integration)
11. [One-Way Gate Logic](#11-one-way-gate-logic)
12. [Bot Commands Setup](#12-bot-commands-setup)
13. [Message Templates](#13-message-templates)
14. [Error Handling](#14-error-handling)
15. [Implementation Sequence](#15-implementation-sequence)
16. [Verification](#16-verification)
---
## 1. Architecture
## 2. Architecture
```
┌──────────────────────────────────────────────┐
Support Bot Process (TS) │
│ │
│ mainClient ──WS──> simplex-chat CLI (5225) │ Business address, event loop
grokClient ──WS──> simplex-chat CLI (5226) │ Grok identity, auto-join groups
conversations: Map<groupId, State>
│ grokGroupMap: Map<mainGroupId, grokGroupId>
GrokApiClient → api.x.ai/v1/chat/completions
└──────────────────────────────────────────────┘
┌─────────────────────────────────────────────────
│ Support Bot Process (Node.js) │
│ mainChat: ChatApi ← ChatApi.init("./data/bot") │
• Business address, event routing, state mgmt │
• DB: data/bot_chat.db + data/bot_agent.db
│ grokChat: ChatApi ← ChatApi.init("./data/grok")
• Grok identity, auto-joins groups
│ • DB: data/grok_chat.db + data/grok_agent.db │
│ │
│ conversations: Map<groupId, ConversationState> │
│ grokGroupMap: Map<mainGroupId, grokGroupId> │
│ GrokApiClient → api.x.ai/v1/chat/completions │
└─────────────────────────────────────────────────┘
```
Two `simplex-chat` CLI servers must run externally:
- CLI 1 (port 5225): Main bot profile with business address
- CLI 2 (port 5226): Grok agent profile (pre-configured as contact of main bot)
- Single Node.js process, no external dependencies except Grok API
- Two `ChatApi` instances via native NAPI — each embeds simplex-chat core
- Business address auto-accept creates a group per customer (business chat = special group)
- Grok agent is a separate identity that gets invited as group member, making Grok appear as a separate participant per spec
- Cross-instance group ID correlation via protocol-level `memberId` (string, same across both databases)
The bot connects to both via WebSocket using the `simplex-chat` npm package.
## 2. Project Structure
## 3. Project Structure
```
apps/simplex-chat-support-bot/
├── package.json # deps: simplex-chat, @simplex-chat/types
├── tsconfig.json # ES2022, Node module resolution
├── tsconfig.json # ES2022, strict, Node16 module resolution
├── src/
│ ├── index.ts # Entry: parse args, create clients, run
│ ├── config.ts # CLI arg parsing, env vars, Config type
│ ├── bot.ts # SupportBot class: event loop, dispatch
│ ├── state.ts # ConversationState union, transitions
│ ├── grok.ts # Grok xAI API client, system prompt, history
│ ├── messages.ts # All user-facing message templates
│ └── util.ts # extractText, isWeekend, logging
├── docs/
│ └── simplex-context.md # Curated SimpleX docs injected into Grok prompt
│ ├── index.ts # Entry: parse config, init instances, run
│ ├── config.ts # CLI arg parsing, ID:name validation, Config type
│ ├── bot.ts # SupportBot class: state mgmt, event dispatch, routing
│ ├── state.ts # ConversationState union type
│ ├── grok.ts # GrokApiClient: xAI API wrapper, system prompt, history
│ ├── messages.ts # All user-facing message templates (verbatim from spec)
│ └── util.ts # isWeekend, logging helpers
├── data/ # SQLite databases (created at runtime)
└── docs/
└── simplex-context.md # Curated SimpleX docs injected into Grok system prompt
```
## 3. Configuration
## 4. Configuration — ID:name Format
All entity references use `ID:name` format. The bot validates each pair at startup against live data from `apiListContacts()` / `apiListGroups()`.
**CLI args:**
| Arg | Required | Default | Purpose |
|-----|----------|---------|---------|
| `--main-port` | No | 5225 | Main bot CLI WebSocket port |
| `--grok-port` | No | 5226 | Grok agent CLI WebSocket port |
| `--team-group` | Yes | — | GroupId for team message forwarding |
| `--team-members` | Yes | — | Comma-separated contactIds of team members |
| `--grok-contact-id` | Yes | — | ContactId of Grok agent in main bot's contacts |
| `--group-links` | No | "" | Public group link(s) for welcome message |
| `--timezone` | No | "UTC" | IANA timezone for weekend detection |
| Arg | Required | Default | Format | Purpose |
|-----|----------|---------|--------|---------|
| `--db-prefix` | No | `./data/bot` | path | Main bot database file prefix |
| `--grok-db-prefix` | No | `./data/grok` | path | Grok agent database file prefix |
| `--team-group` | Yes | — | `ID:name` | Group for forwarding customer messages to team |
| `--team-members` | Yes | — | `ID:name,...` | Comma-separated team member contacts |
| `--grok-contact` | Yes* | — | `ID:name` | Grok agent's contactId in main bot's database |
| `--group-links` | No | `""` | string | Public group link(s) for welcome message |
| `--timezone` | No | `"UTC"` | IANA tz | For weekend detection (24h vs 48h) |
| `--first-run` | No | false | flag | Auto-establish contact between bot and Grok agent |
*`--grok-contact` required unless `--first-run` is used.
**Env vars:** `GROK_API_KEY` (required) — xAI API key.
```typescript
interface Config {
mainPort: number
grokPort: number
teamGroupId: number
teamMemberContactIds: number[]
grokContactId: number
dbPrefix: string
grokDbPrefix: string
teamGroup: {id: number; name: string}
teamMembers: {id: number; name: string}[]
grokContact: {id: number; name: string} | null // null during first-run
groupLinks: string
timezone: string
grokApiKey: string
firstRun: boolean
}
```
Parse via `process.argv` iteration (no deps needed). Fail fast on missing required args.
**ID:name parsing:**
```typescript
function parseIdName(s: string): {id: number; name: string} {
const i = s.indexOf(":")
if (i < 1) throw new Error(`Invalid ID:name format: "${s}"`)
return {id: parseInt(s.slice(0, i), 10), name: s.slice(i + 1)}
}
```
## 4. State Machine
**Startup validation** (exact API calls):
Keyed by `groupId` of business chat group. In-memory only (acceptable for MVP — restart resets conversations; team group retains forwarded messages).
| What | API Call | Validation |
|------|----------|------------|
| Team group | `mainChat.apiListGroups(userId)` → find by `groupId === config.teamGroup.id` | Assert `groupProfile.displayName === config.teamGroup.name` |
| Team members | `mainChat.apiListContacts(userId)` → find each by `contactId` | Assert `profile.displayName === member.name` for each |
| Grok contact | `mainChat.apiListContacts(userId)` → find by `contactId === config.grokContact.id` | Assert `profile.displayName === config.grokContact.name` |
Fail-fast with descriptive error on any mismatch.
## 5. State Machine
Keyed by `groupId` of business chat group. In-memory (restart resets; team group retains forwarded messages).
```typescript
type ConversationState =
| { type: "welcome" }
| { type: "teamQueue"; userMessages: string[] } // tracks msgs for Grok context
| { type: "grokMode"; grokMemberGId: number; history: GrokMessage[] }
| { type: "teamPending"; teamMemberGId: number; grokMemberGId?: number; history?: GrokMessage[] }
| { type: "teamLocked"; teamMemberGId: number }
| {type: "welcome"}
| {type: "teamQueue"; userMessages: string[]}
| {type: "grokMode"; grokMemberGId: number; history: GrokMessage[]}
| {type: "teamPending"; teamMemberGId: number; grokMemberGId?: number; history?: GrokMessage[]}
| {type: "teamLocked"; teamMemberGId: number}
```
**Key**: `teamQueue.userMessages` accumulates user messages so they can be forwarded to Grok as initial context on `/grok` activation (spec: "Your message(s) have been forwarded").
`teamQueue.userMessages` accumulates user messages for Grok initial context on `/grok` activation.
Transitions:
**Transitions:**
```
welcome ──(1st msg)──> teamQueue (msg stored in userMessages)
teamQueue ──(more msgs)──> teamQueue (appended to userMessages)
welcome ──(1st user msg)──> teamQueue
teamQueue ──(user msg)──> teamQueue (append to userMessages)
teamQueue ──(/grok)──> grokMode (userMessages → initial Grok history)
teamQueue ──(/team)──> teamPending
grokMode ──(/team)──> teamPending (carries grokMemberGId + history)
grokMode ──(msg)──> grokMode (history appended)
teamPending ──(team member msg)──> teamLocked (grok removed)
teamPending ──(/grok, grok present)──> still teamPending (grok answers)
grokMode ──(user msg)──> grokMode (forward to Grok API, append to history)
grokMode ──(/team)──> teamPending (carry grokMemberGId + history)
teamPending ──(team member msg)──> teamLocked (remove Grok if present)
teamPending ──(/grok, grok present)──> teamPending (forward to Grok, still usable)
teamPending ──(/grok, no grok)──> reply "team mode"
teamLocked ──(/grok)──> reply "team mode", stay locked
teamLocked ──(any)──> no action (team sees directly)
```
## 5. Two-ChatClient Coordination
## 6. Two-Instance Coordination
**Problem**: When main bot invites Grok agent to a business chat group, the Grok agent's local `groupId` differs from the main bot's `groupId` (different databases).
**Problem:** When main bot invites Grok agent to a business group, Grok agent's local `groupId` differs (different databases).
**Solution**: Shared in-process maps correlated via `memberId` (protocol-level, same across both databases).
**Solution:** In-process maps correlated via protocol-level `memberId` (string, same across databases).
```typescript
const pendingGrokJoins = new Map<string, number>() // memberId → mainGroupId
const grokGroupMap = new Map<number, number>() // mainGroupId → grokGroupId
const grokGroupMap = new Map<number, number>() // mainGroupId → grokLocalGroupId
const reverseGrokMap = new Map<number, number>() // grokLocalGroupId → mainGroupId
```
Flow:
1. Main bot: `apiAddMember(mainGroupId, grokContactId, "member")` → response has `member.memberId`
**Flow:**
1. Main bot: `mainChat.apiAddMember(mainGroupId, grokContactId, "member")` → response `member.memberId`
2. Store: `pendingGrokJoins.set(member.memberId, mainGroupId)`
3. Grok agent event loop: `receivedGroupInvitation``evt.groupInfo.membership.memberId` matches → `apiJoinGroup(evt.groupInfo.groupId)` → store mapping
4. Send Grok response: `grokClient.apiSendTextMessage(ChatType.Group, grokGroupMap.get(mainGroupId), text)`
3. Grok agent receives `receivedGroupInvitation` event`evt.groupInfo.membership.memberId` matches → `grokChat.apiJoinGroup(evt.groupInfo.groupId)` → store bidirectional mapping
4. Send Grok response: `grokChat.apiSendTextMessage([T.ChatType.Group, grokGroupMap.get(mainGroupId)!], text)`
**Grok agent event loop** (minimal, in same process):
**Grok agent event subscriptions:**
```typescript
async function runGrokAgentLoop(grokClient: ChatClient): Promise<void> {
for await (const r of grokClient.msgQ) {
const evt = r instanceof Promise ? await r : r
if (evt.type === "receivedGroupInvitation") {
const memberId = evt.groupInfo.membership.memberId
const mainGroupId = pendingGrokJoins.get(memberId)
if (mainGroupId !== undefined) {
pendingGrokJoins.delete(memberId)
const grokLocalGroupId = evt.groupInfo.groupId
grokGroupMap.set(mainGroupId, grokLocalGroupId)
await grokClient.apiJoinGroup(grokLocalGroupId)
}
}
// Ignore all other events
grokChat.on("receivedGroupInvitation", async ({groupInfo}) => {
const memberId = groupInfo.membership.memberId
const mainGroupId = pendingGrokJoins.get(memberId)
if (mainGroupId !== undefined) {
pendingGrokJoins.delete(memberId)
grokGroupMap.set(mainGroupId, groupInfo.groupId)
reverseGrokMap.set(groupInfo.groupId, mainGroupId)
await grokChat.apiJoinGroup(groupInfo.groupId)
}
})
```
## 7. Bot Initialization
**Main bot** uses `bot.run()` for setup automation (address, profile, commands), with only `events` parameter for full routing control:
```typescript
let supportBot: SupportBot // set after bot.run returns
const [mainChat, mainUser, mainAddress] = await bot.run({
profile: {displayName: "SimpleX Support", fullName: ""},
dbOpts: {dbFilePrefix: config.dbPrefix},
options: {
addressSettings: {
businessAddress: true,
autoAccept: true,
welcomeMessage: welcomeMessage(config.groupLinks),
},
commands: [
{type: "command", keyword: "grok", label: "Ask Grok AI"},
{type: "command", keyword: "team", label: "Switch to team"},
],
useBotProfile: true,
},
events: {
acceptingBusinessRequest: (evt) => supportBot?.onBusinessRequest(evt),
newChatItems: (evt) => supportBot?.onNewChatItems(evt),
leftMember: (evt) => supportBot?.onLeftMember(evt),
deletedMemberUser: (evt) => supportBot?.onDeletedMemberUser(evt),
groupDeleted: (evt) => supportBot?.onGroupDeleted(evt),
connectedToGroupMember: (evt) => supportBot?.onMemberConnected(evt),
},
})
```
**Grok agent** uses direct ChatApi:
```typescript
const grokChat = await ChatApi.init(config.grokDbPrefix)
let grokUser = await grokChat.apiGetActiveUser()
if (!grokUser) grokUser = await grokChat.apiCreateActiveUser({displayName: "Grok AI", fullName: ""})
await grokChat.startChat()
// Subscribe Grok event handlers (receivedGroupInvitation)
```
**First-run mode** (`--first-run`):
1. Both instances init and create users
2. Main bot: `mainChat.apiCreateLink(mainUser.userId)` → invitation link
3. Grok agent: `grokChat.apiConnectActiveUser(invLink)`
4. Main bot: `mainChat.wait("contactConnected", 60000)` — wait for connection
5. Print: "Grok contact established. ContactId=X. Use: --grok-contact X:GrokAI"
6. Exit (user restarts without `--first-run`)
**Startup validation** (after init, before event loop):
1. `mainChat.apiListContacts(mainUser.userId)` → validate `--team-members` and `--grok-contact` ID:name pairs
2. `mainChat.apiListGroups(mainUser.userId)` → validate `--team-group` ID:name pair
## 8. Event Processing
**Main bot event handlers:**
| Event | Handler | Action |
|-------|---------|--------|
| `acceptingBusinessRequest` | `onBusinessRequest` | `conversations.set(groupInfo.groupId, {type: "welcome"})` |
| `newChatItems` | `onNewChatItems` | For each chatItem: identify sender, extract text, dispatch to routing |
| `leftMember` | `onLeftMember` | If customer left → delete state. If team member left → revert to teamQueue. If Grok left → clear grokMemberGId. |
| `deletedMemberUser` | `onDeletedMemberUser` | Bot removed from group → delete state |
| `groupDeleted` | `onGroupDeleted` | Delete state, delete grokGroupMap entry |
| `connectedToGroupMember` | `onMemberConnected` | Log for debugging |
**Sender identification in `newChatItems`:**
```typescript
for (const ci of evt.chatItems) {
const {chatInfo, chatItem} = ci
if (chatInfo.type !== "group") continue
const groupInfo = chatInfo.groupInfo
if (!groupInfo.businessChat) continue // only process business chats
const groupId = groupInfo.groupId
const state = conversations.get(groupId)
if (!state) continue
if (chatItem.chatDir.type === "groupSnd") continue // our own message
if (chatItem.chatDir.type !== "groupRcv") continue
const sender = chatItem.chatDir.groupMember
const isCustomer = sender.memberId === groupInfo.businessChat.customerId
const isTeamMember = state.type === "teamPending" || state.type === "teamLocked"
? sender.groupMemberId === state.teamMemberGId
: false
const isGrok = (state.type === "grokMode" || state.type === "teamPending")
&& state.grokMemberGId === sender.groupMemberId
if (isGrok) continue // skip Grok messages (we sent them via grokChat)
if (isCustomer) onCustomerMessage(groupId, groupInfo, chatItem, state)
else if (isTeamMember) onTeamMemberMessage(groupId, state)
}
```
## 6. Bot Initialization
In `index.ts`:
1. Parse config
2. Connect both ChatClients (`ChatClient.create("ws://localhost:PORT")`)
3. Verify both have active user profiles
4. Register bot commands (`/grok`, `/team`) and set `peerType: "bot"` via `apiUpdateProfile`
5. Ensure main bot has business address with auto-accept and welcome auto-reply
6. Start Grok agent event loop (background, same process)
7. Start main bot event loop
Business address setup:
**Text extraction:**
```typescript
await mainClient.enableAddressAutoAccept(
mainUser.userId,
{ type: "text", text: welcomeMessage(config.groupLinks) },
true // businessAddress = true
)
```
The auto-reply handles Step 1 (Welcome) automatically on connect.
## 7. Event Processing
Main event loop in `SupportBot.run()` iterates `mainClient.msgQ`:
| Event | Action |
|-------|--------|
| `acceptingBusinessRequest` | Initialize conversation state as `welcome` |
| `newChatItems` (group, business, customer rcv) | Dispatch to `onCustomerMessage` |
| `newChatItems` (group, business, team member rcv) | Dispatch to `onTeamMemberMessage` |
| `leftMember` / `deletedMemberUser` / `groupDeleted` | Clean up conversation state |
| Everything else | Ignore |
**Identifying senders in `newChatItems`:**
- `chatItem.chatDir.type === "groupRcv"` → received from `chatItem.chatDir.groupMember`
- `chatItem.chatDir.type === "groupSnd"` → our own message (skip)
- Customer: `chatDir.groupMember.memberId === groupInfo.businessChat.customerId`
- Team member: `chatDir.groupMember.groupMemberId === state.teamMemberGId`
- Grok agent: `chatDir.groupMember.groupMemberId === state.grokMemberGId` (skip — we sent it via grokClient)
**Extracting text:**
```typescript
function extractText(content: CIContent): string | null {
if (content.type === "rcvMsgContent" || content.type === "sndMsgContent") {
const mc = content.msgContent
if (mc.type === "text" || mc.type === "link" || mc.type === "file") return mc.text
}
return null
function extractText(chatItem: T.ChatItem): string | null {
const text = util.ciContentText(chatItem)
return text?.trim() || null
}
```
## 8. Message Routing
## 9. Message Routing Table
`onCustomerMessage(groupId, groupInfo, text, state)`:
`onCustomerMessage(groupId, groupInfo, chatItem, state)`:
| State | `/grok` | `/team` | Other text |
|-------|---------|---------|------------|
| `welcome` | — | — | Forward to team, reply with queue msg → `teamQueue` (store msg) |
| `teamQueue` | Activate Grok (with accumulated msgs) `grokMode` | Activate team → `teamPending` | Forward to team, append to `userMessages` |
| `grokMode` | (ignored, already grok) | Activate team → `teamPending` | Forward to Grok API + team |
| `teamPending` (grok present) | Forward to Grok | (ignored, already team) | No forwarding (team sees directly) |
| `teamPending` (no grok) | Reply "team mode" | — | No forwarding (team sees directly) |
| `teamLocked` | Reply "team mode" | — | No forwarding needed (team sees directly) |
| State | Input | Actions | API Calls | Next State |
|-------|-------|---------|-----------|------------|
| `welcome` | any text | Forward to team, send queue reply | `mainChat.apiSendTextMessage([Group, teamGroupId], fwd)` + `mainChat.apiSendTextMessage([Group, groupId], queueMsg)` | `teamQueue` (store msg) |
| `teamQueue` | `/grok` | Activate Grok (invite, wait join, send accumulated msgs to Grok API, relay response) | `mainChat.apiAddMember(groupId, grokContactId, "member")` + `mainChat.apiSendTextMessage([Group, groupId], grokActivatedMsg)` + wait for join + `grokChat.apiSendTextMessage([Group, grokLocalGId], grokResponse)` | `grokMode` |
| `teamQueue` | `/team` | Add team member | `mainChat.apiAddMember(groupId, teamContactId, "member")` + `mainChat.apiSendTextMessage([Group, groupId], teamAddedMsg)` | `teamPending` |
| `teamQueue` | other text | Forward to team, append to userMessages | `mainChat.apiSendTextMessage([Group, teamGroupId], fwd)` | `teamQueue` |
| `grokMode` | `/grok` | Ignore (already in grok mode) | — | `grokMode` |
| `grokMode` | `/team` | Add team member (keep Grok for now) | `mainChat.apiAddMember(groupId, teamContactId, "member")` + `mainChat.apiSendTextMessage([Group, groupId], teamAddedMsg)` | `teamPending` (carry grokMemberGId + history) |
| `grokMode` | other text | Forward to Grok API + team | Grok API call + `grokChat.apiSendTextMessage([Group, grokLocalGId], response)` + `mainChat.apiSendTextMessage([Group, teamGroupId], fwd)` | `grokMode` (append history) |
| `teamPending` (grok present) | `/grok` | Forward to Grok (still usable) | Grok API call + `grokChat.apiSendTextMessage(...)` | `teamPending` |
| `teamPending` (no grok) | `/grok` | Reply "team mode" | `mainChat.apiSendTextMessage([Group, groupId], teamLockedMsg)` | `teamPending` |
| `teamPending` | `/team` | Ignore (already team) | — | `teamPending` |
| `teamPending` | other text | No forwarding (team sees directly in group) | — | `teamPending` |
| `teamLocked` | `/grok` | Reply "team mode" | `mainChat.apiSendTextMessage([Group, groupId], teamLockedMsg)` | `teamLocked` |
| `teamLocked` | `/team` | Ignore | — | `teamLocked` |
| `teamLocked` | other text | No action (team sees directly) | — | `teamLocked` |
## 9. Team Forwarding
## 10. Team Forwarding
Forward messages to the designated team group:
```typescript
async forwardToTeam(groupId: number, groupInfo: GroupInfo, text: string): Promise<void> {
const customerName = groupInfo.groupProfile.displayName || `group-${groupId}`
const fwd = `[${customerName} #${groupId}]\n${text}`
await this.mainClient.apiSendTextMessage(ChatType.Group, this.config.teamGroupId, fwd)
async forwardToTeam(groupId: number, groupInfo: T.GroupInfo, text: string): Promise<void> {
const name = groupInfo.groupProfile.displayName || `group-${groupId}`
const fwd = `[${name} #${groupId}]\n${text}`
await this.mainChat.apiSendTextMessage(
[T.ChatType.Group, this.config.teamGroup.id],
fwd
)
}
```
Adding team member to business chat:
```typescript
async activateTeam(groupId: number, currentState: ConversationState): Promise<void> {
const teamContactId = this.config.teamMemberContactIds[0]
const member = await this.mainClient.apiAddMember(groupId, teamContactId, "member")
async activateTeam(groupId: number, state: ConversationState): Promise<void> {
const teamContactId = this.config.teamMembers[0].id // round-robin or first available
const member = await this.mainChat.apiAddMember(groupId, teamContactId, "member")
this.conversations.set(groupId, {
type: "teamPending",
teamMemberGId: member.groupMemberId,
grokMemberGId: currentState.type === "grokMode" ? currentState.grokMemberGId : undefined,
history: currentState.type === "grokMode" ? currentState.history : undefined,
grokMemberGId: state.type === "grokMode" ? state.grokMemberGId : undefined,
history: state.type === "grokMode" ? state.history : undefined,
})
await this.sendGroupMessage(groupId, teamAddedMessage(this.config.timezone))
await this.mainChat.apiSendTextMessage(
[T.ChatType.Group, groupId],
teamAddedMessage(this.config.timezone)
)
}
```
## 10. Grok API Integration
**`grok.ts`** wraps xAI's OpenAI-compatible API:
- Endpoint: `https://api.x.ai/v1/chat/completions`
- Model: `grok-3`
- System prompt: "Privacy expert and SimpleX Chat evangelist" + curated docs from `docs/simplex-context.md`
- Per-conversation history: last 20 messages (trim from front)
- API key from `GROK_API_KEY` env var
## 11. Grok API Integration
```typescript
class GrokApiClient {
private docsContext: string // loaded from file at startup
constructor(private apiKey: string, private docsContext: string) {}
async chat(history: GrokMessage[], userMessage: string): Promise<string> {
const messages = [
{ role: "system", content: this.systemPrompt() },
{role: "system", content: this.systemPrompt()},
...history.slice(-20),
{ role: "user", content: userMessage },
{role: "user", content: userMessage},
]
const resp = await fetch("https://api.x.ai/v1/chat/completions", {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${this.apiKey}` },
body: JSON.stringify({ model: "grok-3", messages, max_tokens: 2048 }),
headers: {"Content-Type": "application/json", Authorization: `Bearer ${this.apiKey}`},
body: JSON.stringify({model: "grok-3", messages, max_tokens: 2048}),
})
if (!resp.ok) throw new Error(`Grok API: ${resp.status}`)
if (!resp.ok) throw new Error(`Grok API ${resp.status}: ${await resp.text()}`)
const data = await resp.json()
return data.choices[0].message.content
}
private systemPrompt(): string {
return `You are a privacy expert and SimpleX Chat evangelist...\n\n${this.docsContext}`
}
}
```
**Activating Grok:**
1. `apiAddMember(groupId, grokContactId, "member")` — invites Grok to business chat
2. Store `pendingGrokJoins` mapping
3. Wait for Grok agent to join (poll `grokGroupMap` with timeout)
4. Build initial Grok history from `state.userMessages` (accumulated in teamQueue)
5. Forward all accumulated messages to Grok API in a single call
6. Send bot activation message first, then Grok response via `grokClient.apiSendTextMessage`
7. State transitions to `grokMode`
**Activating Grok** (on `/grok` in teamQueue):
1. `mainChat.apiAddMember(groupId, grokContactId, "member")` → stores `pendingGrokJoins.set(member.memberId, groupId)`
2. Send bot activation message: `mainChat.apiSendTextMessage([Group, groupId], grokActivatedMsg)`
3. Wait for Grok join: poll `grokGroupMap.has(groupId)` with 30s timeout (or use `mainChat.wait("connectedToGroupMember", pred, 30000)`)
4. Build initial Grok history from `state.userMessages`
5. Call Grok API with accumulated messages
6. Send response via Grok identity: `grokChat.apiSendTextMessage([Group, grokGroupMap.get(groupId)!], response)`
7. Transition to `grokMode` with history
**Fallback**: If Grok API fails, send error message and revert to teamQueue state.
**Fallback:** If Grok API fails send error message via `mainChat.apiSendTextMessage`, keep accumulated messages, stay in `teamQueue`.
## 11. One-Way Gate Logic
Per spec: "/grok permanently disabled ONLY after team joins AND team member sends a message."
## 12. One-Way Gate Logic
```typescript
async onTeamMemberMessage(groupId: number, state: ConversationState): Promise<void> {
@@ -303,110 +364,187 @@ async onTeamMemberMessage(groupId: number, state: ConversationState): Promise<vo
// Remove Grok if present
if (state.grokMemberGId) {
try { await this.mainClient.apiRemoveMembers(groupId, [state.grokMemberGId]) } catch {}
try { await this.mainChat.apiRemoveMembers(groupId, [state.grokMemberGId]) } catch {}
grokGroupMap.delete(groupId)
reverseGrokMap.delete(/* grokLocalGroupId */)
}
// Lock: /grok permanently disabled
this.conversations.set(groupId, { type: "teamLocked", teamMemberGId: state.teamMemberGId })
this.conversations.set(groupId, {type: "teamLocked", teamMemberGId: state.teamMemberGId})
}
```
Timeline:
1. User sends `/team`bot adds team member → state = `teamPending` (Grok still usable if present)
2. Team member sends a message → state = `teamLocked`, Grok removed
3. Any subsequent `/grok` → "You are now in team mode. A team member will reply to your message."
Timeline per spec:
1. User sends `/team``apiAddMember` → state = `teamPending` (Grok still usable if present)
2. Team member sends message → `onTeamMemberMessage` → state = `teamLocked`, Grok removed via `apiRemoveMembers`
3. Any `/grok` reply "You are now in team mode. A team member will reply to your message."
## 12. Bot Commands Setup
Register `/grok` and `/team` via profile update:
## 13. Message Templates (verbatim from spec)
```typescript
const botCommands: T.ChatBotCommand[] = [
{ type: "command", keyword: "grok", label: "Ask Grok AI" },
{ type: "command", keyword: "team", label: "Switch to team" },
]
await mainClient.apiUpdateProfile(mainUser.userId, {
displayName, fullName, shortDescr, image, contactLink,
peerType: T.ChatPeerType.Bot,
preferences: { ...preferences, commands: botCommands },
})
// Welcome (auto-reply via business address)
function welcomeMessage(groupLinks: string): string {
return `Hello! Feel free to ask any question about SimpleX Chat.\n*Only SimpleX Chat team has access to your messages.* This is a SimpleX Chat team bot — it is not any LLM or AI.${groupLinks ? `\n*Join public groups*: ${groupLinks}` : ""}\nPlease send questions in English, you can use translator.`
}
// After first message (teamQueue)
function teamQueueMessage(timezone: string): string {
const hours = isWeekend(timezone) ? "48" : "24"
return `Thank you for your message, it is forwarded to the team.\nIt may take a team member up to ${hours} hours to reply.\n\nClick /grok if your question is about SimpleX apps or network, is not sensitive, and you want Grok LLM to answer it right away. *Your previous message and all subsequent messages will be forwarded to Grok* until you click /team. You can ask Grok questions in any language and it will not see your profile name.\n\nWe appreciate if you try Grok: you can learn a lot about SimpleX Chat from it. It is objective, answers the way our team would, and it saves our team time.`
}
// Grok activated
const grokActivatedMessage = `*You are now chatting with Grok. You can send questions in any language.* Your message(s) have been forwarded.\nSend /team at any time to switch to a human team member.`
// Team added
function teamAddedMessage(timezone: string): string {
const hours = isWeekend(timezone) ? "48" : "24"
return `A team member has been added and will reply within ${hours} hours. You can keep describing your issue — they will see the full conversation.`
}
// Team mode locked
const teamLockedMessage = "You are now in team mode. A team member will reply to your message."
```
## 13. Message Templates
**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"
}
```
All in `messages.ts`, verbatim from spec:
## 14. Complete API Call Map (100% Coverage)
- **Welcome** (auto-reply): "Hello! Feel free to ask any question about SimpleX Chat..."
- **Team queue** (after 1st msg): "Thank you for your message, it is forwarded to the team. It may take a team member up to {24/48} hours to reply. Click /grok if..."
- **Grok activated**: "*You are now chatting with Grok...*"
- **Team added**: "A team member has been added and will reply within {24/48} hours..."
- **Team mode locked**: "You are now in team mode. A team member will reply to your message."
| # | Operation | When | ChatApi Instance | Method | Parameters | Response Type | Error Handling |
|---|-----------|------|-----------------|--------|------------|---------------|----------------|
| 1 | Init main bot | Startup | mainChat | `bot.run()` (wraps `ChatApi.init`) | dbFilePrefix, profile, addressSettings | `[ChatApi, User, UserContactLink]` | Exit on failure |
| 2 | Init Grok agent | Startup | grokChat | `ChatApi.init(grokDbPrefix)` | dbFilePrefix | `ChatApi` | Exit on failure |
| 3 | Get/create Grok user | Startup | grokChat | `apiGetActiveUser()` / `apiCreateActiveUser(profile)` | profile: {displayName: "Grok AI"} | `User` | Exit on failure |
| 4 | Start Grok chat | Startup | grokChat | `startChat()` | — | void | Exit on failure |
| 5 | Validate team group | Startup | mainChat | `apiListGroups(userId)` | userId | `GroupInfo[]` | Exit if ID:name mismatch |
| 6 | Validate contacts | Startup | mainChat | `apiListContacts(userId)` | userId | `Contact[]` | Exit if ID:name mismatch |
| 7 | First-run: create link | First-run | mainChat | `apiCreateLink(userId)` | userId | `string` (invitation link) | Exit on failure |
| 8 | First-run: connect | First-run | grokChat | `apiConnectActiveUser(invLink)` | connLink | `ConnReqType` | Exit on failure |
| 9 | First-run: wait | First-run | mainChat | `wait("contactConnected", 60000)` | event, timeout | `ChatEvent \| undefined` | Exit on timeout |
| 10 | Send msg to customer | Various | mainChat | `apiSendTextMessage([Group, groupId], text)` | chat, text | `AChatItem[]` | Log error |
| 11 | Forward to team | welcome→teamQueue, teamQueue msg | mainChat | `apiSendTextMessage([Group, teamGroupId], fwd)` | chat, formatted text | `AChatItem[]` | Log error |
| 12 | Invite Grok to group | /grok in teamQueue | mainChat | `apiAddMember(groupId, grokContactId, "member")` | groupId, contactId, role | `GroupMember` | Send error msg, stay in teamQueue |
| 13 | Grok joins group | receivedGroupInvitation | grokChat | `apiJoinGroup(groupId)` | groupId | `GroupInfo` | Log error |
| 14 | Grok sends response | After Grok API reply | grokChat | `apiSendTextMessage([Group, grokLocalGId], text)` | chat, text | `AChatItem[]` | Send error msg via mainChat |
| 15 | Invite team member | /team | mainChat | `apiAddMember(groupId, teamContactId, "member")` | groupId, contactId, role | `GroupMember` | Send error msg to customer |
| 16 | Remove Grok | Team member msg in teamPending | mainChat | `apiRemoveMembers(groupId, [grokMemberGId])` | groupId, memberIds | `GroupMember[]` | Ignore (may have left) |
| 17 | Update bot profile | Startup (via bot.run) | mainChat | `apiUpdateProfile(userId, profile)` | userId, profile with peerType+commands | `UserProfileUpdateSummary` | Log warning |
| 18 | Set address settings | Startup (via bot.run) | mainChat | `apiSetAddressSettings(userId, settings)` | userId, {businessAddress, autoAccept, welcomeMessage} | void | Exit on failure |
Weekend detection: `Intl.DateTimeFormat("en-US", { timeZone, weekday: "short" })` → "Sat"/"Sun".
## 14. Error Handling
## 15. Error Handling
| Scenario | Handling |
|----------|----------|
| CLI disconnection | Event loop exits → log, process exits (let process manager restart) |
| Grok API error | Catch, send "Grok temporarily unavailable", revert to teamQueue |
| `apiAddMember` fails | Catch, send error message to user |
| `apiRemoveMembers` fails | Ignore (member may have left already) |
| Grok join timeout (30s) | Send "Grok unavailable", stay in current state |
| Customer leaves group | Clean up conversation state on `leftMember` event |
| Group deleted | Clean up on `groupDeleted` event |
| Grok leaves during teamPending | Clear Grok reference, keep teamPending |
| Team member leaves | Revert to teamQueue |
| ChatApi init fails | Log error, exit (let process manager restart) |
| Grok API error (HTTP/timeout) | `mainChat.apiSendTextMessage` "Grok temporarily unavailable", revert to `teamQueue` |
| `apiAddMember` fails (Grok) | `mainChat.apiSendTextMessage` error msg, stay in `teamQueue` |
| `apiAddMember` fails (team) | `mainChat.apiSendTextMessage` error msg, stay in current state |
| `apiRemoveMembers` fails | Catch and ignore (member may have left) |
| Grok join timeout (30s) | `mainChat.apiSendTextMessage` "Grok unavailable", stay in `teamQueue` |
| Customer leaves (`leftMember` where member is customer) | Delete conversation state, delete grokGroupMap entry |
| Group deleted | Delete conversation state, delete grokGroupMap entry |
| Grok leaves during `teamPending` | Clear `grokMemberGId` from state, keep `teamPending` |
| Team member leaves | Revert to `teamQueue` (accumulate messages again) |
| Bot removed from group (`deletedMemberUser`) | Delete conversation state |
| Grok agent connection lost | Log error; Grok features unavailable until restart |
| `apiSendTextMessage` fails | Log error, continue (message lost but bot stays alive) |
| Config validation fails | Print descriptive error with actual vs expected name, exit |
## 15. Implementation Sequence
## 16. Implementation Sequence
**Phase 1: Scaffold**`package.json`, `tsconfig.json`, `config.ts`, `index.ts`, `util.ts`
- Create project, install deps, implement config parsing
- Connect both ChatClients, verify user profiles, set up business address
- Verify: both clients connect and print profiles
**Phase 1: Scaffold**
- Create project: `package.json`, `tsconfig.json`
- Implement `config.ts`: CLI arg parsing, ID:name format, `Config` type
- Implement `index.ts`: init both ChatApi instances, verify profiles
- Implement `util.ts`: `isWeekend`, logging
- **Verify:** Both instances init, print user profiles, validate config
**Phase 2: State machine + event loop**`state.ts`, `bot.ts`, `messages.ts`
- Define `ConversationState` type
- Implement `SupportBot` class with event loop
- Handle `acceptingBusinessRequest` → init state
- Handle `newChatItems` → customer message dispatch
- Implement Welcome → TeamQueue transition + team forwarding
- Verify: customer connects, sends msg, bot replies with queue info, msg forwarded
**Phase 2: State machine + event loop**
- Implement `state.ts`: `ConversationState` union type
- Implement `bot.ts`: `SupportBot` class with `conversations` map
- Handle `acceptingBusinessRequest` → init state as `welcome`
- Handle `newChatItems` sender identification → customer message dispatch
- Implement welcome → teamQueue transition + team forwarding
- Implement `messages.ts`: all templates
- **Verify:** Customer connects → welcome auto-reply → sends msg → forwarded to team group → queue reply received
**Phase 3: Grok integration**`grok.ts`, updates to `bot.ts`
- Implement `GrokApiClient` with system prompt + doc injection
- Implement Grok agent event loop (auto-join groups)
- Implement `activateGrok`: add member, ID mapping, activation message
- Implement `forwardToGrok`: API call → response via Grok client
- Verify: /grok works, user sees Grok responses under Grok profile
**Phase 3: Grok integration**
- Implement `grok.ts`: `GrokApiClient` with system prompt + docs injection
- Implement Grok agent event handler (`receivedGroupInvitation`auto-join)
- Implement `activateGrok`: add member, ID mapping, wait for join, Grok API call, send response via grokChat
- Implement `forwardToGrok`: ongoing message routing in grokMode
- **Verify:** `/grok` → Grok joins as separate participant → Grok responses appear from Grok profile
**Phase 4: Team mode + one-way gate** — updates to `bot.ts`
**Phase 4: Team mode + one-way gate**
- Implement `activateTeam`: add team member
- Implement `onTeamMemberMessage`: detect team msg → lock → remove Grok
- Implement `/grok` rejection in `teamLocked`
- Verify: full flow including gate lock
- **Verify:** Full flow including: teamQueue → /grok → grokMode → /team → teamPending → team msg → teamLocked → /grok rejected
**Phase 5: Polish** — bot commands, edge cases, docs
- Register /grok and /team as bot commands via profile update
- Handle customer leave, group delete, Grok timeout, error messages
- Write `simplex-context.md` for Grok prompt injection
- End-to-end test
**Phase 5: Polish + first-run**
- Implement `--first-run` auto-contact establishment
- Handle edge cases: customer leave, group delete, Grok timeout, member leave
- Write `docs/simplex-context.md` for Grok prompt injection
- End-to-end test all flows
## 16. Verification
## 17. Self-Review Requirement
1. **Start infrastructure**: Two `simplex-chat` CLIs on ports 5225/5226
2. **Initialize profiles**: Create "Support Bot" and "Grok" user profiles, establish contact between them
3. **Run bot**: `npx ts-node src/index.ts --team-group X --team-members Y --grok-contact-id Z`
4. **Test welcome flow**: Connect from a third SimpleX client to bot's business address → verify welcome message
5. **Test first message**: Send a question → verify forwarded to team group, queue reply received
6. **Test /grok**: Send `/grok` → verify Grok joins as separate participant, responses appear from Grok profile
7. **Test /team from grok**: Send `/team` → verify team member added, send team member message → verify /grok locked, Grok removed
8. **Test weekend**: Change timezone to a weekend timezone, verify "48 hours" in messages
**Mandatory for all implementation subagents:**
Each code artifact must undergo adversarial self-review/fix loop:
1. Write/edit code
2. Self-review against this plan: check correctness, completeness, consistency, all state transitions covered, all API calls match the plan, all error cases handled
3. Fix any issues found
4. Repeat review until **2 consecutive zero-issue passes**
5. Only then report completion
6. User reviews and provides feedback
7. If changes needed → return to step 1 (review cycle restarts)
8. Done when: 2 clean LLM passes AND user finds no issues
Any edit restarts the review cycle. Batch changes within a round.
## 18. Verification
**First-run setup:**
```bash
cd apps/simplex-chat-support-bot
npm install
npx ts-node src/index.ts --first-run --db-prefix ./data/bot --grok-db-prefix ./data/grok
# → Prints: "Grok contact established. ContactId=X. Use: --grok-contact X:GrokAI"
```
**Normal run:**
```bash
npx ts-node src/index.ts \
--team-group 1:SupportTeam \
--team-members 2:Alice,3:Bob \
--grok-contact 4:GrokAI \
--timezone America/New_York \
--group-links "https://simplex.chat/contact#..."
```
**Test scenarios:**
1. Connect from SimpleX client to bot's business address → verify welcome message
2. Send question → verify forwarded to team group with `[CustomerName #groupId]` prefix, queue reply received
3. Send `/grok` → verify Grok joins as separate participant, responses appear from "Grok AI" profile
4. Send text in grokMode → verify Grok response + forwarded to team
5. Send `/team` → verify team member added, team added message
6. Send team member message → verify Grok removed, state locked
7. Send `/grok` after lock → verify "team mode" reply
8. Test weekend: set timezone to weekend timezone → verify "48 hours" in messages
9. Customer disconnects → verify state cleanup
10. Grok API failure → verify error message, graceful fallback to teamQueue
### Critical Reference Files
- `packages/simplex-chat-client/typescript/src/client.ts` — ChatClient API
- `packages/simplex-chat-client/types/typescript/src/events.ts` — Event types (CEvt)
- `packages/simplex-chat-client/types/typescript/src/types.ts` — Domain types
- `packages/simplex-chat-client/types/typescript/src/commands.ts` — Command builders (CC)
- `packages/simplex-chat-client/typescript/examples/squaring-bot.js` — Reference pattern
- **Native library API:** `packages/simplex-chat-nodejs/src/api.ts` (ChatApi class — all methods)
- **Bot automation:** `packages/simplex-chat-nodejs/src/bot.ts` (bot.run — setup helper)
- **Utilities:** `packages/simplex-chat-nodejs/src/util.ts` (ciContentText, ciBotCommand, chatInfoRef)
- **Types:** `packages/simplex-chat-client/types/typescript/src/types.ts` (BusinessChatInfo, GroupMember, CIDirection, etc.)
- **Events:** `packages/simplex-chat-client/types/typescript/src/events.ts` (CEvt — all event types)
- **Product spec:** `apps/multiplatform/plans/20260207-support-bot.md`