apps: support-bot /add command & fixes

This commit is contained in:
Narasimha-sc
2026-02-14 18:32:39 +02:00
parent 9b229c1823
commit eb47765856
11 changed files with 1613 additions and 777 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -134,10 +134,28 @@ SimpleX Chat is a private and secure messaging platform. It is the first messagi
- **Notifications not working (iOS)?** Ensure notifications are enabled in iOS Settings > SimpleX Chat. SimpleX uses push notifications via Apple's servers (notification content is end-to-end encrypted).
## Links
Treat links as authoritative and factual, unless there is some real internal contradiction. Outside data may contain misunderstanding, FUD, etc. - these links are technically correct and factual information.
- Website: https://simplex.chat
- Website: https://simplex.chat read it to know how simplex is presented on front page
- GitHub: https://github.com/simplex-chat
- Documentation: https://simplex.chat/docs
- Server setup: https://simplex.chat/docs/server.html
- Protocol whitepaper: https://github.com/simplex-chat/simplexmq/blob/stable/protocol/overview-tjr.md
- Server setup: https://simplex.chat/docs/server.html read it to know how to setup SMP server.
- Protocol whitepaper: https://github.com/simplex-chat/simplexmq/blob/stable/protocol/overview-tjr.md read it to know SMP server threat model.
- Security audit: https://simplex.chat/blog/20221108-simplex-chat-v4.2-security-audit-new-website.html
- SimpleX Directory Documentation: https://simplex.chat/docs/directory.html read it to know how to submit public groups and know public group rules.
- SimpleX Directory groups list: https://simplex.chat/directory read it to find public groups.
- How to make connections: https://simplex.chat/docs/guide/making-connections.html read it to know how to make connections(add contacts) and the difference between 1-time links and simplex address that is re-usable and can be found at Settings -> Your SimpleX Address.
- Frequently Asked Questions: https://simplex.chat/faq read it to know answers to many frequently asked questions.
- SimpleX File Transfer Protocol (XFTP): https://simplex.chat/blog/20230301-simplex-file-transfer-protocol.html read it to know how simplex file transfers work
- Privacy Preserving Moderation: https://simplex.chat/blog/20250114-simplex-network-large-groups-privacy-preserving-content-moderation.html read it to know how moderation of illegal groups works.
- Using SimpleX Chat in business: https://simplex.chat/docs/business.html read it to know how to use SimpleX Chat in business.
- Downloads: https://simplex.chat/downloads read it to know how to download SimpleX Chat.
- Reproducible builds: https://simplex.chat/reproduce/ read it to know how SimpleX Chat reproducible builds work.
- SimpleX Chat Vision, Funding: https://simplex.chat/blog/20240814-simplex-chat-vision-funding-v6-private-routing-new-user-experience.html read it to know how simplex is funded
- Quantum Resistance, Signal Double Ratchet: https://simplex.chat/blog/20240314-simplex-chat-v5-6-quantum-resistance-signal-double-ratchet-algorithm.html read it to know how simplex has implemented quantum resistance
- Dangers of metadata in messengers: https://simplex.chat/blog/20240416-dangers-of-metadata-in-messengers.html read it to know dangers of metadata in messengers and how simplex is superior in this area
- SimpleX Chat user guide: https://simplex.chat/docs/guide/readme.html read it to know how to quick start using the app.
- SimpleX Instant Notifications (iOS): https://simplex.chat/blog/20220404-simplex-chat-instant-notifications.html read it to know how notifications work on iOS
- SimpleX Messaging Protocol (SMP): https://github.com/simplex-chat/simplexmq/blob/stable/protocol/simplex-messaging.md read it to know how SMP works

View File

