Files
simplex-chat/apps/simplex-support-bot/plans/20260207-support-bot-implementation.md
T
2026-03-18 11:36:29 +00:00

38 KiB

SimpleX Support Bot — Implementation Plan

1. Executive Summary

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.

2. Architecture

┌─────────────────────────────────────────────────┐
│          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  │
│                                                  │
│  State: derived from group composition + chat DB  │
│  grokGroupMap: Map<mainGroupId, grokGroupId>     │
│  GrokApiClient → api.x.ai/v1/chat/completions   │
└─────────────────────────────────────────────────┘
  • 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)

3. Project Structure

apps/simplex-support-bot/
├── package.json          # deps: simplex-chat, @simplex-chat/types
├── tsconfig.json         # ES2022, strict, Node16 module resolution
├── src/
│   ├── index.ts          # Entry: parse config, init instances, run
│   ├── config.ts         # CLI arg parsing, ID:name validation, Config type
│   ├── bot.ts            # SupportBot class: stateless state derivation, event dispatch, routing
│   ├── state.ts          # GrokMessage type
│   ├── grok.ts           # GrokApiClient: xAI API wrapper, system prompt, history
│   ├── messages.ts       # All user-facing message templates
│   └── util.ts           # isWeekend, logging helpers
├── data/                 # SQLite databases (created at runtime)
└── docs/
    └── simplex-context.md  # Curated SimpleX docs injected into Grok system prompt

4. Configuration

All runtime state (team group ID, Grok contact ID) is auto-resolved and persisted to {dbPrefix}_state.json. No manual IDs needed for core entities.

CLI args:

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 name Team group display name (auto-created if absent)
--team-members No "" ID:name,... Comma-separated team member contacts (optional)
--group-links No "" string Public group link(s) for welcome message
--timezone No "UTC" IANA tz For weekend detection (24h vs 48h)

Env vars: GROK_API_KEY (required) — xAI API key.

interface Config {
  dbPrefix: string
  grokDbPrefix: string
  teamGroup: {id: number; name: string}  // id=0 at parse time, resolved at startup
  teamMembers: {id: number; name: string}[]  // optional, empty if not provided
  grokContactId: number | null  // resolved at startup from state file
  groupLinks: string
  timezone: string
  grokApiKey: string
}

State file{dbPrefix}_state.json:

{"teamGroupId": 123, "grokContactId": 4, "grokGroupMap": {"100": 200}}

Team group ID, Grok contact ID, and Grok group map are persisted to ensure the bot reconnects to the same entities across restarts. The Grok group map (mainGroupId → grokLocalGroupId) is updated on every Grok join/leave event.

Grok contact resolution (auto-establish):

  1. Read grokContactId from state file → validate it exists in apiListContacts
  2. If not found: create invitation link (apiCreateLink), connect Grok agent (apiConnectActiveUser), wait for contactConnected (60s), persist new contact ID
  3. If Grok contact is unavailable, bot runs but /grok returns "temporarily unavailable"

Team group resolution (auto-create):

  1. Read teamGroupId from state file → validate it exists in apiListGroups
  2. If not found: create with apiNewGroup, persist new group ID

Team group invite link lifecycle:

  1. Delete any stale link from previous run: apiDeleteGroupLink (best-effort)
  2. Create invite link: apiCreateGroupLink(teamGroupId, GroupMemberRole.Member)
  3. Display link on stdout for team members to join
  4. Schedule deletion after 10 minutes: apiDeleteGroupLink(teamGroupId)
  5. On shutdown (SIGINT/SIGTERM), delete link before exit (idempotent, best-effort)

Team member validation (optional):

  • If --team-members provided: validate each contact ID/name pair via apiListContacts, fail-fast on mismatch
  • If not provided: bot runs without team members; /team returns "No team members are available yet"

5. State Derivation (Stateless)

State is derived from group composition (apiListMembers) and chat history (apiGetChat via sendChatCmd). No in-memory conversations map — survives restarts naturally.

Derived states:

Condition Equivalent State
No bot groupSnd containing "forwarded to the team" welcome
No Grok member, no team member, bot has sent queue reply teamQueue
Grok member present (active) grokMode
Team member present, hasn't sent message teamPending
Team member present, has sent message teamLocked

