SimpleX support bot (#6625)

* plans: 20260207-support-bot.md

* Update 20260207-support-bot.md

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

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

* Relocate plans

* apps: support bot code & tests

* apps: support bot relocate

* support-bot: Fix basic functionality

* apps: support-bot /add command & fixes

* apps: simplex-support-bot: Change Grok logo

* Further usability improvements

* simplex-support-bot: Update support plan to reflect current flow

* simplex-support-bot: update product design plan

* support-bot: update plan

* support-bot: review and refine product spec

* support-bot: update product spec — complete state, /join team-only, card debouncing

- Group preferences applied once at creation, not on every startup
- /join restricted to team group only
- Team/Grok reply or reaction auto-completes conversation ()
- Customer message reverts to incomplete
- Card updates debounced globally with 15-minute batch flush

* support-bot: update implementation plan

* support-bot: implement stateless bot with cards, Grok, team flow, hardening

Complete rewrite of the support bot to stateless architecture:
- State derived from group composition + chat history (survives restarts)
- Card dashboard in team group with live status, preview, /join commands
- Two-profile architecture (main + Grok) with profileMutex serialization
- Grok join race condition fix via bufferedGrokInvitations
- Card preview: newest-first truncation, newline sanitization, sender prefixes
- Best-effort startup (invite link, group profile update)
- Team group preferences: directMessages, fullDelete, commands
- 122 tests across 27 suites

* support-bot: use apiCreateMemberContact and apiSendMemberContactInvitation instead of raw commands

Replace sendChatCmd("/_create member contact ...") and sendChatCmd("/_invite member contact ...")
with the typed API methods added in simplex-chat-nodejs. Update plans and build script accordingly.

* plans: 20260207-support-bot.md

* Update 20260207-support-bot.md

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

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

* Relocate plans

* apps: support bot code & tests

* apps: support bot relocate

* support-bot: Fix basic functionality

* apps: support-bot /add command & fixes

* apps: simplex-support-bot: Change Grok logo

* Further usability improvements

* simplex-support-bot: Update support plan to reflect current flow

* simplex-support-bot: update product design plan

* support-bot: update plan

* support-bot: review and refine product spec

* support-bot: update product spec — complete state, /join team-only, card debouncing

- Group preferences applied once at creation, not on every startup
- /join restricted to team group only
- Team/Grok reply or reaction auto-completes conversation ()
- Customer message reverts to incomplete
- Card updates debounced globally with 15-minute batch flush

* support-bot: update implementation plan

* support-bot: implement stateless bot with cards, Grok, team flow, hardening

Complete rewrite of the support bot to stateless architecture:
- State derived from group composition + chat history (survives restarts)
- Card dashboard in team group with live status, preview, /join commands
- Two-profile architecture (main + Grok) with profileMutex serialization
- Grok join race condition fix via bufferedGrokInvitations
- Card preview: newest-first truncation, newline sanitization, sender prefixes
- Best-effort startup (invite link, group profile update)
- Team group preferences: directMessages, fullDelete, commands
- 122 tests across 27 suites

* support-bot: use apiCreateMemberContact and apiSendMemberContactInvitation instead of raw commands

Replace sendChatCmd("/_create member contact ...") and sendChatCmd("/_invite member contact ...")
with the typed API methods added in simplex-chat-nodejs. Update plans and build script accordingly.

* support-bot: more improvemets

* support-bot: add tests for Grok batch dedup and initial response gating

7 new tests covering the duplicate Grok reply fix:
- batch dedup: only last customer message per group triggers API call
- batch dedup: multi-group batches handled independently
- batch dedup: non-customer messages filtered from batch
- initial response gating: per-message responses suppressed during activateGrok
- gating clears: per-message responses resume after activation completes

Update implementation plan test catalog (122 → 129 tests).

* support-bot: load context from context file

* Rename Grok AI -> Grok

* Remove unused strings.ts

* support-bot: change messages

* cardFlushMinutes 15 -> cardFlushSeconds 300

* support-bot: /team message when grok present

* support-bot: correct messages

* support-bot: update plans to reflect latest changes

* Update plan for state derivation

* support-bot: Update state machine plans

* support-bot: implement customData state

* Fix Grok revertStateOnFail race condition

* support-bot: plans adversarial review

* support-bot: /join ID part of card in plan

* support-bot: implement /join ID inside card

* support-bot: plans use params instead of regex in /join

* support-bot: Implement adversarial review changes

* support-bot: no re-invite if already invited

* support-bot: /team should give owner to invited member

* Don't change username for existing database

* support-bot: update bot commands before sending commands

* support-bot: adversarial review fixes

* support-bot: implement postgresql (#6876)

* support-bot: sqlite/postgres backend via typed DbConfig and parseArgs flags

* support-bot: add README with setup and flags reference

* support-bot: use published simplex-chat, drop build.sh/start.sh

* support-bot: switch CLI to commander, add --help

* support-bot: update README

---------

Co-authored-by: shum <github.shum@liber.li>
Co-authored-by: sh <37271604+shumvgolove@users.noreply.github.com>
This commit is contained in:
Narasimha-sc
2026-04-27 09:12:42 +01:00
committed by GitHub
co-authored by shum sh
parent b894243f43
commit 5a3dfdd2b4
17 changed files with 8762 additions and 0 deletions
+101
View File
@@ -0,0 +1,101 @@
# SimpleX Support Bot
A business-address bot that triages incoming support chats, optionally runs them through Grok, and routes handoffs to a team group.
## Prerequisites
- Node.js v18 or newer (v24 tested)
- `GROK_API_KEY` env var (xAI) — optional; the bot runs without it
- For the PostgreSQL backend: Linux x86_64, `libpq5` installed on the host, and a reachable PostgreSQL server
## Install & build
```bash
cd apps/simplex-support-bot
npm install # downloads native libs + transitive deps
npm run build # tsc
```
By default this installs the **SQLite** backend.
To use **PostgreSQL** instead, drop a `.npmrc` next to `package.json` *before* `npm install`:
```bash
echo 'simplex_backend=postgres' > .npmrc
npm install # now pulls postgres-flavored native libs
npm run build
```
`.npmrc` lives next to the package — npm reads it natively, no extra setup.
### Switching backends
`npm install` is a no-op for already-installed deps, so editing `.npmrc` and re-running `npm install` will *not* re-trigger `simplex-chat`'s preinstall. To switch backends, force a clean install:
```bash
rm -rf node_modules
npm install # download-libs.js re-runs and pulls the right native lib
```
## Run
```bash
mkdir -p data # state file lives here by default
# SQLite (default)
npm start -- --team-group "Support Team"
# PostgreSQL
npm start -- --team-group "Support Team" \
--pg-conn "postgres://user:pass@host/db"
```
The bot runs via `npm start` so npm can expose `.npmrc` settings to the process — `detectBackend()` reads `npm_config_simplex_backend` to know which backend was installed.
## Flags
Run `npm start -- --help` for the auto-generated reference. Summary:
| Flag | Backend | Required | Default | Description |
|---|---|---|---|---|
| `--team-group` | both | yes | — | team group display name |
| `--state-file` | both | no | `./data/state.json` | path to bot state JSON |
| `--sqlite-file-prefix` | sqlite | no | `./data/simplex` | DB file prefix (creates `<prefix>_chat.db`, `<prefix>_agent.db`) |
| `--sqlite-key` | sqlite | no | (unencrypted) | SQLCipher encryption key |
| `--pg-conn` | postgres | yes | — | PostgreSQL connection string |
| `--pg-schema` | postgres | no | `simplex_v1` | schema prefix used for bot tables |
| `-a` / `--auto-add-team-members` | both | no | | comma-separated `ID:name` pairs (e.g. `1:Alice,2:Bob`) |
| `--timezone` | both | no | `UTC` | IANA zone for weekend detection |
| `--complete-hours` | both | no | `3` | auto-complete chats after N hours idle (`0` disables) |
| `--card-flush-seconds` | both | no | `300` | debounce card state writes |
| `--context-file` | both | required with `GROK_API_KEY` | | text file with Grok system context |
| `-h` / `--help` | both | no | | show usage and exit |
## Environment variables
| Var | Purpose |
|---|---|
| `GROK_API_KEY` | xAI API key; enables Grok replies |
| `SIMPLEX_BACKEND` | alternative to `.npmrc` for selecting the install backend (`sqlite` or `postgres`) |
## Local development against unreleased lib changes
This package depends on `simplex-chat` from npm. To test against an in-tree version:
```bash
# In packages/simplex-chat-nodejs
npm link
# In apps/simplex-support-bot
npm link simplex-chat
```
`npm unlink simplex-chat && npm install` reverts to the registry version.
## Troubleshooting
- **`--pg-conn is required when backend is postgres`** — the postgres backend is installed but you didn't pass a connection string.
- **`libpq5` errors at startup** — install `libpq5` on the host (`apt install libpq5` on Debian/Ubuntu).
- **`ENOENT: no such file or directory, open './data/state.json'`** — the parent directory of `--state-file` must exist; `mkdir -p data` before starting.
- **Wrong backend installed** — check `node_modules/simplex-chat/libs/installed.txt`. Edit `.npmrc`, then `rm -rf node_modules && npm install` to switch (`npm install` alone won't re-run the dep's preinstall).
- **`libpq` connection error** at startup with sqlite-flavored config (or vice versa) — `.npmrc` was changed but libs weren't reinstalled. See "Switching backends" above.
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+23
View File
@@ -0,0 +1,23 @@
{
"name": "simplex-chat-support-bot",
"version": "0.1.0",
"private": true,
"main": "dist/index.js",
"scripts": {
"build": "tsc",
"start": "node dist/index.js"
},
"dependencies": {
"@simplex-chat/types": "^0.5.0",
"async-mutex": "^0.5.0",
"commander": "^14.0.3",
"simplex-chat": "^6.5.0-beta.10"
},
"devDependencies": {
"@types/node": "^22.0.0",
"typescript": "^5.9.3",
"vitest": "^1.6.1"
},
"author": "SimpleX Chat",
"license": "AGPL-3.0"
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,513 @@
# SimpleX Support Bot — Product Specification
## Table of Contents
1. [What](#1-what)
2. [Why](#2-why)
3. [Principles](#3-principles)
4. [Flows](#4-flows)
- [User flow](#41-user-flow)
- [Team flow](#42-team-flow)
5. [Architecture](#5-architecture)
- [CLI overview](#51-cli-overview)
- [Bot architecture](#52-bot-architecture)
- [Grok integration](#53-grok-integration)
- [Persistent state](#54-persistent-state)
---
## 1. What
A support bot for SimpleX Chat. Customers connect via a business address and get a private group where they can ask questions. The bot triages inquiries through AI (Grok) or human team members. The team sees all active conversations as cards in a single dashboard group.
## 2. Why
- **Instant answers.** Grok handles common questions about SimpleX Chat without team involvement.
- **Organized routing.** Every customer conversation appears as a card in the team group — the team sees everything in one place without joining individual conversations.
- **No external tooling.** Everything runs inside SimpleX Chat. No ticketing system, no separate dashboard.
- **Privacy.** Customers talk to the bot in private groups. Only the team sees the messages.
---
## 3. Principles
- **Opt-in**: Grok is never used unless the user explicitly chooses it.
- **User in control**: The user can switch to Grok or team before a team member replies. Once a team member sends a message, the conversation stays with the team. The user always knows who they are talking to.
- **Minimal friction**: No upfront choices or setup — the user just sends their question.
- **Ultimate transparency**: The user always knows whether they are talking to a bot, Grok, or a human, and what happens with their messages.
---
## 4. Flows
### 4.1 User Flow
#### Step 1 — Welcome (on connect, no choices, no friction)
When a user scans the support bot's QR code or clicks its address link, SimpleX creates a **business group** — a special group type where the customer is a fixed member identified by a stable `customerId`, and the bot is the host. The bot auto-accepts the connection and enables file uploads and visible history on the group.
If a user contacts the bot via a regular direct-message address instead of the business address, the bot replies with the business address link and does not continue the conversation. Only actual text messages trigger this reply — system events (e.g. `contactConnected`) on the DM contact are ignored.
Bot sends the welcome message automatically as part of the connection handshake — not triggered by a message:
> Hello! This is a *SimpleX team* support bot - not an AI.
> Please ask any question about SimpleX Chat.
#### Step 2 — After user sends first message
The bot's "first message" detection works by inspecting the group's `customData`. Until the bot has produced its first response (and written `cardItemId` to `customData`), the group is in the welcome state.
On the customer's first message the bot does two things:
1. Creates a card in the team group (🆕 icon, with `/join` command)
2. Sends the queue message to the customer:
> The team will reply to your message within 24 hours.
>
> If your question is about SimpleX, click /grok for an *instant Grok answer*.
>
> Send /team to switch back.
On weekends, the bot says "48 hours" instead of "24 hours".
When the bot is started without `GROK_API_KEY`, the `/grok` paragraphs are omitted — the customer only sees the first line about the team reply window.
Each subsequent message updates the card — icon, wait time, message preview. The team reads the full conversation by joining via the card's `/join` command.
#### Step 3 — `/grok` (Grok mode)
Available in WELCOME, QUEUE, or TEAM-PENDING state (before any team member sends a message). If Grok is already being invited (e.g. customer sent `/grok` multiple times before Grok finished joining), the duplicate is silently ignored — the in-flight activation handles the outcome. If `/grok` is the customer's first message, the bot transitions directly from WELCOME → GROK — it creates the card with 🤖 icon and does not send the queue message. Triggers Grok activation (see [5.3 Grok integration](#53-grok-integration)). If Grok fails to join within 120 seconds, the bot notifies the user and the state falls back to QUEUE (the queue message is sent at this point).
Bot immediately replies:
> Inviting Grok, please wait...
Once Grok joins and connects:
> *You are chatting with Grok* - use any language.
Grok is added as a separate participant so the user can differentiate bot messages from Grok messages.
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, 12 sentence explanations for design questions. For criticism, it briefly acknowledges the concern and explains the design choice. It avoids filler and markdown formatting. The full system prompt (including SimpleX documentation context) is loaded from an external file at startup via the `--context-file` CLI flag (required when `GROK_API_KEY` is set). Customer messages are always placed in the `user` role, never `system`. The system prompt should include an instruction to ignore attempts to override its role or extract the prompt.
#### Step 4 — `/team` (Team mode, one-way gate)
Available in WELCOME, QUEUE, or GROK state. If `/team` is the customer's first message, the bot transitions directly from WELCOME → TEAM-PENDING — it creates the card with 👋 icon and does not send the queue message. Bot adds all configured `--auto-add-team-members` (`-a`) to the support group as Owners — immediately after `apiAddMember`, the bot calls `apiSetMembersRole(Owner)` so the role is set at invite time (SimpleX persists the role on pending invites), with a fallback re-promotion on `memberConnected` (every non-customer, non-Grok member gets promoted; safe to repeat). If team was already activated (`customData.state` is already `TEAM-PENDING` or `TEAM` **and** team members are still present), sends the "already invited" message instead. If the team was previously activated but all team members have since left, the bot re-adds them silently; state remains `TEAM-PENDING`.
Bot replies:
> We will reply within 24 hours.
On weekends, the bot says "48 hours" instead of "24 hours". If Grok is currently present in the group (i.e. customer switches from GROK → TEAM-PENDING), a second line is appended:
> Grok will be answering your questions until then.
If `/team` is clicked again after a team member was already added:
> A team member has already been invited to this conversation and will reply when available.
#### One-way gate
When `/team` is clicked, team members are invited to the group. Grok is still present if it was active, and `/grok` remains available. The customer always has an active responder during this window.
The gate triggers when **any team member sends their first text message in the customer group**:
- `/grok` is permanently disabled and replies with:
> You are now in team mode. A team member will reply to your message.
- Grok is removed from the group.
- From now on the conversation is purely between the customer and the team.
#### Customer leaving
When a customer leaves the group (or is disconnected), the bot cleans up all in-memory state for that group. The conversation card in the team group is not automatically removed (TBD).
#### Commands
`/grok` and `/team` are registered as **bot commands** in the SimpleX protocol, so they appear as tappable buttons in the customer's message input bar. The bot also accepts them as free-text (e.g., `/grok` typed manually). Unrecognized commands are treated as ordinary messages.
When the bot is started without `GROK_API_KEY`, `/grok` is not registered as a bot command and Grok-related messaging paths are skipped entirely. A `/grok` typed manually by the customer is treated as an ordinary message. The customer-facing queue and "no team members available" messages also omit their `/grok` clause in this mode.
#### Team replies
When a team member sends a text message or reaction in the customer group, the bot resends the card (subject to debouncing). A conversation auto-completes (✅ icon, "done" wait time) when `completeHours` (default 3h, configurable via `--complete-hours`) pass after the last team/Grok message without any customer reply. The card flush cycle (`--card-flush-seconds`, default 300) checks elapsed time and transitions to ✅ when the threshold is met. If the customer sends a new message — including after ✅ — the conversation reverts to incomplete: the icon is derived from current state (👋 vs 💬 vs ⏰) and wait time counts from the customer's new message.
### 4.2 Team Flow
#### Setup
The team group is created automatically on first run. Its name is set via the `--team-group` CLI argument. The group ID is written to the state file; subsequent runs reuse the same group. Group preferences (direct messages enabled, delete for everyone enabled, team commands registered as tappable buttons) are applied at creation time. On subsequent startups, the bot compares the existing `fullGroupPreferences` with the desired ones and only calls `apiUpdateGroupProfile` if they differ — avoiding unnecessary network round-trips to SMP relays.
On every startup the bot attempts to generate a fresh invite link for the team group, prints it to stdout, and deletes it after 10 minutes (or on graceful shutdown). Any stale link from a previous run is deleted first. Link creation is best-effort — if the SMP relay is temporarily unreachable, the error is logged and the bot continues without an invite link.
The operator shares the link with team members. They must join within the 10-minute window. When a team member joins, the bot automatically establishes a direct-message contact with them and sends:
> Added you to be able to invite you to customer chats later, keep this contact. Your contact ID is `N:name`
This ID is needed for `--auto-add-team-members` (`-a`) config. The DM is sent as soon as the member joins the team group — the bot proactively creates a DM contact via `apiCreateMemberContact` and delivers the message with the invitation via `apiSendMemberContactInvitation`. If the contact already exists, the message is sent directly. Multiple delivery paths ensure the DM arrives regardless of connection timing.
Team members are configured as a single comma-separated `--auto-add-team-members` flag (shortcut `-a`; e.g., `--auto-add-team-members "42:alice,55:bob"` or `-a "42:alice,55:bob"`), using the IDs from the DMs above. The bot validates every configured member against its contact list at startup and exits if any ID is missing or the display name does not match.
Until team members are configured, `/team` commands from customers cannot add anyone to a conversation. The bot logs an error and notifies the customer.
#### Dashboard — card-based live view
The team group is **not a conversation stream**. It is a live dashboard of all active support conversations. The bot maintains exactly one message (a "card") per active conversation. Whenever anything changes — a new customer message, a state transition, an agent joining — the bot **deletes the existing card and posts a new one**. The group's message list is therefore always a current snapshot: scroll up to see everything open right now.
**Trust assumption:** All team group members see all card previews, including customer message content. The team group is a trusted space — only authorized team members should be given access.
#### Card format
Each card is **one** message with five parts (the join command is the final line of the card text, not a separate message):
```
[ICON] *[Customer Name]* · [wait] · [N msgs]
[STATE][· agent1, agent2, ...]
"[last message(s), truncated]"
/'join [id]'
```
**Icon / urgency signal**
| Icon | Condition |
|------|-----------|
| 🆕 | QUEUE — first message arrived < 5 min ago |
| 🟡 | QUEUE — waiting for team response < 2 h |
| 🔴 | QUEUE — waiting > 2 h with no team response |
| 🤖 | GROK — Grok is handling the conversation |
| 👋 | TEAM — team member added, no reply yet |
| 💬 | TEAM — team member has replied; conversation active |
| ⏰ | TEAM — customer sent a follow-up, team hasn't replied in > 2 h |
| ✅ | Done — no customer reply for `completeHours` (default 3h) after last team/Grok message |
**Wait time** — time since the customer's last unanswered message. For ✅ (auto-completed) conversations, the wait field shows the literal string "done". For conversations where the team has replied and the customer hasn't followed up, time since last message from either side.
**State label**
| Value | Meaning |
|-------|---------|
| `Queue` | No agent or Grok yet |
| `Grok` | Grok is the active responder |
| `Team pending` | Team member added, hasn't replied yet (takes priority over `Grok` if both are present) |
| `Team` | Team member engaged |
**Agents** — comma-separated display names of all team members currently in the group. Omitted when no team member has joined.
**Message preview** — the last several messages, most recent last, separated by a blue `/` (rendered via SimpleX markdown `!3 /!`). Newlines in message text are replaced with spaces to prevent card layout bloat. Newest messages are prioritized — when the total preview exceeds ~500 characters, the oldest messages are truncated (with `[truncated]` prepended) while the newest are always shown. Each message is prefixed with the sender's name (`Name: message`) on the first message in a consecutive run from that sender — subsequent messages from the same sender omit the prefix until a different sender's message appears. Sender identification: Grok is labeled "Grok"; the customer is labeled with their display name (newlines replaced with spaces for display); team members use their display name. The bot's own messages are excluded. Each individual message is truncated to ~200 characters with `[truncated]` appended. Media-only messages show a type label: `[image]`, `[file]`, `[voice]`, `[video]`.
**Markdown escaping in previews** — SimpleX markdown interprets `!N<space>` (where N is `1``6`, `r`, `g`, `b`, `y`, `c`, `m`, or `-`) as styled-text markup, closing at the next `!`. There is no escape mechanism in the parser. To prevent customer/agent message text from triggering false color formatting or interfering with the blue `/` separator, the bot inserts a zero-width space (U+200B) between `!` and any color-trigger character in preview text before joining with the separator. This is invisible to the user but breaks the parser trigger pattern.
**Join command** — the final line of the card is `/'join <id>'`. The single quotes around `join <id>` make the whole token clickable in SimpleX clients; when tapped, the client sends `/join <id>` back to the team group. The bot does not pattern-match the message text — it asks the framework for the structured command (`util.ciBotCommand` returns `{keyword: "join", params: "<id>"}`) and converts `params` to a number with `Number.parseInt`. The numeric form is the only accepted form: there is no `/join <id>:<name>` legacy syntax and no regex fallback.
The icon in line 1 is the sole urgency indicator — no reactions are used.
#### Card examples
---
**1. Brand new conversation**
```
🆕 *Alice Johnson* · just now · 1 msg
Queue
"Alice Johnson: I can't connect to my contacts after updating to 6.3."
/'join 42'
```
---
**2. Queue — short wait, two short messages combined in preview**
```
🟡 *Emma Webb* · 20m · 2 msgs
Queue
"Emma Webb: Hi" / "Is anyone there? I have an urgent question about my keys"
/'join 88'
```
Second message has no prefix because it's the same sender as the first.
---
**3. Queue — urgent, no response in over 2 hours**
```
🔴 *Maria Santos* · 3h 20m · 6 msgs
Queue
"Maria Santos: I reset my phone and now all conversations are gone" / "I tried reinstalling but nothing changed" / "Please help, I've lost access to all my conversations after resetting my phone…"
/'join 38'
```
---
**4. Grok mode — alternating senders**
```
🤖 *David Kim* · 1h 5m · 8 msgs
Grok
"David Kim: Which encryption algorithm does SimpleX use for messages?" / "Grok: SimpleX uses double ratchet with NaCl crypto_box for end-to-end encryption…[truncated]" / "David Kim: And what about metadata protection?"
/'join 29'
```
Each sender change triggers a new name prefix. David and Grok alternate, so every message gets a prefix.
---
**5. Team invited — no reply yet**
```
👋 *Sarah Miller* · 2h 10m · 5 msgs
Team pending · evan
"Sarah Miller: Notifications completely stopped working after I updated my phone OS. I'm on Android 14…"
/'join 55'
```
---
**6. Team active — two agents, name with spaces**
```
💬 *François Dupont* · 30m · 14 msgs
Team · evan, alex
"François Dupont: OK merci, I will try this and let you know."
/'join 61'
```
---
**7. Team overdue — customer follow-up unanswered > 2 h**
```
*Wang Fang* · 4h · 19 msgs
Team · alex
"Wang Fang: The app crashes when I open large groups" / "I tried what you suggested but it still doesn't work. Any other ideas?"
/'join 73'
```
---
#### Card lifecycle
**Tracking: group customData.** The bot stores the current card's team group message ID (`cardItemId`) in the customer group's `customData` via `apiSetGroupCustomData(groupId, {cardItemId})`. This is the single source of truth for which team group message is the card for a given customer. It survives restarts because `customData` is in the database.
**Create** — when the customer sends their first message (triggering the Step 2 queue message) or `/grok` as their first message (WELCOME → GROK, skipping Step 2):
1. Bot composes the card as a single message (🆕 for first message, 🤖 for `/grok` as first message; customer name, message preview, `/'join <id>'` as the final line)
2. Bot posts it to the team group via `apiSendTextMessage` → receives back the `chatItemId`
3. Bot writes `{cardItemId: chatItemId}` into the customer group's `customData`
**Update** (delete + repost) — on every subsequent event: new customer message, team member reply in the customer group, state change (QUEUE → GROK, GROK → TEAM, GROK → QUEUE on join timeout, etc.), agent joining. Card updates are debounced globally — the bot collects all pending card changes and flushes them in a single batch at a configurable interval (default 300 seconds, set via `--card-flush-seconds`). Within a batch, each customer group's card is reposted at most once with the latest state.
1. Bot reads `cardItemId` from the customer group's `customData`
2. Bot deletes the old card in the team group via `apiDeleteChatItem(teamGroupId, cardItemId, "broadcast")` (delete for everyone)
3. Bot composes the new card (updated icon, wait time, message count, preview)
4. Bot posts new card to the team group → receives new `chatItemId`
5. Bot overwrites `customData` with the new `{cardItemId: newChatItemId}`
If `apiDeleteChatItem` fails (e.g., card was already deleted due to a prior crash), the bot ignores the error and proceeds to post the new card. The new `cardItemId` overwrites `customData`, recovering the lifecycle.
Because the old card is deleted and the new one is posted at the bottom, the most recently updated conversations always appear last in the team group.
**Cleanup** — when the customer leaves the group:
1. Bot reads `cardItemId` from `customData`
2. Card is **not deleted** — it remains in the team group until a retention policy is added (resolved state TBD)
3. Bot clears the `cardItemId` from `customData`
**Completion tracking:** When a card is composed with the ✅ icon (auto-completed), the bot writes `complete: true` into the group's `customData` alongside `cardItemId`. When a customer sends a new message and the card is recomposed as non-✅, the `complete` flag is omitted from the new `customData` (self-healing). This allows the bot to skip completed conversations on restart without re-reading chat history for every group.
**Restart recovery** — on startup, the bot refreshes existing cards to update wait times, icons, and auto-complete status. It lists all groups, finds those with `customData.cardItemId` set and `customData.complete` not set, sorts by `cardItemId` ascending (higher IDs = more recently updated cards), and re-posts them oldest-first. This ensures the most recently active cards appear at the bottom of the team group (newest position). Completed cards are skipped — they remain as-is until a new customer message triggers the normal event-driven update. Old/pre-bot groups without `customData` are also skipped. The bot attempts to delete the old card message before reposting; deletion failures (e.g., card older than 24h) are silently ignored. Subsequent events resume the normal delete-repost cycle via `customData`.
#### Team commands
Team members use these commands in the team group:
| Command | Effect |
|---------|--------|
| `/join <groupId>` | Join the specified customer group as Owner. Card emits the clickable form `/'join <groupId>'`; the handler reads `groupId` from the framework's structured command (`util.ciBotCommand → {keyword, params}`), not from regex over the message text. |
`/join` is **team-only** — it is registered as a bot command only in the team group. If a customer sends `/join` in a customer group, the bot treats it as an ordinary message (per the existing rule: unrecognized commands are treated as normal messages).
#### Joining a customer group
When a team member taps `/join`, the bot first verifies that the target `groupId` is a business group hosted by the main profile (i.e., has a `businessChat` property). If not, the bot replies with an error in the team group and does nothing. If valid, the bot adds the team member to the customer group (via the shared `addOrFindTeamMember` helper, which promotes to Owner at invite time via `apiSetMembersRole(Owner)`, with a fallback re-promotion on connect). From within the customer group, the team member chats directly with the customer. Their messages trigger card updates in the team group (icon change, wait time reset). The customer sees the team member as a real group participant.
#### Edge cases
| Situation | What happens |
|-----------|-------------|
| All team members leave before any sends a message | State stays `TEAM-PENDING` (customer is still waiting for a response). Next `/team` re-adds them silently. |
| Customer leaves | All in-memory state cleaned up; card remains (TBD) |
| No `--auto-add-team-members` (`-a`) configured | `/team` tells customer "no team members available yet" |
| Team member already in customer group | `apiListMembers` lookup finds existing member — no error |
---
## 5. Architecture
### 5.1 CLI Overview
```
GROK_API_KEY=... node dist/index.js --team-group "Support Team" [options]
```
**Environment variables:**
| Var | Required | Purpose |
|-----|----------|---------|
| `GROK_API_KEY` | No | xAI API key for Grok. If unset or empty, the bot starts with Grok API disabled: it logs `"No GROK_API_KEY provided, disabling Grok support"`, the `/grok` command is not registered, customer-facing messages (`queueMessage`, `noTeamMembersMessage`) drop the `/grok` clause, and any `/grok` the customer types is treated as an unrecognized command. Note: `config.grokContactId` is still restored from the state file even when the API is disabled, so the one-way gate can identify and remove Grok members from groups when team takes over. When `GROK_API_KEY` is set, `--context-file` must also be provided — startup fails otherwise. |
**CLI flags:**
| Flag | Required | Default | Format | Purpose |
|------|----------|---------|--------|---------|
| `--db-prefix` | No | `./data/simplex` | path | Database file prefix (both profiles share it) |
| `--team-group` | Yes | — | `name` | Team group display name (auto-created if absent, resolved by persisted ID on restarts) |
| `--auto-add-team-members` / `-a` | No | `""` | `ID:name,...` | Comma-separated team member contacts. Validated at startup — exits on mismatch. Without this, `/team` tells customers no members available. |
| `--context-file` | Required when `GROK_API_KEY` set | — | path | Path to the Grok system-prompt / SimpleX documentation context file. Loaded at startup and passed as the `system` message on every Grok API call. Required when `GROK_API_KEY` is set — startup fails otherwise. When missing at runtime (file unreadable), a warning is logged and Grok runs with an empty system prompt. |
| `--timezone` | No | `"UTC"` | IANA tz | For weekend detection (24h vs 48h). Weekend is Saturday 00:00 through Sunday 23:59 in this timezone. |
| `--complete-hours` | No | `3` | number | Hours of customer inactivity after last team/Grok reply before auto-completing a conversation (✅ icon, "done" wait time). |
| `--card-flush-seconds` | No | `300` | number | Seconds between card dashboard update flushes. Lower values give faster updates; higher values reduce message churn. |
**Why `--auto-add-team-members` (`-a`) uses `ID:name`:** Contact IDs are local to the bot's database — not discoverable externally. The bot DMs each team member their ID when they join the team group. The name is validated at startup to catch stale IDs pointing to the wrong contact.
**Customer commands** (available as tappable buttons in customer business chats; see implementation plan §7 for the per-group lazy sync):
| Command | Available | Effect |
|---------|-----------|--------|
| `/grok` | Before any team member sends a message, and only if `GROK_API_KEY` is set | Enter Grok mode |
| `/team` | QUEUE or GROK state | Add team members, permanently enter Team mode once any replies |
**Unrecognized commands** are treated as normal messages in the current mode. When Grok is disabled (no `GROK_API_KEY`), `/grok` is not registered in the bot command list and, if typed manually, falls into this "unrecognized" path.
**Team commands** (registered in team group via `groupPreferences`):
| Command | Effect |
|---------|--------|
| `/join <groupId>` | Join the specified customer group as Owner. Card emits the clickable form `/'join <groupId>'`; the handler reads `groupId` from the framework's structured command (`util.ciBotCommand → {keyword, params}`), not from regex over the message text. |
### 5.2 Bot Architecture
The bot process runs a single `ChatApi` instance with **two user profiles**:
- **Main profile** — the support bot's account ("Ask SimpleX Team"). Owns the business address, hosts all business groups, communicates with customers, communicates with the team group, and controls group membership. On startup the bot checks the main profile for an existing business address via `apiGetUserAddress`; if none exists (first run), it creates one via `apiCreateBusinessAddress`. The address is stored in the SimpleX database as part of the profile — it survives restarts and state file loss without re-creation. The business address link is printed to stdout on every startup.
- **Grok profile** — the Grok agent's account (display name "Grok"). Is invited into customer groups as a Member. Sends Grok's responses so they appear to come from the Grok identity. The Grok user is created by the bot on first run via `apiCreateActiveUser` and its `userId` is persisted to `state.json` as `grokUserId`; subsequent runs look it up by ID (never by name — a renamed profile would silently break name-based matching). On startup, if the profile already exists, the bot compares its current profile (display name, image) against the desired values and calls `apiUpdateProfile()` if anything changed — this pushes the update to all Grok contacts so profile picture changes take effect immediately.
```
┌─────────────────────────────────────────────────┐
│ Support Bot Process (Node.js) │
│ │
│ chat: ChatApi ← ChatApi.init("./data/simplex") │
│ Single database, two user profiles │
│ │
│ mainUserId ← "Ask SimpleX Team" profile │
│ • Business address, event routing, state mgmt │
│ • Controls group membership │
│ │
│ grokUserId ← "Grok" profile │
│ • Joins customer groups as Member │
│ • Sends Grok responses into groups │
│ │
│ profileMutex: serialize apiSetActiveUser + call │
│ GrokApiClient → api.x.ai/v1/chat/completions │
└─────────────────────────────────────────────────┘
```
Before each SimpleX API call, the bot switches to the appropriate profile via `apiSetActiveUser(userId)`. All profile-switching and SimpleX API calls are serialized through a mutex to prevent interleaving. The Grok HTTP API call (external network request to xAI) is made **outside** the mutex — only the profile switch + SimpleX read/send calls need serialization. This prevents a slow Grok response from blocking all other bot operations.
**Event delivery is profile-independent.** ChatApi delivers events for all user profiles in the database, not just the active one. Every event includes a `user` field identifying which profile it belongs to. `apiSetActiveUser` only affects the context for write/send API calls — it does not filter event subscription. The bot routes events by checking `event.user`: main profile events go to the main handler, Grok profile events go to the Grok handler.
The Grok profile is self-contained: it watches its own events (`newChatItems`, `receivedGroupInvitation`), calls the Grok HTTP API, and sends responses — all using group IDs from its own events. The main profile only controls Grok's group membership (invite/remove) and reflects Grok's responses in the team group card.
### 5.3 Grok Integration
Grok is not a service call hidden behind the bot's account. It is a **second user profile** within the same SimpleX Chat process and database. The customer sees messages from "Grok" as a real group participant — not from the support bot. This is what makes Grok transparent to the user.
The Grok profile is **self-contained**: it watches its own events, reads group history through its own view, calls the Grok HTTP API, and sends responses — all using its own local group IDs from its own events. No cross-profile ID mapping is needed.
#### Startup: establishing the bot↔Grok contact
On first run (no state file), the bot must establish a SimpleX contact between the main and Grok profiles:
1. Main profile creates a one-time invite link
2. Grok profile connects to it
3. The bot waits up to 60 seconds for `contactConnected` to fire
4. The resulting `grokContactId` is written to the state file
On subsequent runs, the bot always looks up `grokContactId` from the state file and verifies it still exists in the main profile's contact list — even when `GROK_API_KEY` is not set. This ensures the one-way gate can identify and remove Grok members from groups when a team member sends a text message, preventing "phantom" Grok members that would cause dual responses if Grok is later re-enabled. If the contact is not found and Grok is enabled, it is re-established.
#### Per-conversation: how Grok joins a group
When a customer sends `/grok`:
**Main profile side (failure detection):**
1. Bot sends "Inviting Grok, please wait..." to the customer group
2. Main profile: `apiAddMember(groupId, grokContactId, Member)` — invites the Grok contact to the customer's business group. If `groupDuplicateMember` (customer sent `/grok` again before join completed), the duplicate activation returns silently — the in-flight one handles the outcome.
3. The `member.memberId` is stored in an in-memory map `pendingGrokJoins: memberId → mainGroupId`. Any invitation event that arrived during the `apiAddMember` await (race condition) is drained from the buffer and processed immediately.
4. Main profile receives `connectedToGroupMember` for any member connecting in the group. The bot checks the event's `memberId` against `pendingGrokJoins` — only a match resolves the 120-second promise. This promise is only for failure detection — if it times out, the bot notifies the customer and falls back to QUEUE.
**Grok profile side (independent, triggered by its own events):**
5. Grok profile receives a `receivedGroupInvitation` event. If a matching `pendingGrokJoins` entry exists, auto-accepts via `apiJoinGroup(groupId)`. If not (race: event arrived before step 3), buffers the event for the main profile to drain.
5. Grok profile reads visible history from the group — the last 100 messages — to build the initial Grok API context (customer messages → `user` role)
6. Grok profile calls the Grok HTTP API with this context
7. Grok profile sends the response into the group via `apiSendTextMessage([Group, groupId], response)` — visible to the customer as a message from "Grok"
**Initial response gating:** When Grok joins a group, the message backlog may trigger per-message responses (via `newChatItems`) at the same time `activateGrok` is sending the initial combined response. To prevent duplicate replies, per-message responses are suppressed (via `grokInitialResponsePending`) until the initial combined response completes. The flag is set before `waitForGrokJoin` and cleared after the initial response is sent (or fails). Without this gate, customers would receive both individual per-message replies AND a combined initial reply — e.g. 3 replies for 2 messages.
**Card update:** Main profile sees Grok's response as `groupRcv` and updates the team group card (same mechanism as ongoing Grok messages).
**Visible history** must be enabled on customer groups (the bot enables it alongside file uploads in the business request handler). This allows Grok to read the full conversation history after joining, rather than only seeing messages sent after it joined. If Grok reads history and finds no customer messages (e.g., visible history was disabled or the API call failed), it sends a generic greeting asking the customer to repeat their question.
#### Per-message: ongoing Grok conversation
After the initial response, the Grok profile watches its own `newChatItems` events. It only triggers a Grok API call for `groupRcv` messages from the customer — identified via `businessChat.customerId` on the group's `groupInfo` (accessible to all members). Messages from the bot (main profile), from Grok itself (`groupSnd`), and from team members are ignored. Non-text messages (images, files, voice) do not trigger Grok API calls but still trigger a card update in the team group.
**Batch deduplication:** When multiple customer messages arrive in a single `newChatItems` event (e.g., rapid messages delivered as a batch), only the last customer message per group triggers a Grok API call. Earlier messages are included in the history context via `apiGetChat`, so the single response addresses all messages in the batch. Without this, each message in the batch would trigger a separate API call, and the earlier calls would include later messages in their history — producing incoherent responses that reference messages "from the future."
Every subsequent customer text message in a group where Grok is a member:
1. Triggers a card update in the team group (via the main profile, which sees the customer message as `groupRcv`)
2. Grok profile receives the message via its own event, rebuilds history by reading the last 100 messages from its own view of the group (Grok's messages → `assistant` role, customer's messages → `user` role)
3. Grok profile calls the Grok HTTP API and sends the response into the group using the group ID from its own event
4. Main profile sees Grok's response as `groupRcv` and updates the team group card
In Grok mode, each customer message triggers two card updates — one on receipt (reflecting the new message and updated wait time) and one after Grok responds. This gives the team real-time visibility into active Grok conversations.
If the Grok HTTP API call fails or times out for a per-message request, the Grok profile sends an error message into the group: "Sorry, I couldn't process that. Please try again or send /team for a human team member." Grok remains in the group and the state stays GROK — the customer can retry by sending another message.
Grok API calls are NOT serialized per customer group in the MVP. If a new customer message arrives while a Grok API call is in flight, a second call runs concurrently — `apiGetChat` is re-read at the start of each call so history converges eventually, but two rapid messages in the same group can produce interleaved context. Cross-group calls run concurrently by design (see implementation plan §10 "Cross-group Grok parallelism"). Per-group serialization is a planned future improvement.
#### Grok removal
Grok is removed from the group (via main profile `apiRemoveMembers`) in three cases:
1. Team member sends their first text message in the customer group
2. Grok join fails (120-second timeout) — graceful fallback to QUEUE, bot notifies the customer
3. Customer leaves the group
### 5.4 Persistent State
The bot writes a single JSON file (`{dbPrefix}_state.json`) that survives restarts. It uses the same `--db-prefix` as the SimpleX database files, so the state file is always co-located with the database (e.g. `./data/simplex_state.json` alongside `./data/simplex_chat.db` and `./data/simplex_agent.db`). This ensures backups and migrations that copy the database directory also capture the bot state.
#### Why a state file at all?
SimpleX Chat's own database stores the full message history and group membership, but it does not store the bot's derived knowledge — things like which team group was created on first run, or which contact is the established bot↔Grok link. Per-conversation state (QUEUE/GROK/TEAM-PENDING/TEAM) is written into the customer group's `customData` at the moment the bot handles each transition — it observes its own `/grok` invite, `/team` add, team message, first customer message. Only display data (message counts, timestamps, sender names) is re-derived from chat history on demand.
#### What is persisted and why
| Key | Type | Why persisted | What breaks without it |
|-----|------|---------------|------------------------|
| `teamGroupId` | number | The bot creates the team group on first run; subsequent runs must find the same group | Bot creates a new empty team group on every restart; all team members lose their dashboard |
| `grokContactId` | number | Establishing a bot↔Grok contact takes up to 60 seconds and is a one-time setup | Every restart requires a 60-second re-connection; if it fails the bot exits |
| `grokUserId` | number | The bot creates the Grok user on first run; subsequent runs identify it by ID so a renamed profile cannot be silently mistaken for the main user | Startup restore (active-user recovery) and Grok profile resolution would fall back to display-name matching — fragile to any rename of the Grok profile |
The `mainUserId` is **not** persisted — it is resolved at startup from `bot.run()`, which creates the main profile on a fresh DB and returns the user object.
#### What is NOT persisted and why
Per-group state (`state`, `cardItemId`, `complete`) lives in SimpleX's database as the group's `customData` — persisted there rather than in the bot's state file.
| State | Where it lives instead |
|-------|----------------------|
| `state, cardItemId, complete` (per group) | Stored in the group's customData — conversation state, card message ID, auto-completed flag. `state` is written at event time (first customer message, `/grok`, `/team`, team's first message); the bot never re-derives it by scanning chat history. |
| Last customer message time | Derived from most recent customer message in chat history |
| Message count | Derived from message count in chat history (all messages except the bot's own) |
| Customer name | Always available from the group's display name |
| Who sent last message | Derived from recent chat history |
| `pendingGrokJoins` | In-flight during the 120-second join window only |
| Owner role promotion | Not tracked — the bot promotes team members to Owner at two idempotent points: (1) at invite time, immediately after `apiAddMember` in `addOrFindTeamMember` (skipped if the member is already in the group); (2) on every `memberConnected` in a customer group (unless the member is the customer or Grok). Survives restarts. |
| `pendingTeamDMs` | Messages queued to greet team members — simply not sent if lost |
| `grokJoinResolvers`, `grokFullyConnected` | Pure async synchronization primitives — always empty at startup |
#### Failure modes
If the state file is deleted or corrupted:
- A new team group is created. Team members must re-join it.
- The bot↔Grok contact is re-established (60-second startup delay).
- Grok remains in any groups it was already a member of. Since the Grok profile watches its own events, it will continue responding to customer messages in those groups without any additional recovery — no cross-profile state needs to be rebuilt.
+920
View File
@@ -0,0 +1,920 @@
import {api, util} from "simplex-chat"
import {T, CEvt} from "@simplex-chat/types"
import {Config} from "./config.js"
import {GrokMessage, GrokApiClient} from "./grok.js"
import {CardManager, ConversationState} from "./cards.js"
import {
queueMessage, grokInvitingMessage, grokActivatedMessage, teamAddedMessage,
teamAlreadyInvitedMessage, teamLockedMessage, noTeamMembersMessage,
grokUnavailableMessage, grokErrorMessage, grokNoHistoryMessage,
} from "./messages.js"
import {profileMutex, log, logError} from "./util.js"
// True for any non-terminal status — invited but not yet accepted, through
// connected. Used to decide whether a contact is already in the group so we
// don't trigger a re-invite (the SimpleX API resends the invitation for a
// member in GSMemInvited).
function isInGroup(m: T.GroupMember): boolean {
switch (m.memberStatus) {
case T.GroupMemberStatus.Rejected:
case T.GroupMemberStatus.Removed:
case T.GroupMemberStatus.Left:
case T.GroupMemberStatus.Deleted:
case T.GroupMemberStatus.Unknown:
return false
default:
return true
}
}
export class SupportBot {
// Card manager
cards: CardManager
// Grok group mapping: memberId → mainGroupId (for pending joins)
private pendingGrokJoins = new Map<string, number>()
// Buffered invitations that arrived before pendingGrokJoins was set (race condition)
private bufferedGrokInvitations = new Map<string, CEvt.ReceivedGroupInvitation>()
// mainGroupId → grokLocalGroupId
private grokGroupMap = new Map<number, number>()
// grokLocalGroupId → mainGroupId
private reverseGrokMap = new Map<number, number>()
// mainGroupId → resolve fn for grok join
private grokJoinResolvers = new Map<number, () => void>()
// mainGroupIds where Grok connectedToGroupMember fired
private grokFullyConnected = new Set<number>()
// Suppress per-message Grok responses while activateGrok sends the initial combined response
private grokInitialResponsePending = new Set<number>()
// Pending DMs for team group members (contactId → message)
private pendingTeamDMs = new Map<number, string>()
// Contacts that already received the team DM (dedup)
private sentTeamDMs = new Set<number>()
// Tracked fire-and-forget operations (for testing)
private _pendingOps: Promise<void>[] = []
// Bot's business address link
businessAddress: string | null = null
// Groups whose groupPreferences.commands we've already verified/synced
// in this process. Populated lazily by syncGroupCommands() on the first
// send to each group.
private syncedGroups = new Set<number>()
constructor(
private chat: api.ChatApi,
private grokApi: GrokApiClient | null,
private config: Config,
private mainUserId: number,
private grokUserId: number | null,
private desiredCommands: T.ChatBotCommand[],
) {
this.cards = new CardManager(chat, config, mainUserId, config.cardFlushSeconds * 1000)
}
private get grokEnabled(): boolean {
return this.grokApi !== null
}
// Wait for all fire-and-forget operations to settle (for testing)
async flush(): Promise<void> {
while (this._pendingOps.length > 0) {
const ops = this._pendingOps.splice(0)
await Promise.allSettled(ops)
}
}
private fireAndForget(op: Promise<void>): void {
const tracked = op.catch(err => logError("async operation error", err))
this._pendingOps.push(tracked)
tracked.finally(() => {
const idx = this._pendingOps.indexOf(tracked)
if (idx >= 0) this._pendingOps.splice(idx, 1)
})
}
// --- Profile-switching helpers ---
private async withMainProfile<R>(fn: () => Promise<R>): Promise<R> {
return profileMutex.runExclusive(async () => {
await this.chat.apiSetActiveUser(this.mainUserId)
return fn()
})
}
// Ensure this group's groupPreferences.commands match desiredCommands,
// so commands in outgoing messages render as clickable for members of
// this group. Scoped to the group (apiUpdateGroupProfile broadcasts
// XGrpInfo/XGrpPrefs to group members only), and cached so we don't
// re-check on every send. Pre-checks local state via apiGetChat so we
// don't issue a no-op broadcast when the group already has the
// commands.
private async syncGroupCommands(groupId: number): Promise<void> {
if (this.syncedGroups.has(groupId)) return
const desiredJSON = JSON.stringify(this.desiredCommands)
const chat = await this.chat.apiGetChat(T.ChatType.Group, groupId, 0)
const info = chat.chatInfo
if (info.type !== "group") return
const gp = info.groupInfo.groupProfile
const currentPrefs = gp.groupPreferences ?? {}
if (JSON.stringify(currentPrefs.commands ?? []) !== desiredJSON) {
await this.chat.apiUpdateGroupProfile(groupId, {
...gp,
groupPreferences: {...currentPrefs, commands: this.desiredCommands},
})
log(`Pushed commands to group ${groupId}`)
}
this.syncedGroups.add(groupId)
}
private async withGrokProfile<R>(fn: () => Promise<R>): Promise<R> {
if (this.grokUserId === null) throw new Error("Grok is disabled (no GROK_API_KEY)")
const grokUserId = this.grokUserId
return profileMutex.runExclusive(async () => {
await this.chat.apiSetActiveUser(grokUserId)
return fn()
})
}
// --- Main profile event handlers ---
async onBusinessRequest(evt: CEvt.AcceptingBusinessRequest): Promise<void> {
const groupId = evt.groupInfo.groupId
try {
const profile = evt.groupInfo.groupProfile
await this.withMainProfile(() =>
this.chat.apiUpdateGroupProfile(groupId, {
displayName: profile.displayName,
fullName: profile.fullName,
groupPreferences: {
...profile.groupPreferences,
files: {enable: T.GroupFeatureEnabled.On},
history: {enable: T.GroupFeatureEnabled.On},
},
})
)
// file uploads + history enabled
} catch (err) {
logError(`Failed to update business group ${groupId} preferences`, err)
}
}
async onNewChatItems(evt: CEvt.NewChatItems): Promise<void> {
// Only process events for main profile
if (evt.user.userId !== this.mainUserId) return
for (const ci of evt.chatItems) {
try {
await this.processMainChatItem(ci)
} catch (err) {
logError("Error processing chat item", err)
}
}
}
async onChatItemUpdated(evt: CEvt.ChatItemUpdated): Promise<void> {
if (evt.user.userId !== this.mainUserId) return
const {chatInfo} = evt.chatItem
if (chatInfo.type !== "group") return
const groupInfo = chatInfo.groupInfo
if (!groupInfo.businessChat) return
this.cards.scheduleUpdate(groupInfo.groupId)
}
async onChatItemReaction(evt: CEvt.ChatItemReaction): Promise<void> {
if (evt.user.userId !== this.mainUserId) return
if (!evt.added) return
const chatInfo = evt.reaction.chatInfo
if (chatInfo.type !== "group") return
const groupInfo = chatInfo.groupInfo
if (!groupInfo.businessChat) return
this.cards.scheduleUpdate(groupInfo.groupId)
}
async onLeftMember(evt: CEvt.LeftMember): Promise<void> {
if (evt.user.userId !== this.mainUserId) return
const groupId = evt.groupInfo.groupId
const member = evt.member
const bc = evt.groupInfo.businessChat
if (!bc) return
if (member.memberId === bc.customerId) {
log(`Customer left group ${groupId}`)
this.cleanupGrokMaps(groupId)
try { await this.cards.clearCustomData(groupId) } catch {}
return
}
if (this.config.grokContactId !== null && member.memberContactId === this.config.grokContactId) {
log(`Grok left group ${groupId}`)
this.cleanupGrokMaps(groupId)
return
}
if (this.config.teamMembers.some(tm => tm.id === member.memberContactId)) {
log(`Team member left group ${groupId}`)
}
}
async onJoinedGroupMember(evt: CEvt.JoinedGroupMember): Promise<void> {
if (evt.user.userId !== this.mainUserId) return
if (evt.groupInfo.groupId === this.config.teamGroup.id) {
await this.sendTeamMemberDM(evt.member)
}
}
async onMemberConnected(evt: CEvt.ConnectedToGroupMember): Promise<void> {
if (evt.user.userId !== this.mainUserId) return
const groupId = evt.groupInfo.groupId
// Team group → send DM (if not already sent by onJoinedGroupMember)
if (groupId === this.config.teamGroup.id) {
await this.sendTeamMemberDM(evt.member, evt.memberContact)
return
}
// Customer group → promote to Owner (unless customer or Grok). Idempotent per plan §11.
const bc = evt.groupInfo.businessChat
if (bc) {
const isCustomer = evt.member.memberId === bc.customerId
const isGrok = this.config.grokContactId !== null
&& evt.member.memberContactId === this.config.grokContactId
if (!isCustomer && !isGrok) {
try {
await this.withMainProfile(() =>
this.chat.apiSetMembersRole(groupId, [evt.member.groupMemberId], T.GroupMemberRole.Owner)
)
log(`Promoted member ${evt.member.groupMemberId} to Owner in group ${groupId}`)
} catch (err) {
logError(`Failed to promote member in group ${groupId}`, err)
}
}
}
}
async onMemberContactReceivedInv(evt: CEvt.NewMemberContactReceivedInv): Promise<void> {
if (evt.user.userId !== this.mainUserId) return
const {contact, groupInfo, member} = evt
if (groupInfo.groupId === this.config.teamGroup.id) {
if (this.sentTeamDMs.has(contact.contactId)) return
log(`DM contact from team group member: ${contact.contactId}:${member.memberProfile.displayName}`)
const name = member.memberProfile.displayName
const formatted = name.includes(" ") ? `'${name}'` : name
const msg = `Added you to be able to invite you to customer chats later, keep this contact. Your contact ID is ${contact.contactId}:${formatted}`
// Try sending immediately — contact may already be usable
try {
await this.withMainProfile(() =>
this.chat.apiSendTextMessage([T.ChatType.Direct, contact.contactId], msg)
)
this.sentTeamDMs.add(contact.contactId)
log(`Sent DM to team member ${contact.contactId}:${name}`)
} catch {
// Not ready yet — queue for contactConnected / contactSndReady
this.pendingTeamDMs.set(contact.contactId, msg)
log(`Queued DM for team member ${contact.contactId}:${name}`)
}
}
}
async onContactConnected(evt: CEvt.ContactConnected): Promise<void> {
if (evt.user.userId !== this.mainUserId) return
await this.deliverPendingDM(evt.contact.contactId)
}
async onContactSndReady(evt: CEvt.ContactSndReady): Promise<void> {
if (evt.user.userId !== this.mainUserId) return
await this.deliverPendingDM(evt.contact.contactId)
}
private async deliverPendingDM(contactId: number): Promise<void> {
if (this.sentTeamDMs.has(contactId)) {
this.pendingTeamDMs.delete(contactId)
return
}
const pendingMsg = this.pendingTeamDMs.get(contactId)
if (pendingMsg === undefined) return
this.pendingTeamDMs.delete(contactId)
try {
await this.withMainProfile(() =>
this.chat.apiSendTextMessage([T.ChatType.Direct, contactId], pendingMsg)
)
this.sentTeamDMs.add(contactId)
log(`Sent DM to team member ${contactId}`)
} catch (err) {
logError(`Failed to send DM to team member ${contactId}`, err)
}
}
// --- Grok profile event handlers ---
async onGrokGroupInvitation(evt: CEvt.ReceivedGroupInvitation): Promise<void> {
if (evt.user.userId !== this.grokUserId) return
const memberId = evt.groupInfo.membership.memberId
const mainGroupId = this.pendingGrokJoins.get(memberId)
if (mainGroupId === undefined) {
// Buffer: invitation may arrive before pendingGrokJoins is set (race with apiAddMember)
this.bufferedGrokInvitations.set(memberId, evt)
return
}
this.pendingGrokJoins.delete(memberId)
this.bufferedGrokInvitations.delete(memberId)
await this.processGrokInvitation(evt, mainGroupId)
}
private async processGrokInvitation(evt: CEvt.ReceivedGroupInvitation, mainGroupId: number): Promise<void> {
log(`Grok joining group: mainGroupId=${mainGroupId}, grokGroupId=${evt.groupInfo.groupId}`)
try {
await this.withGrokProfile(() => this.chat.apiJoinGroup(evt.groupInfo.groupId))
} catch (err) {
logError(`Grok failed to join group ${evt.groupInfo.groupId}`, err)
return
}
this.grokGroupMap.set(mainGroupId, evt.groupInfo.groupId)
this.reverseGrokMap.set(evt.groupInfo.groupId, mainGroupId)
}
async onGrokMemberConnected(evt: CEvt.ConnectedToGroupMember): Promise<void> {
if (evt.user.userId !== this.grokUserId) return
const grokGroupId = evt.groupInfo.groupId
const mainGroupId = this.reverseGrokMap.get(grokGroupId)
if (mainGroupId === undefined) return
this.grokFullyConnected.add(mainGroupId)
const resolver = this.grokJoinResolvers.get(mainGroupId)
if (resolver) {
this.grokJoinResolvers.delete(mainGroupId)
log(`Grok fully connected: mainGroupId=${mainGroupId}, grokGroupId=${grokGroupId}`)
resolver()
}
}
async onGrokNewChatItems(evt: CEvt.NewChatItems): Promise<void> {
if (evt.user.userId !== this.grokUserId) return
// When multiple customer messages arrive in one batch, only respond to the
// last per group — earlier messages are included in its history context.
const lastPerGroup = new Map<number, T.AChatItem>()
for (const ci of evt.chatItems) {
const {chatInfo, chatItem} = ci
if (chatInfo.type !== "group") continue
if (chatItem.chatDir.type !== "groupRcv") continue
if (!util.ciContentText(chatItem)?.trim()) continue
if (util.ciBotCommand(chatItem)) continue
const bc = chatInfo.groupInfo.businessChat
if (!bc) continue
if (chatItem.chatDir.groupMember.memberId !== bc.customerId) continue
lastPerGroup.set(chatInfo.groupInfo.groupId, ci)
}
// Groups are independent — avoid serializing one group's xAI latency across the others.
await Promise.allSettled(
[...lastPerGroup.values()].map((ci) => this.processGrokChatItem(ci)),
)
}
// --- Main profile message routing ---
private async processMainChatItem(ci: T.AChatItem): Promise<void> {
const {chatInfo, chatItem} = ci
// 1. Direct text message → reply with business address
if (chatInfo.type === "direct" && chatItem.chatDir.type === "directRcv"
&& (chatItem.content as any).type === "rcvMsgContent") {
if (this.businessAddress) {
const contactId = chatInfo.contact.contactId
try {
await this.withMainProfile(() =>
this.chat.apiSendTextMessage(
[T.ChatType.Direct, contactId],
`Please use my business address to ask questions: ${this.businessAddress}`,
)
)
} catch (err) {
logError(`Failed to reply to direct message from contact ${contactId}`, err)
}
}
return
}
if (chatInfo.type !== "group") return
const groupInfo = chatInfo.groupInfo
const groupId = groupInfo.groupId
// 2. Team group → handle /join
if (groupId === this.config.teamGroup.id) {
await this.processTeamGroupMessage(chatItem)
return
}
// 3. Skip non-business groups
if (!groupInfo.businessChat) return
// 4. Skip own messages
if (chatItem.chatDir.type === "groupSnd") return
if (chatItem.chatDir.type !== "groupRcv") return
const sender = chatItem.chatDir.groupMember
const bc = groupInfo.businessChat
const isCustomer = sender.memberId === bc.customerId
// 6. Non-customer message → one-way gate check + card update
if (!isCustomer) {
const isTeam = this.config.teamMembers.some(tm => tm.id === sender.memberContactId)
if (isTeam && util.ciContentText(chatItem)?.trim()) {
// One-way gate: first team text → transition to TEAM + remove Grok
const data = await this.cards.getRawCustomData(groupId)
if (data?.state !== "TEAM") {
await this.cards.mergeCustomData(groupId, {state: "TEAM"})
const {grokMember} = await this.cards.getGroupComposition(groupId)
if (grokMember) {
log(`One-way gate: team message in group ${groupId}, removing Grok`)
try {
await this.withMainProfile(() =>
this.chat.apiRemoveMembers(groupId, [grokMember.groupMemberId])
)
} catch {
// may have already left
}
this.cleanupGrokMaps(groupId)
}
}
}
// Schedule card update for any non-customer message (team or Grok)
this.cards.scheduleUpdate(groupId)
return
}
// 8. Customer message → derive state and dispatch
const state = await this.cards.deriveState(groupId)
const rawCmd = util.ciBotCommand(chatItem)
// When Grok is disabled, ignore /grok so it behaves like an unknown command
const cmd = rawCmd?.keyword === "grok" && !this.grokEnabled ? null : rawCmd
const text = util.ciContentText(chatItem)?.trim() || null
switch (state) {
case "WELCOME":
if (cmd?.keyword === "grok") {
// WELCOME → GROK (skip queue msg). Write state optimistically so the
// card renders with GROK icon/label; activateGrok will revert via
// setStateOnFail if activation fails.
// Fire-and-forget: activateGrok awaits future events (waitForGrokJoin)
// which would deadlock the sequential event loop if awaited here.
await this.cards.mergeCustomData(groupId, {state: "GROK"})
await this.cards.createCard(groupId, groupInfo)
this.fireAndForget(this.activateGrok(groupId, {sendQueueOnFail: true, setStateOnFail: "QUEUE"}))
return
}
if (cmd?.keyword === "team") {
// activateTeam writes state=TEAM-PENDING before the add loop
await this.activateTeam(groupId)
await this.cards.createCard(groupId, groupInfo)
return
}
// First regular message → QUEUE
if (text) {
await this.cards.mergeCustomData(groupId, {state: "QUEUE"})
await this.sendToGroup(groupId, queueMessage(this.config.timezone, this.grokEnabled))
await this.cards.createCard(groupId, groupInfo)
}
break
case "QUEUE":
if (cmd?.keyword === "grok") {
// Write state optimistically; activateGrok reverts to QUEUE on failure
await this.cards.mergeCustomData(groupId, {state: "GROK"})
this.fireAndForget(this.activateGrok(groupId, {setStateOnFail: "QUEUE"}))
} else if (cmd?.keyword === "team") {
await this.activateTeam(groupId)
}
this.cards.scheduleUpdate(groupId)
break
case "GROK":
if (cmd?.keyword === "team") {
await this.activateTeam(groupId)
} else if (cmd?.keyword === "grok") {
// Already in grok mode — ignore
} else if (text) {
// Customer text → Grok responds (handled by Grok profile's onGrokNewChatItems)
// Just schedule card update for the customer message
}
this.cards.scheduleUpdate(groupId)
break
case "TEAM-PENDING":
if (cmd?.keyword === "grok") {
// Invite Grok if not present; state stays TEAM-PENDING
const {grokMember} = await this.cards.getGroupComposition(groupId)
if (!grokMember) {
this.fireAndForget(this.activateGrok(groupId))
}
// else: already present, ignore
} else if (cmd?.keyword === "team") {
// activateTeam handles "already invited" reply (team still present)
// or silent re-add (team has all left)
await this.activateTeam(groupId)
}
this.cards.scheduleUpdate(groupId)
break
case "TEAM":
if (cmd?.keyword === "grok") {
await this.sendToGroup(groupId, teamLockedMessage)
} else if (cmd?.keyword === "team") {
// Team still present → "already invited"; team all left → silent re-add
await this.activateTeam(groupId)
}
this.cards.scheduleUpdate(groupId)
break
}
}
// --- Grok profile message processing ---
private async processGrokChatItem(ci: T.AChatItem): Promise<void> {
if (!this.grokApi) return
const grokApi = this.grokApi
const {chatInfo, chatItem} = ci
if (chatInfo.type !== "group") return
const groupInfo = chatInfo.groupInfo
const grokGroupId = groupInfo.groupId
// Skip while activateGrok is sending the initial combined response
const mainGroupId = this.reverseGrokMap.get(grokGroupId)
if (mainGroupId !== undefined && this.grokInitialResponsePending.has(mainGroupId)) return
// Only process received text messages from customer
if (chatItem.chatDir.type !== "groupRcv") return
const text = util.ciContentText(chatItem)?.trim()
if (!text) return // ignore non-text
// Ignore bot commands
if (util.ciBotCommand(chatItem)) return
// Only respond in business groups (survives restart without in-memory maps)
const bc = groupInfo.businessChat
if (!bc) return
// Only respond to customer messages, not bot or team messages
if (chatItem.chatDir.groupMember.memberId !== bc.customerId) return
// Read history from Grok's own view
try {
const chat = await this.withGrokProfile(() =>
this.chat.apiGetChat(T.ChatType.Group, grokGroupId, 100)
)
const history: GrokMessage[] = []
for (const histCi of chat.chatItems) {
const histText = util.ciContentText(histCi)?.trim()
if (!histText) continue
if (histCi.chatDir.type === "groupSnd") {
history.push({role: "assistant", content: histText})
} else if (histCi.chatDir.type === "groupRcv"
&& histCi.chatDir.groupMember.memberId === bc.customerId
&& !util.ciBotCommand(histCi)) {
history.push({role: "user", content: histText})
}
}
// Don't include the current message in history — it's the userMessage
if (history.length > 0 && history[history.length - 1].role === "user"
&& history[history.length - 1].content === text) {
history.pop()
}
// Call Grok API (outside mutex)
const response = await grokApi.chat(history, text)
// Send response via Grok profile
await this.withGrokProfile(() =>
this.chat.apiSendTextMessage([T.ChatType.Group, grokGroupId], response)
)
} catch (err) {
logError(`Grok per-message error for grokGroup ${grokGroupId}`, err)
try {
await this.withGrokProfile(() =>
this.chat.apiSendTextMessage([T.ChatType.Group, grokGroupId], grokErrorMessage)
)
} catch {}
}
// Card update scheduled by main profile seeing the groupRcv events
}
// --- Grok activation ---
private async activateGrok(
groupId: number,
opts: {sendQueueOnFail?: boolean; setStateOnFail?: ConversationState} = {},
): Promise<void> {
if (!this.grokApi) return
const grokApi = this.grokApi
const revertStateOnFail = async () => {
if (!opts.setStateOnFail) return
const current = await this.cards.getRawCustomData(groupId)
if (current?.state !== "GROK") return
await this.cards.mergeCustomData(groupId, {state: opts.setStateOnFail})
}
if (this.config.grokContactId === null) {
await revertStateOnFail()
await this.sendToGroup(groupId, grokUnavailableMessage)
if (opts.sendQueueOnFail) await this.sendToGroup(groupId, queueMessage(this.config.timezone, this.grokEnabled))
this.cards.scheduleUpdate(groupId)
return
}
// Pre-check: silent return if Grok is already in the group in any
// non-terminal status. The apiAddMember/groupDuplicateMember catch below
// handles Connected/etc. but the SimpleX API resends the invitation for
// GSMemInvited (no error thrown), so without this check a /grok issued
// while a previous activation is still pending would re-trigger the invite.
const grokMembers = await this.withMainProfile(() => this.chat.apiListMembers(groupId))
if (grokMembers.some(m => m.memberContactId === this.config.grokContactId && isInGroup(m))) {
return
}
// Gate MUST be up before apiAddMember / pendingGrokJoins / reverseGrokMap —
// any later and onGrokNewChatItems can fire a duplicate per-message reply.
this.grokInitialResponsePending.add(groupId)
try {
await this.sendToGroup(groupId, grokInvitingMessage)
let member: T.GroupMember
try {
member = await this.withMainProfile(() =>
this.chat.apiAddMember(groupId, this.config.grokContactId!, T.GroupMemberRole.Member)
)
} catch (err: unknown) {
const chatErr = err as {chatError?: {errorType?: {type?: string}}}
if (chatErr?.chatError?.errorType?.type === "groupDuplicateMember") {
// Grok already in group (e.g. customer sent /grok again before join completed) —
// the in-flight activation will handle the outcome, just return silently
return
}
logError(`Failed to invite Grok to group ${groupId}`, err)
await revertStateOnFail()
await this.sendToGroup(groupId, grokUnavailableMessage)
if (opts.sendQueueOnFail) await this.sendToGroup(groupId, queueMessage(this.config.timezone, this.grokEnabled))
this.cards.scheduleUpdate(groupId)
return
}
this.pendingGrokJoins.set(member.memberId, groupId)
// Drain buffered invitation that arrived during the apiAddMember await
const buffered = this.bufferedGrokInvitations.get(member.memberId)
if (buffered) {
this.bufferedGrokInvitations.delete(member.memberId)
this.pendingGrokJoins.delete(member.memberId)
await this.processGrokInvitation(buffered, groupId)
}
const joined = await this.waitForGrokJoin(groupId, 120_000)
if (!joined) {
this.pendingGrokJoins.delete(member.memberId)
try {
await this.withMainProfile(() =>
this.chat.apiRemoveMembers(groupId, [member.groupMemberId])
)
} catch {}
this.cleanupGrokMaps(groupId)
await revertStateOnFail()
await this.sendToGroup(groupId, grokUnavailableMessage)
if (opts.sendQueueOnFail) await this.sendToGroup(groupId, queueMessage(this.config.timezone, this.grokEnabled))
this.cards.scheduleUpdate(groupId)
return
}
await this.sendToGroup(groupId, grokActivatedMessage)
// Grok joined — send initial response based on customer's accumulated messages
try {
const grokLocalGId = this.grokGroupMap.get(groupId)
if (grokLocalGId === undefined) {
await this.sendToGroup(groupId, grokUnavailableMessage)
return
}
// Read history from Grok's own view — only customer messages.
// The previous `grokBc && ...` short-circuit let bot and team
// messages through when Grok's view had no businessChat; require
// grokBc.customerId to be present and match strictly.
const chat = await this.withGrokProfile(() =>
this.chat.apiGetChat(T.ChatType.Group, grokLocalGId, 100)
)
const grokBc = chat.chatInfo.type === "group" ? chat.chatInfo.groupInfo.businessChat : null
const customerMessages: string[] = []
for (const ci of chat.chatItems) {
if (ci.chatDir.type !== "groupRcv") continue
if (!grokBc || ci.chatDir.groupMember.memberId !== grokBc.customerId) continue
const t = util.ciContentText(ci)?.trim()
if (t && !util.ciBotCommand(ci)) customerMessages.push(t)
}
if (customerMessages.length === 0) {
await this.withGrokProfile(() =>
this.chat.apiSendTextMessage([T.ChatType.Group, grokLocalGId], grokNoHistoryMessage)
)
return
}
const initialMsg = customerMessages.join("\n")
const response = await grokApi.chat([], initialMsg)
await this.withGrokProfile(() =>
this.chat.apiSendTextMessage([T.ChatType.Group, grokLocalGId], response)
)
} catch (err) {
logError(`Grok initial response failed for group ${groupId}`, err)
await this.sendToGroup(groupId, grokUnavailableMessage)
}
} finally {
this.grokInitialResponsePending.delete(groupId)
}
}
// --- Team activation ---
private async activateTeam(groupId: number): Promise<void> {
if (this.config.teamMembers.length === 0) {
await this.sendToGroup(groupId, noTeamMembersMessage(this.grokEnabled))
return
}
const data = await this.cards.getRawCustomData(groupId)
const alreadyActivated = data?.state === "TEAM-PENDING" || data?.state === "TEAM"
if (alreadyActivated) {
const {teamMembers} = await this.cards.getGroupComposition(groupId)
if (teamMembers.length > 0) {
await this.sendToGroup(groupId, teamAlreadyInvitedMessage)
return
}
// Team previously activated but all team members have since left —
// re-add silently (no teamAddedMessage). State stays TEAM-PENDING/TEAM.
for (const tm of this.config.teamMembers) {
try {
await this.addOrFindTeamMember(groupId, tm.id)
} catch (err) {
logError(`Failed to add team member ${tm.id} to group ${groupId}`, err)
}
}
return
}
// First activation — write state BEFORE add loop so concurrent customer
// events observing mid-flight see TEAM-PENDING rather than stale state.
await this.cards.mergeCustomData(groupId, {state: "TEAM-PENDING"})
for (const tm of this.config.teamMembers) {
try {
await this.addOrFindTeamMember(groupId, tm.id)
} catch (err) {
logError(`Failed to add team member ${tm.id} to group ${groupId}`, err)
}
}
const {grokMember} = await this.cards.getGroupComposition(groupId)
await this.sendToGroup(groupId, teamAddedMessage(this.config.timezone, !!grokMember))
}
// --- Team group commands ---
private async processTeamGroupMessage(chatItem: T.ChatItem): Promise<void> {
if (chatItem.chatDir.type !== "groupRcv") return
const senderContactId = chatItem.chatDir.groupMember.memberContactId
if (!senderContactId) return
const cmd = util.ciBotCommand(chatItem)
if (cmd?.keyword !== "join") return
const targetGroupId = Number.parseInt(cmd.params, 10)
if (Number.isNaN(targetGroupId) || targetGroupId <= 0) {
await this.sendToGroup(this.config.teamGroup.id, `Error: invalid group id "${cmd.params}"`)
return
}
await this.handleJoinCommand(targetGroupId, senderContactId)
}
private async handleJoinCommand(targetGroupId: number, senderContactId: number): Promise<void> {
// Validate target is a business group
const groups = await this.withMainProfile(() =>
this.chat.apiListGroups(this.mainUserId)
)
const targetGroup = groups.find(g => g.groupId === targetGroupId)
if (!targetGroup?.businessChat) {
await this.sendToGroup(this.config.teamGroup.id, `Error: group ${targetGroupId} is not a business chat`)
return
}
try {
const member = await this.addOrFindTeamMember(targetGroupId, senderContactId)
if (member) {
log(`Team member ${senderContactId} joined group ${targetGroupId} via /join`)
}
} catch (err) {
logError(`/join failed for group ${targetGroupId}`, err)
await this.sendToGroup(this.config.teamGroup.id, `Error joining group ${targetGroupId}`)
}
}
// --- Helpers ---
private async addOrFindTeamMember(groupId: number, teamContactId: number): Promise<T.GroupMember | null> {
// Pre-check membership: skip apiAddMember entirely if the contact is in
// the group in any non-terminal status. The SimpleX API resends the
// invitation for a member in GSMemInvited, so calling apiAddMember on a
// pending invitee would re-trigger an invite notification.
const members = await this.withMainProfile(() => this.chat.apiListMembers(groupId))
const existing = members.find(m => m.memberContactId === teamContactId && isInGroup(m))
if (existing) return existing
const member = await this.withMainProfile(() =>
this.chat.apiAddMember(groupId, teamContactId, T.GroupMemberRole.Member)
)
try {
await this.withMainProfile(() =>
this.chat.apiSetMembersRole(groupId, [member.groupMemberId], T.GroupMemberRole.Owner)
)
} catch {
// Not yet connected — will be promoted in onMemberConnected
}
return member
}
async sendToGroup(groupId: number, text: string): Promise<void> {
try {
await this.withMainProfile(async () => {
await this.syncGroupCommands(groupId)
await this.chat.apiSendTextMessage([T.ChatType.Group, groupId], text)
})
} catch (err) {
logError(`Failed to send message to group ${groupId}`, err)
}
}
private waitForGrokJoin(groupId: number, timeout: number): Promise<boolean> {
if (this.grokFullyConnected.has(groupId)) return Promise.resolve(true)
return new Promise<boolean>((resolve) => {
const timer = setTimeout(() => {
this.grokJoinResolvers.delete(groupId)
resolve(false)
}, timeout)
this.grokJoinResolvers.set(groupId, () => {
clearTimeout(timer)
resolve(true)
})
})
}
private async sendTeamMemberDM(member: T.GroupMember, memberContact?: T.Contact): Promise<void> {
const name = member.memberProfile.displayName
const formatted = name.includes(" ") ? `'${name}'` : name
let contactId = memberContact?.contactId ?? member.memberContactId
if (!contactId) {
// No DM contact yet — create one and send invitation with message
try {
const contact = await this.withMainProfile(() =>
this.chat.apiCreateMemberContact(this.config.teamGroup.id, member.groupMemberId)
)
contactId = contact.contactId as number
log(`Created DM contact ${contactId} for team member ${name}`)
} catch (err) {
logError(`Failed to create member contact for ${name}`, err)
return
}
if (this.sentTeamDMs.has(contactId)) return
const msg = `Added you to be able to invite you to customer chats later, keep this contact. Your contact ID is ${contactId}:${formatted}`
try {
await this.withMainProfile(() =>
this.chat.apiSendMemberContactInvitation(contactId!, msg)
)
this.sentTeamDMs.add(contactId)
this.pendingTeamDMs.delete(contactId)
log(`Sent DM invitation to team member ${contactId}:${name}`)
} catch {
this.pendingTeamDMs.set(contactId, msg)
}
return
}
// Contact already exists — send via normal DM
if (this.sentTeamDMs.has(contactId)) return
const msg = `Added you to be able to invite you to customer chats later, keep this contact. Your contact ID is ${contactId}:${formatted}`
try {
await this.withMainProfile(() =>
this.chat.apiSendTextMessage([T.ChatType.Direct, contactId], msg)
)
this.sentTeamDMs.add(contactId)
this.pendingTeamDMs.delete(contactId)
log(`Sent DM to team member ${contactId}:${name}`)
} catch {
this.pendingTeamDMs.set(contactId, msg)
}
}
private cleanupGrokMaps(groupId: number): void {
const grokLocalGId = this.grokGroupMap.get(groupId)
this.grokFullyConnected.delete(groupId)
this.grokInitialResponsePending.delete(groupId)
if (grokLocalGId === undefined) return
this.grokGroupMap.delete(groupId)
this.reverseGrokMap.delete(grokLocalGId)
}
}
+473
View File
@@ -0,0 +1,473 @@
import {T} from "@simplex-chat/types"
import {api, util} from "simplex-chat"
import {Mutex} from "async-mutex"
import {Config} from "./config.js"
import {profileMutex, log, logError} from "./util.js"
// State derivation types
export type ConversationState = "WELCOME" | "QUEUE" | "GROK" | "TEAM-PENDING" | "TEAM"
function isConversationState(x: unknown): x is ConversationState {
return x === "WELCOME" || x === "QUEUE" || x === "GROK" || x === "TEAM-PENDING" || x === "TEAM"
}
export interface GroupComposition {
grokMember: T.GroupMember | undefined
teamMembers: T.GroupMember[]
}
interface CardData {
state?: ConversationState
cardItemId?: number
complete?: boolean
}
function isActiveMember(m: T.GroupMember): boolean {
return m.memberStatus === T.GroupMemberStatus.Connected
|| m.memberStatus === T.GroupMemberStatus.Complete
|| m.memberStatus === T.GroupMemberStatus.Announced
}
// Prevent ! from triggering SimpleX markdown styled text (color/small).
// The parser treats !N<space> as color markup (N: 1-6, r, g, b, y, c, m, -)
// and closes at the next !. No escape mechanism exists in the parser,
// so we insert a zero-width space to break the trigger pattern.
function escapeStyledMarkdown(text: string): string {
return text.replace(/!([1-6rgbycm-])/g, "!\u200B$1")
}
// Truncate a single message to ~maxChars, appending [truncated] if needed
function truncateMsg(text: string, maxChars: number): string {
if (text.length <= maxChars) return text
return text.slice(0, maxChars) + "… [truncated]"
}
// Describe non-text content types
function contentTypeLabel(ci: T.ChatItem): string | null {
const content = ci.content as T.CIContent
if (content.type !== "rcvMsgContent" && content.type !== "sndMsgContent") return null
const mc = content.msgContent
switch (mc.type) {
case "image": return "[image]"
case "video": return "[video]"
case "voice": return "[voice]"
case "file": return "[file]"
default: return null
}
}
export class CardManager {
private pendingUpdates = new Set<number>()
private flushInterval: NodeJS.Timeout
// Outer lock; profileMutex (via withMainProfile) is the inner lock.
private customDataMutexes = new Map<number, Mutex>()
constructor(
private chat: api.ChatApi,
private config: Config,
private mainUserId: number,
flushIntervalMs = 300 * 1000,
) {
this.flushInterval = setInterval(() => this.flush(), flushIntervalMs)
this.flushInterval.unref()
}
private async withMainProfile<R>(fn: () => Promise<R>): Promise<R> {
return profileMutex.runExclusive(async () => {
await this.chat.apiSetActiveUser(this.mainUserId)
return fn()
})
}
private getCustomDataMutex(groupId: number): Mutex {
let m = this.customDataMutexes.get(groupId)
if (!m) {
m = new Mutex()
this.customDataMutexes.set(groupId, m)
}
return m
}
scheduleUpdate(groupId: number): void {
this.pendingUpdates.add(groupId)
}
async createCard(groupId: number, groupInfo: T.GroupInfo): Promise<void> {
const {text} = await this.composeCard(groupId, groupInfo)
const chatRef: T.ChatRef = {chatType: T.ChatType.Group, chatId: this.config.teamGroup.id}
const items = await this.withMainProfile(() =>
this.chat.apiSendMessages(chatRef, [
{msgContent: {type: "text", text}, mentions: {}},
])
)
await this.mergeCustomData(groupId, {cardItemId: items[0].chatItem.meta.itemId})
}
async flush(): Promise<void> {
const groups = [...this.pendingUpdates]
this.pendingUpdates.clear()
for (const groupId of groups) {
try {
await this.flushOne(groupId)
} catch (err) {
logError(`Card flush failed for group ${groupId}`, err)
}
}
}
// Dispatches to create-path when cardItemId is absent so a failed createCard retries.
private async flushOne(groupId: number): Promise<void> {
const groups = await this.withMainProfile(() => this.chat.apiListGroups(this.mainUserId))
const groupInfo = groups.find(g => g.groupId === groupId)
if (!groupInfo) return
const data = groupInfo.customData as Record<string, unknown> | undefined
if (typeof data?.cardItemId === "number") {
await this.updateCard(groupId)
} else {
await this.createCard(groupId, groupInfo)
}
}
async refreshAllCards(): Promise<void> {
const groups = await this.withMainProfile(() => this.chat.apiListGroups(this.mainUserId))
const activeCards: {groupId: number; cardItemId: number}[] = []
for (const group of groups) {
const customData = group.customData as Record<string, unknown> | undefined
if (customData && typeof customData.cardItemId === "number" && !customData.complete) {
activeCards.push({groupId: group.groupId, cardItemId: customData.cardItemId})
}
}
if (activeCards.length === 0) return
// Sort ascending by cardItemId — higher ID = more recently updated card.
// Oldest-updated cards refresh first; newest-updated refresh last,
// so the most recent cards end up at the bottom of the team group.
activeCards.sort((a, b) => a.cardItemId - b.cardItemId)
log(`Startup: refreshing ${activeCards.length} card(s)`)
for (const {groupId} of activeCards) {
try {
await this.updateCard(groupId)
} catch (err) {
logError(`Startup card refresh failed for group ${groupId}`, err)
}
}
}
destroy(): void {
clearInterval(this.flushInterval)
}
// --- State derivation ---
async getGroupComposition(groupId: number): Promise<GroupComposition> {
const members = await this.withMainProfile(() => this.chat.apiListMembers(groupId))
return {
grokMember: members.find(m =>
this.config.grokContactId !== null
&& m.memberContactId === this.config.grokContactId
&& isActiveMember(m)),
teamMembers: members.filter(m =>
this.config.teamMembers.some(tm => tm.id === m.memberContactId)
&& isActiveMember(m)),
}
}
async deriveState(groupId: number): Promise<ConversationState> {
const data = await this.getRawCustomData(groupId)
return data?.state ?? "WELCOME"
}
async getLastCustomerMessageTime(groupId: number, customerId: string): Promise<number | undefined> {
const chat = await this.getChat(groupId, 20)
for (let i = chat.chatItems.length - 1; i >= 0; i--) {
const ci = chat.chatItems[i]
if (ci.chatDir.type === "groupRcv" && ci.chatDir.groupMember.memberId === customerId) {
return new Date(ci.meta.createdAt).getTime()
}
}
return undefined
}
async getLastTeamOrGrokMessageTime(groupId: number): Promise<number | undefined> {
const chat = await this.getChat(groupId, 20)
for (let i = chat.chatItems.length - 1; i >= 0; i--) {
const ci = chat.chatItems[i]
if (ci.chatDir.type === "groupRcv") {
const contactId = ci.chatDir.groupMember.memberContactId
const isTeam = this.config.teamMembers.some(tm => tm.id === contactId)
const isGrok = this.config.grokContactId !== null && contactId === this.config.grokContactId
if (isTeam || isGrok) return new Date(ci.meta.createdAt).getTime()
}
if (ci.chatDir.type === "groupSnd") {
// Bot's own messages don't count
}
}
return undefined
}
// --- Custom data ---
async getRawCustomData(groupId: number): Promise<Partial<CardData> | null> {
const groups = await this.withMainProfile(() => this.chat.apiListGroups(this.mainUserId))
const group = groups.find(g => g.groupId === groupId)
if (!group?.customData) return null
const data = group.customData as Record<string, unknown>
const result: Partial<CardData> = {}
if (isConversationState(data.state)) result.state = data.state
if (typeof data.cardItemId === "number") result.cardItemId = data.cardItemId
if (data.complete === true) result.complete = true
return result
}
async mergeCustomData(groupId: number, patch: Partial<CardData>): Promise<void> {
return this.getCustomDataMutex(groupId).runExclusive(async () => {
const current = (await this.getRawCustomData(groupId)) ?? {}
const merged: Partial<CardData> = {...current, ...patch}
for (const key of Object.keys(merged) as (keyof CardData)[]) {
if (merged[key] === undefined) delete merged[key]
}
await this.withMainProfile(() => this.chat.apiSetGroupCustomData(groupId, merged))
})
}
async clearCustomData(groupId: number): Promise<void> {
return this.getCustomDataMutex(groupId).runExclusive(() =>
this.withMainProfile(() => this.chat.apiSetGroupCustomData(groupId))
)
}
// --- Chat history access ---
async getChat(groupId: number, count: number): Promise<T.AChat> {
return this.withMainProfile(() => this.chat.apiGetChat(T.ChatType.Group, groupId, count))
}
// --- Internal ---
private async updateCard(groupId: number): Promise<void> {
// Read customData and groupInfo in one apiListGroups call
const groups = await this.withMainProfile(() => this.chat.apiListGroups(this.mainUserId))
const groupInfo = groups.find(g => g.groupId === groupId)
if (!groupInfo) return
const customData = groupInfo.customData as Record<string, unknown> | undefined
const cardItemId = customData?.cardItemId
if (typeof cardItemId !== "number") return
try {
await this.withMainProfile(() =>
this.chat.apiDeleteChatItems(
T.ChatType.Group, this.config.teamGroup.id, [cardItemId], T.CIDeleteMode.Broadcast
)
)
} catch {
// card may already be deleted
}
const {text, complete} = await this.composeCard(groupId, groupInfo)
const chatRef: T.ChatRef = {chatType: T.ChatType.Group, chatId: this.config.teamGroup.id}
const items = await this.withMainProfile(() =>
this.chat.apiSendMessages(chatRef, [
{msgContent: {type: "text", text}, mentions: {}},
])
)
const patch: Partial<CardData> = {
cardItemId: items[0].chatItem.meta.itemId,
complete: complete ? true : undefined,
}
await this.mergeCustomData(groupId, patch)
}
private async composeCard(groupId: number, groupInfo: T.GroupInfo): Promise<{text: string, complete: boolean}> {
const rawName = groupInfo.groupProfile.displayName || `group-${groupId}`
const customerName = rawName.replace(/\n+/g, " ")
const bc = groupInfo.businessChat
const customerId = bc?.customerId
const state = await this.deriveState(groupId)
const {teamMembers} = await this.getGroupComposition(groupId)
const icon = await this.computeIcon(groupId, state, customerId ?? undefined)
const waitStr = await this.computeWaitTime(groupId, state, customerId ?? undefined)
const chat = await this.getChat(groupId, 100)
const msgCount = chat.chatItems.filter((ci: T.ChatItem) => ci.chatDir.type !== "groupSnd").length
const stateLabel = this.stateLabel(state)
const agentNames = teamMembers.map(m => m.memberProfile.displayName)
const agentStr = agentNames.length > 0 ? ` · ${agentNames.join(", ")}` : ""
const preview = this.buildPreview(chat.chatItems, customerName, customerId)
// Final line uses /'join <id>' quoting so SimpleX clients render the full
// command (including the argument) as a single clickable token.
const joinCmd = `/'join ${groupId}'`
const line1 = `${icon} *${customerName}* · ${waitStr} · ${msgCount} msgs`
const line2 = `${stateLabel}${agentStr}`
return {text: `${line1}\n${line2}\n${preview}\n${joinCmd}`, complete: icon === "✅"}
}
private async computeIcon(
groupId: number, state: ConversationState, customerId?: string,
): Promise<string> {
const now = Date.now()
const completeMs = this.config.completeHours * 3600_000
// Check auto-complete: last team/Grok message time vs customer silence
const lastTeamGrokTime = await this.getLastTeamOrGrokMessageTime(groupId)
if (lastTeamGrokTime) {
const lastCustTime = customerId
? await this.getLastCustomerMessageTime(groupId, customerId)
: undefined
// Auto-complete if team/grok replied and customer hasn't responded since, for completeHours
if (!lastCustTime || lastCustTime < lastTeamGrokTime) {
if (now - lastTeamGrokTime >= completeMs) return "✅"
}
}
switch (state) {
case "QUEUE": {
const lastCustTime = customerId
? await this.getLastCustomerMessageTime(groupId, customerId)
: undefined
if (!lastCustTime) return "🟡"
const waitMs = now - lastCustTime
if (waitMs < 5 * 60_000) return "🆕"
if (waitMs < 2 * 3600_000) return "🟡"
return "🔴"
}
case "GROK":
return "🤖"
case "TEAM-PENDING":
return "👋"
case "TEAM": {
// Check if customer follow-up unanswered > 2h
const lastCustTime = customerId
? await this.getLastCustomerMessageTime(groupId, customerId)
: undefined
if (lastCustTime && lastTeamGrokTime && lastCustTime > lastTeamGrokTime) {
return (now - lastCustTime > 2 * 3600_000) ? "⏰" : "💬"
}
return "💬"
}
default:
return "🟡"
}
}
private async computeWaitTime(
groupId: number, _state: ConversationState, customerId?: string,
): Promise<string> {
const now = Date.now()
const completeMs = this.config.completeHours * 3600_000
const lastTeamGrokTime = await this.getLastTeamOrGrokMessageTime(groupId)
if (lastTeamGrokTime) {
const lastCustTime = customerId
? await this.getLastCustomerMessageTime(groupId, customerId)
: undefined
if (!lastCustTime || lastCustTime < lastTeamGrokTime) {
if (now - lastTeamGrokTime >= completeMs) return "done"
}
}
const lastCustTime = customerId
? await this.getLastCustomerMessageTime(groupId, customerId)
: undefined
if (!lastCustTime) return "<1m"
return this.formatDuration(now - lastCustTime)
}
private stateLabel(state: ConversationState): string {
switch (state) {
case "QUEUE": return "Queue"
case "GROK": return "Grok"
case "TEAM-PENDING": return "Team pending"
case "TEAM": return "Team"
default: return "Queue"
}
}
private buildPreview(chatItems: T.ChatItem[], customerName: string, customerId?: string): string {
const maxTotal = 500
const maxPer = 200
// Collect entries in chronological order (oldest first)
const entries: {senderId: string; name: string; text: string}[] = []
for (const ci of chatItems) {
if (ci.chatDir.type === "groupSnd") continue
let text = (util.ciContentText(ci)?.trim() || "").replace(/\n+/g, " ")
const mediaLabel = contentTypeLabel(ci)
if (mediaLabel && !text) text = mediaLabel
else if (mediaLabel) text = `${mediaLabel} ${text}`
if (!text) continue
let senderId = ""
let name = ""
if (ci.chatDir.type === "groupRcv") {
const member = ci.chatDir.groupMember
const contactId = member.memberContactId
senderId = member.memberId
if (this.config.grokContactId !== null && contactId === this.config.grokContactId) {
name = "Grok"
} else if (customerId && member.memberId === customerId) {
name = customerName
} else {
name = member.memberProfile.displayName
}
}
entries.push({senderId, name, text: truncateMsg(text, maxPer)})
}
// Compute prefixed lines in chronological order (sender prefix on first msg of each run)
const lines: {line: string; senderId: string; name: string}[] = []
let lastSenderId = ""
for (const entry of entries) {
let line = entry.text
if (entry.senderId !== lastSenderId && entry.name) {
line = `${entry.name}: ${line}`
lastSenderId = entry.senderId
}
lines.push({line, senderId: entry.senderId, name: entry.name})
}
// Take from the end (newest) until maxTotal exceeded — oldest messages are truncated
const selected: string[] = []
let totalLen = 0
let firstSelectedIdx = lines.length
for (let i = lines.length - 1; i >= 0; i--) {
if (totalLen + lines[i].line.length > maxTotal && selected.length > 0) {
break
}
selected.push(lines[i].line)
totalLen += lines[i].line.length
firstSelectedIdx = i
}
selected.reverse()
// If truncation happened, ensure the first visible message has a sender prefix
if (firstSelectedIdx > 0 && selected.length > 0) {
const first = lines[firstSelectedIdx]
if (first.name && !selected[0].startsWith(`${first.name}: `)) {
selected[0] = `${first.name}: ${selected[0]}`
}
selected.unshift("[truncated]")
}
const preview = selected.map(escapeStyledMarkdown).join(" !3 /! ")
return preview ? `"${preview}"` : '""'
}
private formatDuration(ms: number): string {
if (ms < 60_000) return "<1m"
if (ms < 3_600_000) return `${Math.floor(ms / 60_000)}m`
if (ms < 86_400_000) return `${Math.floor(ms / 3_600_000)}h`
return `${Math.floor(ms / 86_400_000)}d`
}
}
+144
View File
@@ -0,0 +1,144 @@
import {Command} from "commander"
import {api} from "simplex-chat"
export interface IdName {
id: number
name: string
}
export type Backend = "sqlite" | "postgres"
export interface Config {
stateFile: string // local path to the bot's state JSON
db: api.DbConfig // passed to ChatApi.init / bot.run
teamGroup: IdName // name from CLI, id resolved at startup from state file
teamMembers: IdName[] // optional, empty if not provided
grokContactId: number | null // resolved at startup
timezone: string
completeHours: number
cardFlushSeconds: number
contextFile: string | null
grokApiKey: string | null
}
// Mirrors packages/simplex-chat-nodejs/src/download-libs.js so runtime detection
// matches what was used at install time. Works whether the user installed via
// SIMPLEX_BACKEND env var, .npmrc (→ npm_config_simplex_backend), or the
// --simplex_backend=postgres CLI flag (also surfaced as npm_config_*).
export function detectBackend(): Backend {
const raw = (process.env.SIMPLEX_BACKEND || process.env.npm_config_simplex_backend || "sqlite").toLowerCase()
if (raw !== "sqlite" && raw !== "postgres") {
throw new Error(`Invalid SIMPLEX_BACKEND: "${raw}". Must be "sqlite" or "postgres".`)
}
return raw
}
export function parseIdName(s: string): IdName {
const i = s.indexOf(":")
if (i < 1) throw new Error(`Invalid ID:name format: "${s}"`)
const id = parseInt(s.slice(0, i), 10)
if (isNaN(id)) throw new Error(`Invalid ID:name format (non-numeric ID): "${s}"`)
return {id, name: s.slice(i + 1)}
}
function parseNonNegativeInt(flag: string) {
return (raw: string): number => {
const n = parseInt(raw, 10)
if (!Number.isFinite(n) || n < 0) {
throw new Error(`${flag} must be a non-negative integer, got "${raw}"`)
}
return n
}
}
function buildCommand(): Command {
return new Command()
.name("simplex-chat-support-bot")
.description("business-address triage bot")
.requiredOption("--team-group <name>", "team group display name")
.option("--state-file <path>", "state JSON path", "./data/state.json")
.option("--sqlite-file-prefix <path>", "SQLite DB file prefix", "./data/simplex")
.option("--sqlite-key <key>", "SQLCipher encryption key (default: unencrypted)")
.option("--pg-conn <conn>", "PostgreSQL connection string (required for postgres)")
.option("--pg-schema <prefix>", "PostgreSQL schema prefix (default: simplex_v1)")
.option("-a, --auto-add-team-members <list>", "comma-separated ID:name pairs (e.g. 1:Alice,2:Bob)")
.option("--timezone <iana>", "IANA timezone for weekend detection", "UTC")
.option("--complete-hours <n>", "auto-complete chats after N hours idle (0 disables)", parseNonNegativeInt("--complete-hours"), 3)
.option("--card-flush-seconds <n>", "debounce card state writes", parseNonNegativeInt("--card-flush-seconds"), 300)
.option("--context-file <path>", "text file with Grok system context (required if GROK_API_KEY set)")
.addHelpText("after", "\nEnvironment:\n GROK_API_KEY xAI API key — enables Grok replies\n SIMPLEX_BACKEND sqlite | postgres — alternative to .npmrc for backend selection\n")
}
interface RawOpts {
teamGroup: string
stateFile: string
sqliteFilePrefix: string
sqliteKey?: string
pgConn?: string
pgSchema?: string
autoAddTeamMembers?: string
timezone: string
completeHours: number
cardFlushSeconds: number
contextFile?: string
}
export function parseConfig(args: string[]): Config {
const cmd = buildCommand().exitOverride()
try {
cmd.parse(args, {from: "user"})
} catch (err) {
const code = (err as {code?: string}).code
if (code === "commander.helpDisplayed" || code === "commander.version") process.exit(0)
throw err
}
const opts = cmd.opts<RawOpts>()
const grokApiKey = process.env.GROK_API_KEY || null
const backend = detectBackend()
let db: api.DbConfig
if (backend === "sqlite") {
db = opts.sqliteKey
? {type: "sqlite", filePrefix: opts.sqliteFilePrefix, encryptionKey: opts.sqliteKey}
: {type: "sqlite", filePrefix: opts.sqliteFilePrefix}
} else {
if (!opts.pgConn) {
throw new Error("--pg-conn is required when backend is postgres (PostgreSQL connection string)")
}
db = opts.pgSchema
? {type: "postgres", connectionString: opts.pgConn, schemaPrefix: opts.pgSchema}
: {type: "postgres", connectionString: opts.pgConn}
}
const teamGroup: IdName = {id: 0, name: opts.teamGroup}
const teamMembersRaw = opts.autoAddTeamMembers ?? ""
const teamMembers = teamMembersRaw
? teamMembersRaw.split(",").map(parseIdName)
: []
try {
new Intl.DateTimeFormat("en-US", {timeZone: opts.timezone, weekday: "short"})
} catch (err) {
throw new Error(`--timezone "${opts.timezone}" is not a valid IANA time zone: ${(err as Error).message}`)
}
const contextFile = opts.contextFile ?? null
if (grokApiKey && !contextFile) {
throw new Error("GROK_API_KEY is set but --context-file is not provided. Grok requires a context file.")
}
return {
stateFile: opts.stateFile,
db,
teamGroup,
teamMembers,
grokContactId: null,
timezone: opts.timezone,
completeHours: opts.completeHours,
cardFlushSeconds: opts.cardFlushSeconds,
contextFile,
grokApiKey,
}
}
+55
View File
@@ -0,0 +1,55 @@
import {log, logError} from "./util.js"
export interface GrokMessage {
role: "system" | "user" | "assistant"
content: string
}
export class GrokApiClient {
private readonly apiKey: string
private readonly systemPrompt: string
constructor(apiKey: string, systemPrompt: string) {
this.apiKey = apiKey
this.systemPrompt = systemPrompt
}
async chatRaw(messages: GrokMessage[]): Promise<string> {
const response = 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-mini",
messages,
temperature: 0.3,
max_tokens: 1024,
}),
signal: AbortSignal.timeout(60_000),
})
if (!response.ok) {
const body = await response.text()
logError(`Grok API HTTP ${response.status}`, body)
throw new Error(`Grok API error: HTTP ${response.status}`)
}
const data = await response.json() as {choices: {message: {content: string}}[]}
const content = data.choices?.[0]?.message?.content
if (!content) throw new Error("Grok API returned empty response")
log(`Grok API response: ${content.length} chars`)
return content
}
async chat(history: GrokMessage[], userMessage: string): Promise<string> {
log(`Grok API call: ${history.length} history msgs, user msg ${userMessage.length} chars`)
return this.chatRaw([
{role: "system", content: this.systemPrompt},
...history,
{role: "user", content: userMessage},
])
}
}
File diff suppressed because one or more lines are too long
+43
View File
@@ -0,0 +1,43 @@
import {isWeekend} from "./util.js"
export const welcomeMessage = `Hello! This is a *SimpleX team* support bot - not an AI.
Please ask any question about SimpleX Chat.`
export function queueMessage(timezone: string, grokEnabled: boolean): string {
const hours = isWeekend(timezone) ? "48" : "24"
const base = `The team will reply to your message within ${hours} hours.`
if (!grokEnabled) return base
return `${base}
If your question is about SimpleX, click /grok for an *instant Grok answer*.
Send /team to switch back.`
}
export const grokActivatedMessage = `*You are chatting with Grok* - use any language.`
export function teamAddedMessage(timezone: string, grokPresent: boolean): string {
const hours = isWeekend(timezone) ? "48" : "24"
const base = `We will reply within ${hours} hours.`
if (!grokPresent) return base
return `${base}
Grok will be answering your questions until then.`
}
export const teamAlreadyInvitedMessage = "A team member has already been invited to this conversation and will reply when available."
export const teamLockedMessage = "You are now in team mode. A team member will reply to your message."
export function noTeamMembersMessage(grokEnabled: boolean): string {
return grokEnabled
? "No team members are available yet. Please try again later or click /grok."
: "No team members are available yet. Please try again later."
}
export const grokInvitingMessage = "Inviting Grok, please wait..."
export const grokUnavailableMessage = "Grok is temporarily unavailable. Please try again later or send /team for a human team member."
export const grokErrorMessage = "Sorry, I couldn't process that. Please try again or send /team for a human team member."
export const grokNoHistoryMessage = "I just joined but couldn't see your earlier messages. Could you repeat your question?"
+22
View File
@@ -0,0 +1,22 @@
import {Mutex} from "async-mutex"
export const profileMutex = new Mutex()
export function isWeekend(timezone: string): boolean {
const day = new Intl.DateTimeFormat("en-US", {timeZone: timezone, weekday: "short"}).format(new Date())
return day === "Sat" || day === "Sun"
}
export function log(msg: string, ...args: unknown[]): void {
const ts = new Date().toISOString()
if (args.length > 0) {
console.log(`[${ts}] ${msg}`, ...args)
} else {
console.log(`[${ts}] ${msg}`)
}
}
export function logError(msg: string, err: unknown): void {
const ts = new Date().toISOString()
console.error(`[${ts}] ERROR: ${msg}`, err)
}
@@ -0,0 +1,12 @@
// Mock for @simplex-chat/types — lightweight stubs
const ChatType = {Direct: "direct", Group: "group", Local: "local"}
const GroupMemberRole = {Member: "member", Owner: "owner", Admin: "admin", Relay: "relay", Observer: "observer", Author: "author", Moderator: "moderator"}
const GroupMemberStatus = {Connected: "connected", Complete: "complete", Announced: "announced", Left: "left", Removed: "removed", Invited: "invited"}
const GroupFeatureEnabled = {On: "on", Off: "off"}
const CIDeleteMode = {Broadcast: "broadcast", Internal: "internal"}
module.exports = {
T: {ChatType, GroupMemberRole, GroupMemberStatus, GroupFeatureEnabled, CIDeleteMode},
CEvt: {},
}
@@ -0,0 +1,26 @@
// Mock for simplex-chat — prevents native addon from loading
function ciContentText(chatItem) {
const c = chatItem.content
if (c.type === "sndMsgContent" || c.type === "rcvMsgContent") return c.msgContent.text
return undefined
}
function ciBotCommand(chatItem) {
const text = ciContentText(chatItem)?.trim()
if (text) {
const r = text.match(/\/([^\s]+)(.*)/)
if (r && r.length >= 3) return {keyword: r[1], params: r[2].trim()}
}
return undefined
}
function contactAddressStr(link) {
return link.connShortLink || link.connFullLink
}
module.exports = {
api: {ChatApi: {}},
bot: {},
util: {ciContentText, ciBotCommand, contactAddressStr},
}
+23
View File
@@ -0,0 +1,23 @@
{
"include": ["src"],
"compilerOptions": {
"declaration": true,
"forceConsistentCasingInFileNames": true,
"lib": ["ES2022"],
"module": "Node16",
"moduleResolution": "Node16",
"noFallthroughCasesInSwitch": true,
"noImplicitAny": true,
"noImplicitReturns": true,
"noImplicitThis": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noEmitOnError": true,
"outDir": "dist",
"sourceMap": true,
"strict": true,
"strictNullChecks": true,
"target": "ES2022",
"types": ["node"]
}
}
+22
View File
@@ -0,0 +1,22 @@
import {defineConfig} from "vitest/config"
import path from "path"
export default defineConfig({
test: {
globals: true,
testTimeout: 10000,
// Clear backend signals — .npmrc next to package.json otherwise injects
// npm_config_simplex_backend into every test's env, breaking sqlite-default
// assumptions in parseConfig tests.
env: {
SIMPLEX_BACKEND: "",
npm_config_simplex_backend: "",
},
},
resolve: {
alias: {
"simplex-chat": path.resolve(__dirname, "test/__mocks__/simplex-chat.js"),
"@simplex-chat/types": path.resolve(__dirname, "test/__mocks__/simplex-chat-types.js"),
},
},
})