@@ -18,7 +18,7 @@ SimpleX Chat support bot — standalone Node.js app using `simplex-chat-nodejs`
│ • Grok identity, auto-joins groups │
│ • DB: data/grok_chat.db + data/grok_agent.db │
│ │
conversations: Map<groupId, ConversationState>
State: derived from group composition + chat DB
│ grokGroupMap: Map<mainGroupId, grokGroupId> │
│ GrokApiClient → api.x.ai/v1/chat/completions │
└─────────────────────────────────────────────────┘
@@ -33,16 +33,16 @@ SimpleX Chat support bot — standalone Node.js app using `simplex-chat-nodejs`
## 3. Project Structure
```
apps/simplex-chat-support-bot/
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: state mgmt, event dispatch, routing
│ ├── state.ts # ConversationState union 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 (verbatim from spec)
│ ├── messages.ts # All user-facing message templates
│ └── util.ts # isWeekend, logging helpers
├── data/ # SQLite databases (created at runtime)
└── docs/
@@ -81,10 +81,10 @@ interface Config {
**State file**`{dbPrefix}_state.json`:
```json
{"teamGroupId": 123, "grokContactId": 4}
{"teamGroupId": 123, "grokContactId": 4, "grokGroupMap": {"100": 200}}
```
Both IDs are persisted to ensure the bot reconnects to the same entities across restarts, even if multiple groups share the same display name.
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`
@@ -106,30 +106,36 @@ Both IDs are persisted to ensure the bot reconnects to the same entities across
- 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 Machine
## 5. State Derivation (Stateless)
Keyed by `groupId` of business chat group. In-memory (restart resets; team group retains forwarded messages).
State is derived from group composition (`apiListMembers`) and chat history (`apiGetChat` via `sendChatCmd`). No in-memory `conversations` map — survives restarts naturally.
```typescript
type ConversationState =
| {type: "welcome"}
| {type: "teamQueue"; userMessages: string[]}
| {type: "grokMode"; grokMemberGId: number; history: GrokMessage[]}
| {type: "teamPending"; teamMemberGId: number}
| {type: "teamLocked"; teamMemberGId: number}
**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):**
```
`teamQueue.userMessages` accumulates user messages for Grok initial context on `/grok` activation.
**Transitions:**
```
welcome ──(1st user msg)──> teamQueue
teamQueue ──(user msg)──> teamQueue (append to userMessages)
teamQueue ──(/grok)──> grokMode (userMessages → initial Grok history)
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, append to history)
grokMode ──(/team)──> teamPending (remove Grok immediately, add team member)
teamPending ──(team member msg)──> teamLocked
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)
@@ -201,15 +207,15 @@ const [mainChat, mainUser, mainAddress] = await bot.run({
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),
deletedMemberUser: (evt) => supportBot?.onDeletedMemberUser(evt),
groupDeleted: (evt) => supportBot?.onGroupDeleted(evt),
connectedToGroupMember: (evt) => supportBot?.onMemberConnected(evt),
newMemberContactReceivedInv: (evt) => supportBot?.onMemberContactReceivedInv(evt),
},
@@ -243,11 +249,10 @@ grokChat.on("connectedToGroupMember", (evt) => supportBot?.onGrokMemberConnected
| 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 during grokMode → revert to teamQueue. |
| `deletedMemberUser` | `onDeletedMemberUser` | Bot removed from group → delete state |
| `groupDeleted` | `onGroupDeleted` | Delete state, delete grokGroupMap entry |
| `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`) |
@@ -260,64 +265,38 @@ grokChat.on("connectedToGroupMember", (evt) => supportBot?.onGrokMemberConnected
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.
**Sender identification in `newChatItems`:**
**Message processing in `newChatItems` (stateless):**
```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
let state = conversations.get(groupId)
if (!state) {
// After restart, re-initialize state for existing business chats
state = {type: "teamQueue", userMessages: []}
conversations.set(groupId, state)
}
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
const isGrok = state.type === "grokMode"
&& 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)
}
// 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"):
```typescript
function extractText(chatItem: T.ChatItem): string | null {
const text = util.ciContentText(chatItem)
return text?.trim() || null
}
// In onCustomerMessage:
const cmd = util.ciBotCommand(chatItem)
if (cmd?.keyword === "grok") { /* handle /grok */ }
else if (cmd?.keyword === "team") { /* handle /team */ }
else { /* handle as normal text message, including unrecognized /commands */ }
```
**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
`onCustomerMessage(groupId, groupInfo, chatItem, state)`:
Customer message routing (derived state → action):
| 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) |
| `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, append to userMessages | `mainChat.apiSendTextMessage([Group, teamGroupId], fwd)` | `teamQueue` |
| `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` (append history) |
| `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` |
@@ -330,35 +309,30 @@ else { /* handle as normal text message, including unrecognized /commands */ }
```typescript
async forwardToTeam(groupId: number, groupInfo: T.GroupInfo, text: string): Promise<void> {
const name = groupInfo.groupProfile.displayName || `group-${groupId}`
const fwd = `[${name} #${groupId}]\n${text}`
const fwd = `${name}:${groupId}: ${text}`
await this.mainChat.apiSendTextMessage(
[T.ChatType.Group, this.config.teamGroup.id],
fwd
)
}
async activateTeam(groupId: number, state: ConversationState): Promise<void> {
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 (state.type === "grokMode") {
try { await this.mainChat.apiRemoveMembers(groupId, [state.grokMemberGId]) } catch {}
if (grokMember) {
try { await this.mainChat.apiRemoveMembers(groupId, [grokMember.groupMemberId]) } catch {}
this.cleanupGrokMaps(groupId)
}
if (this.config.teamMembers.length === 0) {
// No team members configured — revert to teamQueue if was grokMode
if (state.type === "grokMode") this.conversations.set(groupId, {type: "teamQueue", userMessages: []})
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
this.conversations.set(groupId, {
type: "teamPending",
teamMemberGId: member.groupMemberId,
})
await this.mainChat.apiSendTextMessage(
[T.ChatType.Group, groupId],
teamAddedMessage(this.config.timezone)
)
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)
@@ -398,7 +372,7 @@ class GrokApiClient {
}
private systemPrompt(): string {
return `You are a privacy expert and SimpleX Chat evangelist...\n\n${this.docsContext}`
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}`
}
}
```
@@ -407,30 +381,28 @@ class GrokApiClient {
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 state (user may have sent `/team` concurrently — abort if state changed)
5. Build initial Grok history from `state.userMessages`
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 state again after API call (another event may have changed it)
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)`
9. Transition to `grokMode` with history
**Fallback:** If Grok API fails → send error message via `mainChat.apiSendTextMessage`, keep accumulated messages, stay in `teamQueue`.
**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). The one-way gate locks the state after team member engages:
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).
```typescript
async onTeamMemberMessage(groupId: number, state: ConversationState): Promise<void> {
if (state.type !== "teamPending") return
this.conversations.set(groupId, {type: "teamLocked", teamMemberGId: state.teamMemberGId})
}
```
**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 → state = `teamPending`
2. `/grok` in `teamPending` → reply "team mode" (Grok already gone, command disabled)
3. Team member sends message → `onTeamMemberMessage` → state = `teamLocked`
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)
@@ -444,7 +416,7 @@ function welcomeMessage(groupLinks: string): string {
// 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.`
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
@@ -502,23 +474,23 @@ function isWeekend(timezone: string): boolean {
| Scenario | Handling |
|----------|----------|
| 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 |
| 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` |
| Customer leaves (`leftMember` where member is customer) | Delete conversation state, delete grokGroupMap entry |
| Group deleted | Delete conversation state, delete grokGroupMap entry |
| Grok leaves during `grokMode` | Revert to `teamQueue`, delete grokGroupMap entry |
| Team member leaves | Revert to `teamQueue` (accumulate messages again) |
| Bot removed from group (`deletedMemberUser`) | Delete conversation state |
| Grok contact unavailable (`grokContactId === null`) | `/grok` returns "Grok is temporarily unavailable" message, stay in current state |
| No team members configured (`teamMembers.length === 0`) | `/team` returns "No team members are available yet" message; if was grokMode, revert to teamQueue |
| 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: unknown business chat group | Re-initialize conversation state as `teamQueue` (no welcome reply), forward messages to team |
| Restart: any business chat group | State derived from group composition + chat history (no explicit re-initialization needed) |
## 16. Implementation Sequence
@@ -529,12 +501,12 @@ function isWeekend(timezone: string): boolean {
- Implement `util.ts`: `isWeekend`, logging
- **Verify:** Both instances init, print user profiles, Grok contact established, team group created
**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
**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
@@ -547,17 +519,24 @@ function isWeekend(timezone: string): boolean {
**Phase 4: Team mode + one-way gate**
- Implement `activateTeam`: empty teamMembers guard, remove Grok if present, add team member
- Implement `onTeamMemberMessage`: detect team msg → lock state
- Implement `/grok` rejection in `teamPending` and `teamLocked`
- 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, group delete, Grok timeout, member leave
- 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:**
@@ -578,7 +557,7 @@ Any edit restarts the review cycle. Batch changes within a round.
**Startup** (all auto-resolution happens automatically):
```bash
cd apps/simplex-chat-support-bot
cd apps/simplex-support-bot
npm install
GROK_API_KEY=xai-... npx ts-node src/index.ts \
--team-group SupportTeam \
@@ -604,7 +583,7 @@ GROK_API_KEY=xai-... npx ts-node src/index.ts \
**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
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

View File

@@ -20,12 +20,9 @@ No mention of Grok, no choices. User simply types their question. Messages at th
## Step 2 — After user sends first message
All messages are forwarded to the team group. Bot replies:
> Thank you for your message, it is forwarded to the team.
> It may take a team member up to 24 hours to reply.
> Your message is forwarded to the team. A reply may take up to 24 hours.
>
> Click /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.
>
> We 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.
> If your question is about SimpleX Chat, click /grok for an instant AI answer (non-sensitive questions only). Click /team to switch back any time.
On weekends, the bot says "48 hours" instead of "24 hours".
@@ -37,7 +34,7 @@ Bot replies:
Grok must be added as a separate participant to the chat, so that user can differentiate bot messages from Grok messages. When switching to team mode, Grok is removed.
Grok is prompted as a privacy expert and SimpleX Chat evangelist who knows everything about SimpleX Chat apps, network, design choices, and trade-offs. It answers honestly — for every criticism it explains why the team made that design choice. Relevant documentation pages and links must be injected into the context by the bot.
Grok is prompted as a privacy expert and support assistant who knows SimpleX Chat apps, network, design choices, and trade-offs. It gives concise, mobile-friendly answers — brief numbered steps for how-to questions, 1-2 sentence explanations for design questions. For criticism, it briefly acknowledges the concern and explains the design choice. It avoids filler and markdown formatting. Relevant documentation pages and links must be injected into the context by the bot.
## Step 4 — `/team` (Team mode, one-way gate)
@@ -55,5 +52,6 @@ This gate should trigger only after team joins and member sends message to team.
|---------|-------------|--------|
| `/grok` | Team Queue (before escalation only) | Enter Grok mode |
| `/team` | Grok mode or Team Queue | Add team member, permanently enter Team mode |
| `/add` | Team group only | Team member sends `/add groupId:name` → bot adds them to the customer group |
**Unrecognized commands:** treated as normal messages in the current mode.

View File

@@ -1,34 +0,0 @@
A SimpleX Chat bot that monitors public groups, summarizes conversations using
Grok LLM, moderates content, and forwards important messages to a private
staff group.
Core Features
1. Message Summarization
- Periodically summarizes public group messages using Grok API
- Posts summaries to the group on a configurable schedule (e.g. daily/hourly)
- Summaries capture key topics, decisions, and action items
2. Moderation
- Detects spam, abuse, and policy violations using Grok
- Configurable actions per severity: flag-only, auto-delete, or remove member
- All moderation events are forwarded to the staff group for visibility
3. Important Message Forwarding
- Grok classifies messages by importance (urgency, issues, support requests)
- Forwards important messages to a designated private staff group
- Includes context: sender, group, timestamp, and reason for flagging
Configuration
- GROK_API_KEY — Grok API credentials
- PUBLIC_GROUPS — list of monitored public groups
- STAFF_GROUP — private group for forwarded alerts
- SUMMARY_INTERVAL — how often summaries are generated
- MODERATION_RULES — content policy and action thresholds
Non-Goals
- No interactive Q&A or general chatbot behavior in groups
- No direct user communication from the bot (all escalation goes to staff
group)

View File

@@ -1,18 +1,35 @@
import {api, util} from "simplex-chat"
import {T, CEvt} from "@simplex-chat/types"
import {Config} from "./config.js"
import {ConversationState, GrokMessage} from "./state.js"
import {GrokMessage} from "./state.js"
import {GrokApiClient} from "./grok.js"
import {teamQueueMessage, grokActivatedMessage, teamAddedMessage, teamLockedMessage} from "./messages.js"
import {log, logError} from "./util.js"
interface GroupComposition {
grokMember: T.GroupMember | undefined
teamMember: T.GroupMember | undefined
}
function isActiveMember(m: T.GroupMember): boolean {
return m.memberStatus === T.GroupMemberStatus.Connected
|| m.memberStatus === T.GroupMemberStatus.Complete
|| m.memberStatus === T.GroupMemberStatus.Announced
}
export class SupportBot {
private conversations = new Map<number, ConversationState>()
// Grok group mapping (persisted via onGrokMapChanged callback)
private pendingGrokJoins = new Map<string, number>() // memberId → mainGroupId
private grokGroupMap = new Map<number, number>() // mainGroupId → grokLocalGroupId
private reverseGrokMap = new Map<number, number>() // grokLocalGroupId → mainGroupId
private grokJoinResolvers = new Map<number, () => void>() // mainGroupId → resolve fn
// Forwarded message tracking: "groupId:itemId" → {teamItemId, prefix}
private forwardedItems = new Map<string, {teamItemId: number; prefix: string}>()
// Callback to persist grokGroupMap changes
onGrokMapChanged: ((map: ReadonlyMap<number, number>) => void) | null = null
constructor(
private mainChat: api.ChatApi,
private grokChat: api.ChatApi,
@@ -20,12 +37,96 @@ export class SupportBot {
private config: Config,
) {}
// Restore grokGroupMap from persisted state (call after construction, before events)
restoreGrokGroupMap(entries: [number, number][]): void {
for (const [mainGroupId, grokLocalGroupId] of entries) {
this.grokGroupMap.set(mainGroupId, grokLocalGroupId)
this.reverseGrokMap.set(grokLocalGroupId, mainGroupId)
}
log(`Restored Grok group map: ${entries.length} entries`)
}
// --- State Derivation Helpers ---
private async getGroupComposition(groupId: number): Promise<GroupComposition> {
const members = await this.mainChat.apiListMembers(groupId)
return {
grokMember: members.find(m =>
m.memberContactId === this.config.grokContactId && isActiveMember(m)),
teamMember: members.find(m =>
this.config.teamMembers.some(tm => tm.id === m.memberContactId) && isActiveMember(m)),
}
}
private async isFirstCustomerMessage(groupId: number): Promise<boolean> {
const chat = await this.apiGetChat(groupId, 20)
// The platform sends auto-messages on connect (welcome, commands, etc.) as groupSnd.
// The bot's teamQueueMessage (sent after first customer message) uniquely contains
// "forwarded to the team" — none of the platform auto-messages do.
return !chat.chatItems.some((ci: T.ChatItem) =>
ci.chatDir.type === "groupSnd"
&& util.ciContentText(ci)?.includes("forwarded to the team"))
}
private async getGrokHistory(groupId: number, grokMember: T.GroupMember, customerId: string): Promise<GrokMessage[]> {
const chat = await this.apiGetChat(groupId, 100)
const history: GrokMessage[] = []
for (const ci of chat.chatItems) {
if (ci.chatDir.type !== "groupRcv") continue
const text = util.ciContentText(ci)?.trim()
if (!text) continue
if (ci.chatDir.groupMember.groupMemberId === grokMember.groupMemberId) {
history.push({role: "assistant", content: text})
} else if (ci.chatDir.groupMember.memberId === customerId) {
history.push({role: "user", content: text})
}
}
return history
}
private async getCustomerMessages(groupId: number, customerId: string): Promise<string[]> {
const chat = await this.apiGetChat(groupId, 100)
return chat.chatItems
.filter((ci: T.ChatItem) =>
ci.chatDir.type === "groupRcv"
&& ci.chatDir.groupMember.memberId === customerId
&& !util.ciBotCommand(ci))
.map((ci: T.ChatItem) => util.ciContentText(ci)?.trim())
.filter((t): t is string => !!t)
}
private async hasTeamMemberSentMessage(groupId: number, teamMember: T.GroupMember): Promise<boolean> {
const chat = await this.apiGetChat(groupId, 50)
return chat.chatItems.some((ci: T.ChatItem) =>
ci.chatDir.type === "groupRcv"
&& ci.chatDir.groupMember.groupMemberId === teamMember.groupMemberId)
}
// Interim apiGetChat wrapper using sendChatCmd directly
private async apiGetChat(groupId: number, count: number): Promise<T.AChat> {
const r = await this.mainChat.sendChatCmd(`/_get chat #${groupId} count=${count}`) as any
if (r.type === "apiChat") return r.chat
throw new Error(`error getting chat for group ${groupId}: ${r.type}`)
}
// --- Event Handlers (main bot) ---
onBusinessRequest(evt: CEvt.AcceptingBusinessRequest): void {
async onBusinessRequest(evt: CEvt.AcceptingBusinessRequest): Promise<void> {
const groupId = evt.groupInfo.groupId
log(`New business request: groupId=${groupId}`)
this.conversations.set(groupId, {type: "welcome"})
try {
const profile = evt.groupInfo.groupProfile
await this.mainChat.apiUpdateGroupProfile(groupId, {
displayName: profile.displayName,
fullName: profile.fullName,
groupPreferences: {
...profile.groupPreferences,
files: {enable: T.GroupFeatureEnabled.On},
},
})
log(`Enabled media uploads for business group ${groupId}`)
} catch (err) {
logError(`Failed to enable media uploads for group ${groupId}`, err)
}
}
async onNewChatItems(evt: CEvt.NewChatItems): Promise<void> {
@@ -40,9 +141,6 @@ export class SupportBot {
async onLeftMember(evt: CEvt.LeftMember): Promise<void> {
const groupId = evt.groupInfo.groupId
const state = this.conversations.get(groupId)
if (!state) return
const member = evt.member
const bc = evt.groupInfo.businessChat
if (!bc) return
@@ -50,46 +148,59 @@ export class SupportBot {
// Customer left
if (member.memberId === bc.customerId) {
log(`Customer left group ${groupId}, cleaning up`)
this.conversations.delete(groupId)
this.cleanupGrokMaps(groupId)
return
}
// Team member left — teamPending: gate not yet triggered, revert to teamQueue
if (state.type === "teamPending" && member.groupMemberId === state.teamMemberGId) {
log(`Team member left group ${groupId} (teamPending), reverting to teamQueue`)
this.conversations.set(groupId, {type: "teamQueue", userMessages: []})
return
}
// Team member left — teamLocked: one-way gate triggered, stay in team mode (add another member)
if (state.type === "teamLocked" && member.groupMemberId === state.teamMemberGId) {
log(`Team member left group ${groupId} (teamLocked), adding replacement team member`)
await this.addReplacementTeamMember(groupId)
return
}
// Grok left during grokMode
if (state.type === "grokMode" && member.groupMemberId === state.grokMemberGId) {
log(`Grok left group ${groupId} during grokMode, reverting to teamQueue`)
this.conversations.set(groupId, {type: "teamQueue", userMessages: []})
// Grok left
if (member.memberContactId === this.config.grokContactId) {
log(`Grok left group ${groupId}`)
this.cleanupGrokMaps(groupId)
return
}
// Team member left — check if they had engaged (teamLocked vs teamPending)
if (this.config.teamMembers.some(tm => tm.id === member.memberContactId)) {
const engaged = await this.hasTeamMemberSentMessage(groupId, member)
if (engaged) {
log(`Engaged team member left group ${groupId}, adding replacement`)
await this.addReplacementTeamMember(groupId)
} else {
log(`Pending team member left group ${groupId}, reverting to queue`)
// No state to revert — member is already gone from DB
}
}
}
onDeletedMemberUser(evt: CEvt.DeletedMemberUser): void {
const groupId = evt.groupInfo.groupId
log(`Bot removed from group ${groupId}`)
this.conversations.delete(groupId)
this.cleanupGrokMaps(groupId)
}
async onChatItemUpdated(evt: CEvt.ChatItemUpdated): Promise<void> {
const {chatInfo, chatItem} = evt.chatItem
if (chatInfo.type !== "group") return
const groupInfo = chatInfo.groupInfo
if (!groupInfo.businessChat) return
const groupId = groupInfo.groupId
onGroupDeleted(evt: CEvt.GroupDeleted): void {
const groupId = evt.groupInfo.groupId
log(`Group ${groupId} deleted`)
this.conversations.delete(groupId)
this.cleanupGrokMaps(groupId)
if (chatItem.chatDir.type !== "groupRcv") return
const itemId = chatItem.meta.itemId
const key = `${groupId}:${itemId}`
const entry = this.forwardedItems.get(key)
if (!entry) return
const text = util.ciContentText(chatItem)?.trim()
if (!text) return
const fwd = `${entry.prefix}${text}`
try {
await this.mainChat.apiUpdateChatItem(
T.ChatType.Group,
this.config.teamGroup.id,
entry.teamItemId,
{type: "text", text: fwd},
false,
)
} catch (err) {
logError(`Failed to forward edit to team for group ${groupId}, item ${itemId}`, err)
}
}
onMemberConnected(evt: CEvt.ConnectedToGroupMember): void {
@@ -124,9 +235,9 @@ export class SupportBot {
}
// Join request sent — set maps, but don't resolve waiter yet.
// The waiter resolves when grokChat fires connectedToGroupMember (see onGrokMemberConnected).
this.grokGroupMap.set(mainGroupId, evt.groupInfo.groupId)
this.reverseGrokMap.set(evt.groupInfo.groupId, mainGroupId)
this.onGrokMapChanged?.(this.grokGroupMap)
}
onGrokMemberConnected(evt: CEvt.ConnectedToGroupMember): void {
@@ -147,108 +258,119 @@ export class SupportBot {
const {chatInfo, chatItem} = ci
if (chatInfo.type !== "group") return
const groupInfo = chatInfo.groupInfo
if (!groupInfo.businessChat) return
const groupId = groupInfo.groupId
let state = this.conversations.get(groupId)
if (!state) {
// After restart, re-initialize state for existing business chats
state = {type: "teamQueue", userMessages: []}
this.conversations.set(groupId, state)
log(`Re-initialized conversation state for group ${groupId} after restart`)
// Handle /add command in team group
if (groupId === this.config.teamGroup.id) {
await this.processTeamGroupMessage(chatItem)
return
}
if (!groupInfo.businessChat) return
if (chatItem.chatDir.type === "groupSnd") return
if (chatItem.chatDir.type !== "groupRcv") return
const sender = chatItem.chatDir.groupMember
const isCustomer = sender.memberId === groupInfo.businessChat.customerId
const isTeamMember = (state.type === "teamPending" || state.type === "teamLocked")
&& sender.groupMemberId === state.teamMemberGId
const isGrok = state.type === "grokMode"
&& state.grokMemberGId === sender.groupMemberId
if (isGrok) return
if (isCustomer) await this.onCustomerMessage(groupId, groupInfo, chatItem, state)
else if (isTeamMember) await this.onTeamMemberMessage(groupId, state)
if (!isCustomer) {
// Team member message → forward to team group
if (this.config.teamMembers.some(tm => tm.id === sender.memberContactId)) {
const text = util.ciContentText(chatItem)?.trim()
if (text) {
const customerName = groupInfo.groupProfile.displayName || `group-${groupId}`
const teamMemberName = sender.memberProfile.displayName
const contactId = sender.memberContactId
const itemId = chatItem.meta?.itemId
const prefix = `${teamMemberName}:${contactId} > ${customerName}:${groupId}: `
await this.forwardToTeam(groupId, prefix, text, itemId)
}
}
return
}
// Customer message — derive state from group composition
const {grokMember, teamMember} = await this.getGroupComposition(groupId)
if (teamMember) {
await this.handleTeamMode(groupId, chatItem)
} else if (grokMember) {
await this.handleGrokMode(groupId, groupInfo, chatItem, grokMember)
} else {
await this.handleNoSpecialMembers(groupId, groupInfo, chatItem)
}
}
private async onCustomerMessage(
// Customer message when a team member is present (teamPending or teamLocked)
private async handleTeamMode(groupId: number, chatItem: T.ChatItem): Promise<void> {
const cmd = util.ciBotCommand(chatItem)
if (cmd?.keyword === "grok") {
await this.sendToGroup(groupId, teamLockedMessage)
}
// /team → ignore (already team). Other text → no forwarding (team sees directly).
}
// Customer message when Grok is present
private async handleGrokMode(
groupId: number,
groupInfo: T.GroupInfo,
chatItem: T.ChatItem,
state: ConversationState,
grokMember: T.GroupMember,
): Promise<void> {
const cmd = util.ciBotCommand(chatItem)
const text = util.ciContentText(chatItem)?.trim() || null
switch (state.type) {
case "welcome": {
if (!text) return
await this.forwardToTeam(groupId, groupInfo, text)
await this.sendToGroup(groupId, teamQueueMessage(this.config.timezone))
this.conversations.set(groupId, {type: "teamQueue", userMessages: [text]})
break
}
case "teamQueue": {
if (cmd?.keyword === "grok") {
await this.activateGrok(groupId, state)
return
}
if (cmd?.keyword === "team") {
await this.activateTeam(groupId, state)
return
}
if (!text) return
await this.forwardToTeam(groupId, groupInfo, text)
state.userMessages.push(text)
break
}
case "grokMode": {
if (cmd?.keyword === "grok") return
if (cmd?.keyword === "team") {
await this.activateTeam(groupId, state)
return
}
if (!text) return
await this.forwardToTeam(groupId, groupInfo, text)
await this.forwardToGrok(groupId, text, state)
break
}
case "teamPending": {
if (cmd?.keyword === "grok") {
await this.sendToGroup(groupId, teamLockedMessage)
return
}
// /team → ignore (already team). Other text → no forwarding (team sees directly).
break
}
case "teamLocked": {
if (cmd?.keyword === "grok") {
await this.sendToGroup(groupId, teamLockedMessage)
return
}
// No action — team sees directly
break
}
if (cmd?.keyword === "grok") return // already in grok mode
if (cmd?.keyword === "team") {
await this.activateTeam(groupId, grokMember)
return
}
if (!text) return
const prefix = this.customerForwardPrefix(groupId, groupInfo)
await this.forwardToTeam(groupId, prefix, text, chatItem.meta?.itemId)
await this.forwardToGrok(groupId, groupInfo, text, grokMember)
}
private async onTeamMemberMessage(groupId: number, state: ConversationState): Promise<void> {
if (state.type !== "teamPending") return
log(`Team member engaged in group ${groupId}, locking to teamLocked`)
this.conversations.set(groupId, {type: "teamLocked", teamMemberGId: state.teamMemberGId})
// Customer message when neither Grok nor team is present (welcome or teamQueue)
private async handleNoSpecialMembers(
groupId: number,
groupInfo: T.GroupInfo,
chatItem: T.ChatItem,
): Promise<void> {
const cmd = util.ciBotCommand(chatItem)
const text = util.ciContentText(chatItem)?.trim() || null
// Check if this is the first customer message (welcome state)
const firstMessage = await this.isFirstCustomerMessage(groupId)
if (firstMessage) {
// Welcome state — first message transitions to teamQueue
if (!text) return
const prefix = this.customerForwardPrefix(groupId, groupInfo)
await this.forwardToTeam(groupId, prefix, text, chatItem.meta?.itemId)
await this.sendToGroup(groupId, teamQueueMessage(this.config.timezone))
await this.sendAddCommand(groupId, groupInfo)
return
}
// teamQueue state
if (cmd?.keyword === "grok") {
await this.activateGrok(groupId, groupInfo)
return
}
if (cmd?.keyword === "team") {
await this.activateTeam(groupId, undefined)
return
}
if (!text) return
const prefix = this.customerForwardPrefix(groupId, groupInfo)
await this.forwardToTeam(groupId, prefix, text, chatItem.meta?.itemId)
}
// --- Grok Activation ---
private async activateGrok(
groupId: number,
state: {type: "teamQueue"; userMessages: string[]},
): Promise<void> {
private async activateGrok(groupId: number, groupInfo: T.GroupInfo): Promise<void> {
if (this.config.grokContactId === null) {
await this.sendToGroup(groupId, "Grok is temporarily unavailable. Please try again or click /team for a team member.")
return
@@ -274,10 +396,10 @@ export class SupportBot {
return
}
// Verify state hasn't changed while awaiting (e.g., user sent /team concurrently)
const currentState = this.conversations.get(groupId)
if (!currentState || currentState.type !== "teamQueue") {
log(`State changed during Grok activation for group ${groupId} (now ${currentState?.type}), aborting`)
// Verify group composition hasn't changed while awaiting (e.g., user sent /team concurrently)
const {teamMember} = await this.getGroupComposition(groupId)
if (teamMember) {
log(`Team member appeared during Grok activation for group ${groupId}, aborting`)
try {
await this.mainChat.apiRemoveMembers(groupId, [member.groupMemberId])
} catch {
@@ -287,15 +409,17 @@ export class SupportBot {
return
}
// Grok joined — call API with accumulated messages
// Grok joined — call API with accumulated customer messages from chat history
try {
const initialUserMsg = state.userMessages.join("\n")
const customerId = groupInfo.businessChat!.customerId
const customerMessages = await this.getCustomerMessages(groupId, customerId)
const initialUserMsg = customerMessages.join("\n")
const response = await this.grokApi.chat([], initialUserMsg)
// Re-check state after async API call — another event may have changed it
const postApiState = this.conversations.get(groupId)
if (!postApiState || postApiState.type !== "teamQueue") {
log(`State changed during Grok API call for group ${groupId} (now ${postApiState?.type}), aborting`)
// Re-check composition after async API call
const postApi = await this.getGroupComposition(groupId)
if (postApi.teamMember) {
log(`Team member appeared during Grok API call for group ${groupId}, aborting`)
try {
await this.mainChat.apiRemoveMembers(groupId, [member.groupMemberId])
} catch {
@@ -305,26 +429,14 @@ export class SupportBot {
return
}
const history: GrokMessage[] = [
{role: "user", content: initialUserMsg},
{role: "assistant", content: response},
]
const grokLocalGId = this.grokGroupMap.get(groupId)
if (grokLocalGId === undefined) {
log(`Grok map entry missing after join for group ${groupId}`)
return
}
await this.grokChat.apiSendTextMessage([T.ChatType.Group, grokLocalGId], response)
this.conversations.set(groupId, {
type: "grokMode",
grokMemberGId: member.groupMemberId,
history,
})
} catch (err) {
logError(`Grok API/send failed for group ${groupId}`, err)
// Remove Grok since activation failed after join
try {
await this.mainChat.apiRemoveMembers(groupId, [member.groupMemberId])
} catch {
@@ -332,7 +444,6 @@ export class SupportBot {
}
this.cleanupGrokMaps(groupId)
await this.sendToGroup(groupId, "Grok is temporarily unavailable. Please try again or click /team for a team member.")
// Stay in teamQueue
}
}
@@ -340,13 +451,14 @@ export class SupportBot {
private async forwardToGrok(
groupId: number,
groupInfo: T.GroupInfo,
text: string,
state: {type: "grokMode"; grokMemberGId: number; history: GrokMessage[]},
grokMember: T.GroupMember,
): Promise<void> {
try {
const response = await this.grokApi.chat(state.history, text)
state.history.push({role: "user", content: text})
state.history.push({role: "assistant", content: response})
const customerId = groupInfo.businessChat!.customerId
const history = await this.getGrokHistory(groupId, grokMember, customerId)
const response = await this.grokApi.chat(history, text)
const grokLocalGId = this.grokGroupMap.get(groupId)
if (grokLocalGId !== undefined) {
@@ -354,39 +466,39 @@ export class SupportBot {
}
} catch (err) {
logError(`Grok API error for group ${groupId}`, err)
// Per plan: revert to teamQueue on Grok API failure — remove Grok, clean up
try {
await this.mainChat.apiRemoveMembers(groupId, [state.grokMemberGId])
await this.mainChat.apiRemoveMembers(groupId, [grokMember.groupMemberId])
} catch {
// ignore — may have already left
}
this.cleanupGrokMaps(groupId)
this.conversations.set(groupId, {type: "teamQueue", userMessages: []})
await this.sendToGroup(groupId, "Grok is temporarily unavailable. Please try again or click /team for a team member.")
}
}
// --- Team Actions ---
private async forwardToTeam(groupId: number, groupInfo: T.GroupInfo, text: string): Promise<void> {
const name = groupInfo.groupProfile.displayName || `group-${groupId}`
const fwd = `[${name} #${groupId}]\n${text}`
private async forwardToTeam(groupId: number, prefix: string, text: string, sourceItemId?: number): Promise<void> {
const fwd = `${prefix}${text}`
try {
await this.mainChat.apiSendTextMessage(
const result = await this.mainChat.apiSendTextMessage(
[T.ChatType.Group, this.config.teamGroup.id],
fwd,
)
if (sourceItemId !== undefined && result && result[0]) {
const teamItemId = result[0].chatItem.meta.itemId
this.forwardedItems.set(`${groupId}:${sourceItemId}`, {teamItemId, prefix})
}
} catch (err) {
logError(`Failed to forward to team for group ${groupId}`, err)
}
}
private async activateTeam(groupId: number, state: ConversationState): Promise<void> {
// Remove Grok immediately if present (per spec: "When switching to team mode, Grok is removed")
const wasGrokMode = state.type === "grokMode"
if (wasGrokMode) {
private async activateTeam(groupId: number, grokMember: T.GroupMember | undefined): Promise<void> {
// Remove Grok immediately if present
if (grokMember) {
try {
await this.mainChat.apiRemoveMembers(groupId, [state.grokMemberGId])
await this.mainChat.apiRemoveMembers(groupId, [grokMember.groupMemberId])
} catch {
// ignore — may have already left
}
@@ -394,9 +506,6 @@ export class SupportBot {
}
if (this.config.teamMembers.length === 0) {
logError(`No team members configured, cannot add team member to group ${groupId}`, new Error("no team members"))
if (wasGrokMode) {
this.conversations.set(groupId, {type: "teamQueue", userMessages: []})
}
await this.sendToGroup(groupId, "No team members are available yet. Please try again later or click /grok.")
return
}
@@ -404,41 +513,58 @@ export class SupportBot {
const teamContactId = this.config.teamMembers[0].id
const member = await this.addOrFindTeamMember(groupId, teamContactId)
if (!member) {
if (wasGrokMode) {
this.conversations.set(groupId, {type: "teamQueue", userMessages: []})
}
await this.sendToGroup(groupId, "Sorry, there was an error adding a team member. Please try again.")
return
}
this.conversations.set(groupId, {
type: "teamPending",
teamMemberGId: member.groupMemberId,
})
await this.sendToGroup(groupId, teamAddedMessage(this.config.timezone))
} catch (err) {
logError(`Failed to add team member to group ${groupId}`, err)
// If Grok was removed, state is stale (grokMode but Grok gone) — revert to teamQueue
if (wasGrokMode) {
this.conversations.set(groupId, {type: "teamQueue", userMessages: []})
}
await this.sendToGroup(groupId, "Sorry, there was an error adding a team member. Please try again.")
}
}
private customerForwardPrefix(groupId: number, groupInfo: T.GroupInfo): string {
const name = groupInfo.groupProfile.displayName || `group-${groupId}`
return `${name}:${groupId}: `
}
// --- Team Group Commands ---
private async processTeamGroupMessage(chatItem: T.ChatItem): Promise<void> {
if (chatItem.chatDir.type !== "groupRcv") return
const text = util.ciContentText(chatItem)?.trim()
if (!text) return
const match = text.match(/^\/add\s+(\d+):/)
if (!match) return
const targetGroupId = parseInt(match[1])
const senderContactId = chatItem.chatDir.groupMember.memberContactId
if (!senderContactId) return
try {
await this.addOrFindTeamMember(targetGroupId, senderContactId)
log(`Team member ${senderContactId} added to group ${targetGroupId} via /add command`)
} catch (err) {
logError(`Failed to add team member to group ${targetGroupId} via /add`, err)
}
}
private async sendAddCommand(groupId: number, groupInfo: T.GroupInfo): Promise<void> {
const name = groupInfo.groupProfile.displayName || `group-${groupId}`
const formatted = name.includes(" ") ? `'${name}'` : name
const cmd = `/add ${groupId}:${formatted}`
await this.sendToGroup(this.config.teamGroup.id, cmd)
}
// --- Helpers ---
private async addReplacementTeamMember(groupId: number): Promise<void> {
if (this.config.teamMembers.length === 0) return
try {
const teamContactId = this.config.teamMembers[0].id
const member = await this.addOrFindTeamMember(groupId, teamContactId)
if (member) {
this.conversations.set(groupId, {type: "teamLocked", teamMemberGId: member.groupMemberId})
}
await this.addOrFindTeamMember(groupId, teamContactId)
} catch (err) {
logError(`Failed to add replacement team member to group ${groupId}`, err)
// Stay in teamLocked with stale teamMemberGId — one-way gate must hold
// Team will see the message in team group and can join manually
}
}
@@ -447,7 +573,6 @@ export class SupportBot {
return await this.mainChat.apiAddMember(groupId, teamContactId, T.GroupMemberRole.Member)
} catch (err: any) {
if (err?.chatError?.errorType?.type === "groupDuplicateMember") {
// Team member already in group (e.g., from previous session) — find existing member
log(`Team member already in group ${groupId}, looking up existing member`)
const members = await this.mainChat.apiListMembers(groupId)
const existing = members.find(m => m.memberContactId === teamContactId)
@@ -486,9 +611,9 @@ export class SupportBot {
private cleanupGrokMaps(groupId: number): void {
const grokLocalGId = this.grokGroupMap.get(groupId)
if (grokLocalGId === undefined) return
this.grokGroupMap.delete(groupId)
if (grokLocalGId !== undefined) {
this.reverseGrokMap.delete(grokLocalGId)
}
this.reverseGrokMap.delete(grokLocalGId)
this.onGrokMapChanged?.(this.grokGroupMap)
}
}

View File

@@ -40,6 +40,6 @@ export class GrokApiClient {
}
private systemPrompt(): string {
return `You are a privacy expert and SimpleX Chat evangelist. You know everything about SimpleX Chat apps, network, design choices, and trade-offs. Be helpful, accurate, and concise. If you don't know something, say so honestly rather than guessing. For every criticism, explain why the team made that design choice.\n\n${this.docsContext}`
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 — no bold, italic, headers, or code blocks.\n- Avoid filler, preambles, and repeating the question back.\n\n${this.docsContext}`
}
}

File diff suppressed because one or more lines are too long

View File

@@ -6,7 +6,7 @@ export function welcomeMessage(groupLinks: string): string {
export 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.`
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.`
}
export 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.`

View File

@@ -0,0 +1,41 @@
import {existsSync} from "fs"
import {execSync} from "child_process"
import {log, logError} from "./util.js"
// Resolve display_names table conflicts before bot.run updates the profile.
// The SimpleX Chat store enforces unique (user_id, local_display_name) in display_names.
// If the desired name is already used by a contact or group, the profile update fails
// with duplicateName. This renames the conflicting entry to free up the name.
export function resolveDisplayNameConflict(dbPrefix: string, desiredName: string): void {
const dbFile = `${dbPrefix}_chat.db`
if (!existsSync(dbFile)) return
const esc = desiredName.replace(/'/g, "''")
try {
// If user already has this display name, no conflict — Haskell takes the no-change branch
const isUserName = execSync(
`sqlite3 "${dbFile}" "SELECT COUNT(*) FROM users WHERE local_display_name = '${esc}'"`,
{encoding: "utf-8"}
).trim()
if (isUserName !== "0") return
// Check if the name exists in display_names at all
const count = execSync(
`sqlite3 "${dbFile}" "SELECT COUNT(*) FROM display_names WHERE local_display_name = '${esc}'"`,
{encoding: "utf-8"}
).trim()
if (count === "0") return
// Rename the conflicting entry (contact/group) to free the name
const newName = `${esc}_1`
log(`Display name conflict: "${desiredName}" already in display_names, renaming to "${newName}"`)
const sql = [
`UPDATE contacts SET local_display_name = '${newName}' WHERE local_display_name = '${esc}';`,
`UPDATE groups SET local_display_name = '${newName}' WHERE local_display_name = '${esc}';`,
`UPDATE display_names SET local_display_name = '${newName}', ldn_suffix = 1 WHERE local_display_name = '${esc}';`,
].join(" ")
execSync(`sqlite3 "${dbFile}" "${sql}"`, {encoding: "utf-8"})
log("Display name conflict resolved")
} catch (err) {
logError("Failed to resolve display name conflict (sqlite3 may not be available)", err)
}
}

View File

@@ -2,10 +2,3 @@ export interface GrokMessage {
role: "user" | "assistant"
content: string
}
export type ConversationState =
| {type: "welcome"}
| {type: "teamQueue"; userMessages: string[]}
| {type: "grokMode"; grokMemberGId: number; history: GrokMessage[]}
| {type: "teamPending"; teamMemberGId: number}
| {type: "teamLocked"; teamMemberGId: number}