State derivation helpers:

  • getGroupComposition(groupId){grokMember, teamMember} from apiListMembers
  • isFirstCustomerMessage(groupId) → checks if bot has sent "forwarded to the team" via apiGetChat
  • getGrokHistory(groupId, grokMember, customerId) → reconstructs Grok conversation from chat history
  • getCustomerMessages(groupId, customerId) → accumulated customer messages from chat history
  • hasTeamMemberSentMessage(groupId, teamMember) → teamPending vs teamLocked from chat history

Transitions (same as stateful approach):

welcome ──(1st user msg)──> teamQueue (forward to team + queue reply)
teamQueue ──(user msg)──> teamQueue (forward to team)
teamQueue ──(/grok)──> grokMode (invite Grok, send accumulated msgs to API)
teamQueue ──(/team)──> teamPending (add team member)
grokMode ──(user msg)──> grokMode (forward to Grok API + team)
grokMode ──(/team)──> teamPending (remove Grok, add team member)
teamPending ──(team member msg)──> teamLocked (implicit via hasTeamMemberSentMessage)
teamPending ──(/grok)──> reply "team mode"
teamLocked ──(/grok)──> reply "team mode", stay locked
teamLocked ──(any)──> no action (team sees directly)

6. Two-Instance Coordination

Problem: When main bot invites Grok agent to a business group, Grok agent's local groupId differs (different databases).

Solution: In-process maps correlated via protocol-level memberId (string, same across databases).

const pendingGrokJoins = new Map<string, number>()      // memberId → mainGroupId
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

Flow:

  1. Main bot: mainChat.apiAddMember(mainGroupId, grokContactId, "member") → response member.memberId
  2. Store: pendingGrokJoins.set(member.memberId, mainGroupId)
  3. Grok agent receives receivedGroupInvitation event → evt.groupInfo.membership.memberId matches → grokChat.apiJoinGroup(evt.groupInfo.groupId) → store bidirectional mapping (but do NOT resolve waiter yet)
  4. Grok agent receives connectedToGroupMember event → reverseGrokMap lookup → resolve waiter (Grok is now fully connected and can send messages)
  5. Send Grok response: grokChat.apiSendTextMessage([T.ChatType.Group, grokGroupMap.get(mainGroupId)!], text)

Important: apiJoinGroup sends the join request, but Grok is not fully connected until the connectedToGroupMember event fires. Sending messages before this results in "not current member" errors.

Grok agent event subscriptions:

grokChat.on("receivedGroupInvitation", async ({groupInfo}) => {
  const memberId = groupInfo.membership.memberId
  const mainGroupId = pendingGrokJoins.get(memberId)
  if (mainGroupId !== undefined) {
    pendingGrokJoins.delete(memberId)
    await grokChat.apiJoinGroup(groupInfo.groupId)
    // Set maps but don't resolve waiter — wait for connectedToGroupMember
    grokGroupMap.set(mainGroupId, groupInfo.groupId)
    reverseGrokMap.set(groupInfo.groupId, mainGroupId)
  }
})

grokChat.on("connectedToGroupMember", ({groupInfo}) => {
  const mainGroupId = reverseGrokMap.get(groupInfo.groupId)
  if (mainGroupId === undefined) return
  const resolver = grokJoinResolvers.get(mainGroupId)
  if (resolver) {
    grokJoinResolvers.delete(mainGroupId)
    resolver()
  }
})

7. Bot Initialization

Main bot uses bot.run() for setup automation (address, profile, commands), with only events parameter for full routing control:

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"},
      {type: "command", keyword: "add", label: "Join group"},
    ],
    useBotProfile: true,
  },
  events: {
    acceptingBusinessRequest: (evt) => supportBot?.onBusinessRequest(evt),
    newChatItems: (evt) => supportBot?.onNewChatItems(evt),
    chatItemUpdated: (evt) => supportBot?.onChatItemUpdated(evt),
    leftMember: (evt) => supportBot?.onLeftMember(evt),
    connectedToGroupMember: (evt) => supportBot?.onMemberConnected(evt),
    newMemberContactReceivedInv: (evt) => supportBot?.onMemberContactReceivedInv(evt),
  },
})

Grok agent uses direct ChatApi:

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
grokChat.on("receivedGroupInvitation", async (evt) => supportBot?.onGrokGroupInvitation(evt))
grokChat.on("connectedToGroupMember", (evt) => supportBot?.onGrokMemberConnected(evt))

