diff --git a/apps/multiplatform/plans/20260207-support-bot-implementation.md b/apps/multiplatform/plans/20260207-support-bot-implementation.md index 439f1bd3cf..911a148f7c 100644 --- a/apps/multiplatform/plans/20260207-support-bot-implementation.md +++ b/apps/multiplatform/plans/20260207-support-bot-implementation.md @@ -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 │ -│ grokGroupMap: Map │ -│ 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 │ +│ grokGroupMap: Map │ +│ 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() // memberId → mainGroupId -const grokGroupMap = new Map() // mainGroupId → grokGroupId +const grokGroupMap = new Map() // mainGroupId → grokLocalGroupId +const reverseGrokMap = new Map() // 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 { - 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 { - 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 { + 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 { - const teamContactId = this.config.teamMemberContactIds[0] - const member = await this.mainClient.apiAddMember(groupId, teamContactId, "member") +async activateTeam(groupId: number, state: ConversationState): Promise { + 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 { 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 { @@ -303,110 +364,187 @@ async onTeamMemberMessage(groupId: number, state: ConversationState): Promise