Startup resolution (after init, before event loop):

  1. Read {dbPrefix}_state.json for persisted grokContactId and teamGroupId
  2. Enable auto-accept DM contacts from group members: sendChatCmd("/_set accept member contacts ${mainUser.userId} on")
  3. mainChat.apiListContacts(mainUser.userId) → log contacts list, resolve Grok contact (from state or auto-establish via apiCreateLink + apiConnectActiveUser + wait("contactConnected", 60000))
  4. sendChatCmd("/_groups${mainUser.userId}") → resolve team group (from state or auto-create via apiNewGroup + persist)
  5. Ensure direct messages enabled on team group: apiUpdateGroupProfile(teamGroupId, {groupPreferences: {directMessages: {enable: On}}}) for existing groups; included in apiNewGroup for new groups
  6. Delete stale invite link (best-effort), then apiCreateGroupLink(teamGroupId, Member) → display, schedule 10min deletion
  7. If --team-members provided: validate each contact ID/name pair via contacts list, fail-fast on mismatch
  8. On SIGINT/SIGTERM → delete invite link with apiDeleteGroupLink, then exit

8. Event Processing

Main bot event handlers:

Event Handler Action
acceptingBusinessRequest onBusinessRequest Enable file uploads on business group via apiUpdateGroupProfile
newChatItems onNewChatItems For each chatItem: identify sender, extract text, dispatch to routing. Also handles /add in team group.
chatItemUpdated onChatItemUpdated Forward message edits to team group (update forwarded message text)
leftMember onLeftMember If customer left → cleanup grok maps. If Grok left → cleanup grok maps. If team member left → add replacement if engaged (hasTeamMemberSentMessage), else revert to queue (implicit).
connectedToGroupMember onMemberConnected Log for debugging
newMemberContactReceivedInv onMemberContactReceivedInv Log DM contact from team group member (auto-accepted via /_set accept member contacts)

Grok agent event handlers:

Event Handler Action
receivedGroupInvitation onGrokGroupInvitation Match memberIdapiJoinGroup → set bidirectional maps (waiter NOT resolved yet)
connectedToGroupMember onGrokMemberConnected Resolve grokJoinResolvers waiter — Grok is now fully connected and can send messages

We do NOT use onMessage/onCommands from bot.run() — all routing is done in the newChatItems event handler for full control over state-dependent command handling.

Message processing in newChatItems (stateless):

// For each chatItem in evt.chatItems:
// 1. Handle /add command in team group (if groupId === teamGroup.id)
// 2. Skip non-business-chat groups
// 3. Skip groupSnd (own messages)
// 4. Skip non-groupRcv
// 5. Identify sender:
//    - Customer: sender.memberId === businessChat.customerId
//    - Team member: sender.memberContactId matches teamMembers config
// 6. For non-customer messages: forward team member messages to team group
// 7. For customer messages: derive state from group composition (getGroupComposition)
//    - Team member present → handleTeamMode
//    - Grok member present → handleGrokMode
//    - Neither present → handleNoSpecialMembers (welcome or teamQueue)

Command detection — use util.ciBotCommand() for /grok and /team; all other text (including unrecognized /commands) is routed as "other text" per spec ("Unrecognized commands: treated as normal messages in the current mode").

9. Message Routing Table

Customer message routing (derived state → action):

State Input Actions API Calls Next State
welcome any text Forward to team, send queue reply, send /add command mainChat.apiSendTextMessage([Group, teamGroupId], fwd) + mainChat.apiSendTextMessage([Group, groupId], queueMsg) + mainChat.apiSendTextMessage([Group, teamGroupId], addCmd) teamQueue
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 mainChat.apiSendTextMessage([Group, teamGroupId], fwd) teamQueue
grokMode /grok Ignore (already in grok mode) grokMode
grokMode /team Remove Grok, add team member mainChat.apiRemoveMembers(groupId, [grokMemberGId]) + mainChat.apiAddMember(groupId, teamContactId, "member") + mainChat.apiSendTextMessage([Group, groupId], teamAddedMsg) teamPending
grokMode other text Forward to Grok API + forward to team Grok API call + grokChat.apiSendTextMessage([Group, grokLocalGId], response) + mainChat.apiSendTextMessage([Group, teamGroupId], fwd) grokMode
teamPending /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

10. Team Forwarding

async forwardToTeam(groupId: number, groupInfo: T.GroupInfo, text: string): Promise<void> {
  const name = groupInfo.groupProfile.displayName || `group-${groupId}`
  const fwd = `${name}:${groupId}: ${text}`
  await this.mainChat.apiSendTextMessage(
    [T.ChatType.Group, this.config.teamGroup.id],
    fwd
  )
}

async activateTeam(groupId: number, grokMember: T.GroupMember | undefined): Promise<void> {
  // Remove Grok immediately if present (per spec: "When switching to team mode, Grok is removed")
  if (grokMember) {
    try { await this.mainChat.apiRemoveMembers(groupId, [grokMember.groupMemberId]) } catch {}
    this.cleanupGrokMaps(groupId)
  }
  if (this.config.teamMembers.length === 0) {
    await this.sendToGroup(groupId, "No team members are available yet. Please try again later or click /grok.")
    return
  }
  const teamContactId = this.config.teamMembers[0].id
  const member = await this.addOrFindTeamMember(groupId, teamContactId)  // handles groupDuplicateMember
  if (!member) {
    await this.sendToGroup(groupId, "Sorry, there was an error adding a team member. Please try again.")
    return
  }
  await this.sendToGroup(groupId, teamAddedMessage(this.config.timezone))
}

// Helper: handles groupDuplicateMember error (team member already in group from previous session)
private async addOrFindTeamMember(groupId: number, teamContactId: number): Promise<GroupMember | null> {
  try {
    return await this.mainChat.apiAddMember(groupId, teamContactId, "member")
  } catch (err: any) {
    if (err?.chatError?.errorType?.type === "groupDuplicateMember") {
      const members = await this.mainChat.apiListMembers(groupId)
      return members.find(m => m.memberContactId === teamContactId) ?? null
    }
    throw err
  }
}

11. Grok API Integration

class GrokApiClient {
  constructor(private apiKey: string, private docsContext: string) {}

  async chat(history: GrokMessage[], userMessage: string): Promise<string> {
    const messages = [
      {role: "system", content: this.systemPrompt()},
      ...history.slice(-20),
      {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}),
    })
    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 support assistant for SimpleX Chat, answering questions inside the app as instant messages on mobile. You are a privacy expert who knows SimpleX Chat apps, network, design choices, and trade-offs.\n\nGuidelines:\n- Be concise. Keep answers short enough to read comfortably on a phone screen.\n- Answer simple questions in 1-2 sentences.\n- For how-to questions, give brief numbered steps — no extra explanation unless needed.\n- For design questions, give the key reason in 1-2 sentences, then trade-offs only if asked.\n- For criticism, briefly acknowledge the concern and explain the design choice.\n- If you don't know something, say so honestly.\n- Do not use markdown formatting...\n- Avoid filler, preambles, and repeating the question back.\n\n${this.docsContext}`
  }
}

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 via waitForGrokJoin(groupId, 30000) — Promise-based waiter resolved by onGrokMemberConnected (fires on grokChat.connectedToGroupMember), times out after 30s
  4. Re-check group composition (user may have sent /team concurrently — abort if team member appeared)
  5. Get accumulated customer messages from chat history via getCustomerMessages(groupId, customerId)
  6. Call Grok API with accumulated messages
  7. Re-check group composition again after API call (another event may have changed it)
  8. Send response via Grok identity: grokChat.apiSendTextMessage([Group, grokGroupMap.get(groupId)!], response)

Fallback: If Grok API fails → remove Grok from group, cleanup grok maps, send "Grok temporarily unavailable" message.

12. One-Way Gate Logic

Per spec: "When switching to team mode, Grok is removed" and "once the user switches to team mode, /grok command is permanently disabled." Grok removal happens immediately in activateTeam (section 10).

Stateless one-way gate: The gate is derived from group composition + chat history:

  • Team member present → handleTeamMode/grok replies "team mode"
  • hasTeamMemberSentMessage() determines teamPending vs teamLocked:
    • If team member has NOT sent a message and leaves → reverts to teamQueue (implicit, no state to update)
    • If team member HAS sent a message and leaves → replacement team member added

Timeline per spec:

  1. User sends /team → Grok removed immediately (if present) → team member added → teamPending (derived)
  2. /grok in teamPending → reply "team mode" (Grok already gone, command disabled)
  3. Team member sends message → teamLocked (derived via hasTeamMemberSentMessage)
  4. Any subsequent /grok → reply "You are now in team mode. A team member will reply to your message."

13. Message Templates (verbatim from spec)

// 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 `Your message is forwarded to the team. A reply may take up to ${hours} hours.\n\nIf your question is about SimpleX Chat, click /grok for an instant AI answer (non-sensitive questions only). Click /team to switch back any 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."

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"
}

14. Complete API Call Map (100% Coverage)

# 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 | undefined] 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 Resolve team group Startup mainChat Read {dbPrefix}_state.jsonsendChatCmd("/_groups${userId}") find by persisted ID, or apiNewGroup(userId, {groupPreferences: {directMessages: {enable: On}}}) + persist userId, groupProfile GroupInfo[] / GroupInfo Exit on failure
5a Ensure DM on team group Startup (existing group) mainChat apiUpdateGroupProfile(teamGroupId, {groupPreferences: {directMessages: {enable: On}}}) groupId, groupProfile GroupInfo Exit on failure
5b Create team group invite link Startup mainChat apiDeleteGroupLink(groupId) (best-effort) then apiCreateGroupLink(groupId, Member) groupId, memberRole string (invite link) Exit on failure
5c Delete team group invite link 10min timer or shutdown mainChat apiDeleteGroupLink(groupId) groupId void Log error (best-effort)
6 Enable auto-accept DM contacts Startup mainChat sendChatCmd("/_set accept member contacts ${userId} on") userId Log warning
6a List contacts Startup mainChat apiListContacts(userId) userId Contact[] Exit on failure
6b Validate team members Startup (if --team-members provided) mainChat Match contacts by ID/name contact list Exit if ID:name mismatch
7 Auto-establish Grok contact Startup (if not in state file) mainChat apiCreateLink(userId) userId string (invitation link) Exit on failure
8 Auto-establish Grok contact Startup (if not in state file) grokChat apiConnectActiveUser(invLink) connLink ConnReqType Exit on failure
9 Auto-establish Grok contact Startup (if not in state file) 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, grokMode 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 from grokMode 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
19 List group members groupDuplicateMember fallback mainChat apiListMembers(groupId) groupId GroupMember[] Log error

15. Error Handling

Scenario Handling
ChatApi init fails Log error, exit (let process manager restart)
Grok API error (HTTP/timeout) Remove Grok from group, cleanup grok maps, send "Grok temporarily unavailable" message
Grok API error during conversation Remove Grok from group, cleanup grok maps, send "Grok temporarily unavailable" message (next message → teamQueue via stateless derivation)
apiAddMember fails (Grok) mainChat.apiSendTextMessage error msg, stay in teamQueue (stateless)
apiAddMember fails (team) mainChat.apiSendTextMessage error msg, stay in current state (stateless)
apiRemoveMembers fails Catch and ignore (member may have left)
Grok join timeout (30s) mainChat.apiSendTextMessage "Grok unavailable", stay in teamQueue (stateless)
Customer leaves (leftMember where member is customer) Cleanup grokGroupMap entry
Grok leaves during grokMode Cleanup grokGroupMap entry (next message → teamQueue via stateless derivation)
Team member leaves (pending, not engaged) No action needed; next message → teamQueue via stateless derivation
Team member leaves (locked, engaged) Add replacement team member (addReplacementTeamMember)
Grok contact unavailable (grokContactId === null) /grok returns "Grok is temporarily unavailable" message
No team members configured (teamMembers.length === 0) /team returns "No team members are available yet" message
Grok agent connection lost Log error; Grok features unavailable until restart
apiSendTextMessage fails Log error, continue (message lost but bot stays alive)
Team member config validation fails Print descriptive error with actual vs expected name, exit
groupDuplicateMember on apiAddMember Catch error, call apiListMembers to find existing member by memberContactId, use existing groupMemberId
Restart: any business chat group State derived from group composition + chat history (no explicit re-initialization needed)

16. Implementation Sequence

Phase 1: Scaffold

  • Create project: package.json, tsconfig.json
  • Implement config.ts: CLI arg parsing, ID:name format (team members), Config type
  • Implement index.ts: init both ChatApi instances, auto-resolve Grok contact and team group from state file, verify profiles
  • Implement util.ts: isWeekend, logging
  • Verify: Both instances init, print user profiles, Grok contact established, team group created

Phase 2: Stateless event processing

  • Implement state.ts: GrokMessage type
  • Implement bot.ts: SupportBot class with stateless state derivation helpers
  • Handle acceptingBusinessRequest → enable file uploads on business group
  • Handle newChatItems → sender identification → derive state from group composition → dispatch
  • Implement welcome detection (isFirstCustomerMessage) + 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

  • Implement grok.ts: GrokApiClient with system prompt + docs injection
  • Implement Grok agent event handler (receivedGroupInvitation → auto-join)
  • Implement activateGrok: null guard for grokContactId, 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

  • Implement activateTeam: empty teamMembers guard, remove Grok if present, add team member
  • Implement handleTeamMode: /grok rejection when team member present
  • Implement hasTeamMemberSentMessage: teamPending vs teamLocked derivation
  • Verify: Full flow: teamQueue → /grok → grokMode → /team → Grok removed + teamPending → /grok rejected → team msg → teamLocked

Phase 5: Polish + edge cases

  • Handle edge cases: customer leave, Grok timeout, member leave
  • Team group invite link lifecycle: create on startup, delete after 10min or on shutdown
  • Graceful shutdown (SIGINT/SIGTERM)
  • Write docs/simplex-context.md for Grok prompt injection
  • End-to-end test all flows

Phase 6: Extra features (beyond MVP)

  • Edit forwarding: chatItemUpdated → forward edits to team group (update forwarded message)
  • Team member reply forwarding: team member messages in business chats → forwarded to team group
  • /add command: team members send /add groupId:name in team group → bot adds them to the customer group
  • Grok group map persistence: grokGroupMap persisted to state file → survives restarts
  • Profile images: bot and Grok agent have profile images set on startup

17. Self-Review Requirement

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

Startup (all auto-resolution happens automatically):

cd apps/simplex-support-bot
npm install
GROK_API_KEY=xai-... npx ts-node src/index.ts \
  --team-group SupportTeam \
  --timezone America/New_York \
  --group-links "https://simplex.chat/contact#..."

On first startup, the bot auto-establishes the Grok contact and creates the team group, persisting both IDs to {dbPrefix}_state.json. It prints:

Team group invite link (expires in 10 min):
https://simplex.chat/contact#...

Team members scan/click the link to join the team group. After 10 minutes, the link is deleted. On subsequent startups, the existing Grok contact and team group are resolved by persisted ID (not by name — safe even with duplicate group names) and a fresh team group invite link is created.

With optional team members (for pre-validated contacts):

GROK_API_KEY=xai-... npx ts-node src/index.ts \
  --team-group SupportTeam \
  --team-members 2:Alice,3:Bob \
  --timezone America/New_York

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 Grok removed, team member added, team added message
  6. Send /grok after /team (before team member message) → verify "team mode" reply
  7. Send team member message → verify state locked, /grok still rejected
  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
  11. Team group auto-creation: start with a new group name → verify group created, ID persisted to state file, team group invite link displayed
  12. Team group invite link deletion: wait 10 minutes → verify link deleted; kill bot → verify link deleted on shutdown
  13. Team group persistence: restart bot → verify same group ID used from state file (not a new group)
  14. Team group recovery: delete persisted group externally → restart bot → verify new group created and state file updated
  15. Grok contact auto-establish: first startup with empty state file → verify Grok contact created and persisted
  16. Grok contact persistence: restart bot → verify same Grok contact ID used from state file
  17. Grok contact recovery: delete persisted contact externally → restart bot → verify new contact established and state file updated
  18. No team members: start without --team-members → send /team → verify "No team members are available yet" message
  19. Null grokContactId: if Grok contact unavailable → send /grok → verify "Grok is temporarily unavailable" message
  20. Restart recovery: customer message in unknown group → re-init to teamQueue, forward to team (no queue reply)
  21. Restart recovery: after re-init, /grok works in re-initialized group
  22. Grok join waiter: onGrokGroupInvitation alone does NOT resolve waiter — onGrokMemberConnected required
  23. groupDuplicateMember: /team when team member already in group → apiListMembers lookup, transition to teamPending
  24. groupDuplicateMember: member not found in list → error message, stay in current state
  25. DM contact received: newMemberContactReceivedInv from team group → logged, no crash
  26. Direct messages enabled on team group (via groupPreferences) for both new and existing groups

Critical Reference Files

  • 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