mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-07-31 07:19:37 +00:00
simplex-support-bot: add doc overview of enhanced support bot
This commit is contained in:
@@ -0,0 +1,847 @@
|
||||
# Support Bot: Analysis & Implementation Plan
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [1. Architecture](#1-architecture)
|
||||
- [Source Files](#source-files)
|
||||
- [State Machine](#state-machine-both-modes)
|
||||
- [Business Address Flow](#business-address-flow-existing)
|
||||
- [Regular Contact Flow](#regular-contact-flow-not-yet-implemented)
|
||||
- [Grok Join Protocol](#grok-join-protocol)
|
||||
- [Current In-Memory State](#current-in-memory-state)
|
||||
- [Current Event Handlers](#current-event-handlers)
|
||||
- [Current Commands](#current-commands)
|
||||
- [Startup Sequence](#startup-sequence-indexts)
|
||||
- [2. Issues](#2-issues)
|
||||
- [Functional Gaps](#functional-gaps)
|
||||
- [Business-Only Guards](#business-only-guards-block-regular-contact-support)
|
||||
- [Persistence](#persistence)
|
||||
- [Missing API Methods / Bugs](#missing-api-methods--bugs)
|
||||
- [Robustness](#robustness)
|
||||
- [3. Required API Changes](#3-required-api-changes)
|
||||
- [3.1 Haskell Chat Core — New Commands](#31-haskell-chat-core--new-commands)
|
||||
- [3.2 Bot API Docs — Auto-Generation Pipeline](#32-bot-api-docs--auto-generation-pipeline)
|
||||
- [3.3 ChatApi / ChatClient Wrapper Methods](#33-chatapi--chatclient-wrapper-methods)
|
||||
- [3.4 No Raw Commands](#34-no-raw-commands)
|
||||
- [4. Persistent State via customData](#4-persistent-state-via-customdata)
|
||||
- [TypeScript Interfaces](#typescript-interfaces)
|
||||
- [Schema](#schema)
|
||||
- [Startup Recovery](#startup-recovery)
|
||||
- [customData Write Pattern](#customdata-write-pattern)
|
||||
- [Ephemeral State](#ephemeral-state-ram-only)
|
||||
- [5. Regular Contact Support](#5-regular-contact-support)
|
||||
- [Message Flow](#message-flow)
|
||||
- [processDirectMessage](#processdirectmessagecontact-chatitem)
|
||||
- [Reverse Forwarding](#reverse-forwarding-group--direct-chat)
|
||||
- [Direct Chat Edit Propagation](#direct-chat-edit-propagation-customer--group)
|
||||
- [Group Edit Propagation](#group-edit-propagation-teamgrok--customer-direct-chat)
|
||||
- [activateGrok Adaptations](#activategrok-adaptations-for-regular-contacts)
|
||||
- [Code Changes Required](#code-changes-required)
|
||||
- [6. Implementation Plan](#6-implementation-plan)
|
||||
- [Step 1: Haskell API](#step-1-haskell-api--customdata-write-commands--bug-fixes)
|
||||
- [Step 2: ChatApi / ChatClient wrappers](#step-2-chatapi--chatclient-wrapper-methods)
|
||||
- [Step 3: Bot persistence overhaul](#step-3-bot-persistence-overhaul)
|
||||
- [Step 4: Regular contact support](#step-4-regular-contact-support)
|
||||
- [Step 5: Media forwarding](#step-5-media-forwarding)
|
||||
- [Step 6: Robustness fixes](#step-6-robustness-fixes)
|
||||
- [Step 7: Tests](#step-7-tests)
|
||||
- [Dependency Graph](#dependency-graph)
|
||||
|
||||
---
|
||||
|
||||
## 1. Architecture
|
||||
|
||||
Two SimpleX Chat instances (separate databases) in one process:
|
||||
- **Main bot** (`data/bot_*`): Accepts customers via business address or regular contact address, manages groups, forwards messages
|
||||
- **Grok agent** (`data/grok_*`): Separate identity, joins customer groups to send AI responses
|
||||
|
||||
### Source Files
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `src/index.ts` | Entry point: CLI parsing, dual ChatApi init, startup sequence, event wiring, graceful shutdown |
|
||||
| `src/bot.ts` | Core logic: `SupportBot` class with state derivation, event handlers, message routing, Grok/team activation |
|
||||
| `src/grok.ts` | `GrokApiClient`: xAI API wrapper (`grok-3` model), system prompt construction with injected docs context |
|
||||
| `src/config.ts` | `Config` interface and `parseConfig()`: CLI arg parsing, `GROK_API_KEY` env var validation |
|
||||
| `src/startup.ts` | `resolveDisplayNameConflict()`: direct SQLite access to rename conflicting display names (to be removed) |
|
||||
| `src/messages.ts` | All user-facing message templates: welcome, queue, grok activated, team added, team locked |
|
||||
| `src/state.ts` | `GrokMessage` interface (`{role: "user"|"assistant", content: string}`) |
|
||||
| `src/util.ts` | `isWeekend(timezone)`, `log()`, `logError()` helpers |
|
||||
|
||||
### State Machine (both modes)
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
Welcome --> TeamQueue: 1st customer message
|
||||
TeamQueue --> GrokMode: /grok
|
||||
TeamQueue --> TeamPending: /team
|
||||
GrokMode --> TeamQueue: /team (remove Grok)
|
||||
GrokMode --> GrokMode: customer text (relay to API)
|
||||
TeamPending --> TeamLocked: team member sends message
|
||||
TeamLocked --> TeamLocked: customer text (team sees directly)
|
||||
```
|
||||
|
||||
State is derived from group composition + chat history — not stored explicitly.
|
||||
|
||||
| State | Condition |
|
||||
|-------|-----------|
|
||||
| Welcome | No "forwarded to the team" in bot's `groupSnd` messages |
|
||||
| TeamQueue | No Grok/team member present, bot has sent queue reply |
|
||||
| GrokMode | Grok contact is active member (Connected/Complete/Announced) |
|
||||
| TeamPending | Team member present, hasn't sent any message |
|
||||
| TeamLocked | Team member present, has sent at least one message |
|
||||
|
||||
### Business Address Flow (existing)
|
||||
|
||||
1. Customer connects to business address → platform auto-creates business group
|
||||
2. Platform sends welcome message (auto-reply)
|
||||
3. Customer sends first message → bot forwards to team group with `CustomerName:groupId:` prefix, sends queue reply, sends `/add` command to team group
|
||||
4. `/grok` → bot invites Grok agent into group, calls xAI API, Grok sends response as group member
|
||||
5. `/team` → bot removes Grok, invites team member
|
||||
6. Team member messages in customer group → bot forwards to team group
|
||||
7. Team member sends message → "team locked" (no more `/grok`)
|
||||
|
||||
### Regular Contact Flow (not yet implemented)
|
||||
|
||||
1. Customer connects to bot's regular address → `contactConnected` event
|
||||
2. Customer sends direct message → bot creates group "Direct chat with CustomerName"
|
||||
3. Bot invites team member as **owner**
|
||||
4. Bot forwards customer direct messages into the group (as bot's own `groupSnd` messages with customer prefix)
|
||||
5. Team member replies in group → bot forwards back to customer's direct chat
|
||||
6. `/grok` in direct chat → bot invites Grok into support group, gathers forwarded customer messages, calls API, Grok sends response to group, bot relays to direct chat
|
||||
7. `/team` in direct chat → bot removes Grok if present, invites team member into support group
|
||||
|
||||
| Aspect | Business Address | Regular Contact |
|
||||
|--------|-----------------|-----------------|
|
||||
| Group creation | Platform auto-creates | Bot creates via `apiNewGroup` |
|
||||
| Customer in group | Yes (member) | No (direct chat only) |
|
||||
| Team sees customer | Directly in group | Bot forwards to group (prefixed) |
|
||||
| Team replies | Customer sees in group | Bot forwards to direct chat |
|
||||
| Central team group | Messages forwarded there | Messages forwarded there |
|
||||
| Team member role | Member | Owner |
|
||||
| Grok responses | Sent to group directly (customer sees as member) | Bot relays from group to direct chat |
|
||||
| Customer identification | `businessChat.customerId` (protocol field) | `customData.contactId` on group |
|
||||
| File upload prefs | Set via `onBusinessRequest` event | Set during `createSupportGroup()` |
|
||||
| State derivation | `groupRcv` from customer member | `groupSnd` from bot (forwarded msgs) |
|
||||
|
||||
### Grok Join Protocol
|
||||
|
||||
Two-phase confirmation using protocol-level `memberId` (same value across both databases):
|
||||
|
||||
1. Main bot: `apiAddMember(groupId, grokContactId, "member")` → gets `member.memberId`
|
||||
2. Stores `pendingGrokJoins.set(memberId, mainGroupId)`
|
||||
3. Grok receives `receivedGroupInvitation` → matches `memberId` → `apiJoinGroup(localGroupId)` → sets bidirectional maps
|
||||
4. Grok receives `connectedToGroupMember` → resolves waiter via `reverseGrokMap`
|
||||
5. Main bot continues: gathers customer messages, calls xAI API, Grok sends response
|
||||
|
||||
Race condition guard: after API call, re-checks group composition — if team member appeared, removes Grok and aborts.
|
||||
|
||||
### Current In-Memory State
|
||||
|
||||
| Map | Type | Persisted |
|
||||
|-----|------|-----------|
|
||||
| `pendingGrokJoins` | `memberId → mainGroupId` | No |
|
||||
| `grokGroupMap` | `mainGroupId → grokLocalGroupId` | `_state.json` |
|
||||
| `reverseGrokMap` | `grokLocalGroupId → mainGroupId` | No (derived from `grokGroupMap`) |
|
||||
| `grokJoinResolvers` | `mainGroupId → () => void` | No |
|
||||
| `forwardedItems` | `"groupId:itemId" → {teamItemId, prefix}` | No |
|
||||
|
||||
### Current Event Handlers
|
||||
|
||||
**Main bot:** `acceptingBusinessRequest`, `newChatItems`, `chatItemUpdated`, `leftMember`, `connectedToGroupMember`, `newMemberContactReceivedInv`
|
||||
|
||||
**Grok bot:** `receivedGroupInvitation`, `connectedToGroupMember`
|
||||
|
||||
### Current Commands
|
||||
|
||||
| Command | Where | Effect |
|
||||
|---------|-------|--------|
|
||||
| `/grok` | Customer group (teamQueue) | Invite Grok, gather messages, call API, send response |
|
||||
| `/team` | Customer group (teamQueue/grokMode) | Remove Grok if present, add team member |
|
||||
| `/add groupId:name` | Team group | Add sender to customer group |
|
||||
|
||||
### Startup Sequence (index.ts)
|
||||
|
||||
1. Parse config from CLI args + `GROK_API_KEY` env var
|
||||
2. Read persisted state from `{dbPrefix}_state.json` (`teamGroupId`, `grokContactId`, `grokGroupMap`)
|
||||
3. Init Grok agent: `ChatApi.init(grokDbPrefix)` → get/create user → `startChat()`
|
||||
4. Init main bot: `bot.run()` with business address, auto-accept, commands, event handlers
|
||||
5. `resolveDisplayNameConflict()` — direct SQLite access (problematic, see issue #21)
|
||||
6. `sendChatCmd("/_set accept member contacts ${userId} on")` — raw command
|
||||
7. Resolve Grok contact from state or auto-establish via invitation link
|
||||
8. `sendChatCmd("/_groups${userId}")` — raw command workaround for parser space bug (Commands.hs:4647)
|
||||
9. Resolve/create team group, ensure `directMessages: On`
|
||||
10. Create ephemeral team group invite link (10-min TTL, scheduled deletion)
|
||||
11. Validate team member contacts against CLI args
|
||||
12. Load Grok context docs from `docs/simplex-context.md`
|
||||
13. Create `SupportBot` instance, restore `grokGroupMap` from state, wire persistence callback
|
||||
14. Subscribe Grok event handlers, set up graceful shutdown (SIGINT/SIGTERM)
|
||||
|
||||
---
|
||||
|
||||
## 2. Issues
|
||||
|
||||
### Functional Gaps
|
||||
|
||||
1. **No regular contact support**: `chatInfo.type !== "group"` → return (bot.ts:259). Non-business groups ignored: `if (!groupInfo.businessChat) return` (bot.ts:269). Any direct message or non-business group message is silently dropped.
|
||||
|
||||
2. **Text-only forwarding**: Files, images, voice silently dropped — only `ciContentText` used. Both `forwardToTeam` and `forwardToGrok` extract text only. `ComposedMessage` with `fileSource` never used.
|
||||
|
||||
3. **Team member selection**: Always picks `teamMembers[0]` (bot.ts:513 in `activateTeam`, bot.ts:564 in `addReplacementTeamMember`). No round-robin, load-based, or random distribution.
|
||||
|
||||
### Business-Only Guards (block regular contact support)
|
||||
|
||||
4. **`onLeftMember` business-only**: `if (!bc) return` (bot.ts:146) — member leave events for non-business groups silently ignored. Grok cleanup and team replacement won't trigger.
|
||||
|
||||
5. **`onChatItemUpdated` business-only**: `if (!groupInfo.businessChat) return` (bot.ts:179) — edits in non-business groups won't forward to team group.
|
||||
|
||||
6. **`onBusinessRequest` not applicable to regular contacts**: `acceptingBusinessRequest` event only fires for business address connections. For bot-created regular contact groups, file upload preferences must be set during `createSupportGroup()` via `apiNewGroup`/`apiUpdateGroupProfile`.
|
||||
|
||||
7. **`activateGrok` uses `businessChat!.customerId`** (bot.ts:414): Non-null assertion on `businessChat` — crashes if called for non-business groups. Same issue in `forwardToGrok` (bot.ts:459).
|
||||
|
||||
8. **`getGrokHistory`/`getCustomerMessages` assume customer is group member**: These methods filter by `groupMember.memberId === customerId` (bot.ts:80, 92). For regular contacts, customer messages are forwarded by the bot as `groupSnd` messages (with prefix), not sent directly by the customer. These methods need a separate code path for regular contacts.
|
||||
|
||||
9. **`isCustomer` check assumes `businessChat`** (bot.ts:275): `sender.memberId === groupInfo.businessChat.customerId` — fails for non-business groups.
|
||||
|
||||
### Persistence
|
||||
|
||||
10. **Fragile file-based persistence**: `_state.json` uses non-atomic `writeFileSync` (index.ts:24). Crash during write = corruption. No temp-file + rename pattern.
|
||||
|
||||
11. **State lost on restart**: `forwardedItems` (edit tracking) is in-memory only (bot.ts:28). Edits after restart silently fail — the `onChatItemUpdated` handler returns early because the forwarded item lookup misses.
|
||||
|
||||
12. **`forwardedItems` unbounded**: Every forward adds an entry (bot.ts:490), never removed. Memory grows linearly with conversation volume.
|
||||
|
||||
13. **`grokGroupMap` not validated on restore**: Stale entries (deleted groups) are loaded from `_state.json` (index.ts:256). Operations on deleted groups cause silent API failures.
|
||||
|
||||
14. **`_state.json` stores infrastructure IDs**: `teamGroupId` and `grokContactId` are persisted outside the DB (index.ts:37-38). If the DB is restored from backup but the state file isn't, or vice versa, they desync.
|
||||
|
||||
### Missing API Methods / Bugs
|
||||
|
||||
15. **`apiGetChat` missing from `ChatApi`**: Uses raw `sendChatCmd("/_get chat #${groupId} count=${count}")` with `as any` cast (bot.ts:107). The Haskell command exists (`APIGetChat` in Controller.hs:313, parser in Commands.hs:4489): `/_get chat <chatRef> [content=<tag>] <pagination> [search=<term>]` → `CRApiChat`. No typed wrapper available.
|
||||
|
||||
16. **`apiListGroups` broken — space bug in Haskell parser**: The Haskell parser (Commands.hs:4647) is `"/_groups" *> A.decimal` — missing a space after `/_groups`. The docs syntax (Commands.hs:144) correctly has `"/_groups " <> Param "userId"`, which auto-generates TypeScript `cmdString` as `'/_groups ' + self.userId` (commands.ts:541), producing `/_groups 1`. But the parser rejects this because it expects `/_groups1` (no space). **Root cause is in the Haskell parser, not the docs.** Bot works around with raw `sendChatCmd("/_groups${userId}")` (index.ts:156).
|
||||
|
||||
17. **No `customData` write API**: `customData` field exists on `GroupInfo` (types.ts:2445) and `Contact` (types.ts:1882). Haskell store functions exist — `setGroupCustomData` (Store/Groups.hs:2844), `setContactCustomData` (Store/Direct.hs:1015) — but no chat command wires them to the API. Already used by `simplex-directory-service` (Service.hs:790) via direct DB access — wiring to a chat command would expose it to all bots.
|
||||
|
||||
18. **Raw `sendChatCmd` for member contact auto-accept**: `/_set accept member contacts ${userId} on` (index.ts:114). Haskell command `APISetUserAutoAcceptMemberContacts` exists (Controller.hs:274, Commands.hs:4432) but is NOT in `chatCommandsDocsData` (only listed in the all-commands validation list at Commands.hs:395). No TypeScript interface or `ChatApi` wrapper exists.
|
||||
|
||||
19. **`ChatRef.cmdString()` code generator bug**: Auto-generated `ChatRef.cmdString` (types.ts:1551-1553) produces `self.chatType.toString() + self.chatId` which yields `"group123"` instead of `"#123"`. The root cause is in the TypeScript code generator (`bots/src/API/Docs/Syntax.hs:114-116`): `toStringSyntax` emits `.toString()` for types with syntax, but TypeScript enum `.toString()` returns the enum value string (`"group"`), not the command prefix (`"#"`). Should call `ChatType.cmdString(self.chatType)` instead. The `ChatRef` type syntax expression (Types.hs:217) uses `Param "chatType"` which triggers this code path. Affects `APISendMessages.cmdString` (commands.ts:99), `APIUpdateChatItem.cmdString` (commands.ts:116), `APIDeleteChatItem.cmdString` (commands.ts:132), `APIChatItemReaction.cmdString` (commands.ts:164), `APIDeleteChat.cmdString` (commands.ts:556) — all latent because the native addon `ChatApi` constructs commands internally, not via `cmdString`.
|
||||
|
||||
### Robustness
|
||||
|
||||
20. **No Grok API timeout**: `fetch()` (grok.ts:23) has no `AbortController` — hangs indefinitely on network issues. Should timeout after 30s.
|
||||
|
||||
21. **Direct SQLite access**: `startup.ts` shells out to `sqlite3` CLI for `resolveDisplayNameConflict` (startup.ts:15-36). SQL injection risk from display name (mitigated by single-quote escaping only). Must be removed — no direct DB access, only through API. `bot.run()` with `useBotProfile: true` already handles display name conflicts by retrying with suffixed names.
|
||||
|
||||
22. **Grok join race**: If `connectedToGroupMember` fires before `onGrokGroupInvitation` sets maps (bot.ts:243-252), `reverseGrokMap.get(grokGroupId)` returns `undefined` → resolver not found → 30s timeout. This can happen if the Grok agent processes events out of order.
|
||||
|
||||
23. **Grok model hardcoded**: `grok-3` is hardcoded in `grok.ts:29`. Should be configurable via CLI arg or env var for switching to different models.
|
||||
|
||||
---
|
||||
|
||||
## 3. Required API Changes
|
||||
|
||||
### 3.1 Haskell Chat Core — New Commands
|
||||
|
||||
**`APISetGroupCustomData`**
|
||||
```
|
||||
/_set custom #<groupId> [<json>]
|
||||
```
|
||||
Calls existing `setGroupCustomData` (Store/Groups.hs:2844). Returns `CRCmdOk`. Local DB only — no network. Omit JSON to clear (`Nothing`).
|
||||
|
||||
**`APISetContactCustomData`**
|
||||
```
|
||||
/_set custom @<contactId> [<json>]
|
||||
```
|
||||
Calls existing `setContactCustomData` (Store/Direct.hs:1015). Returns `CRCmdOk`. Local DB only — no network. Omit JSON to clear (`Nothing`).
|
||||
|
||||
**Implementation pattern** (following existing `APISetChatUIThemes` / `APISetUserUIThemes` pattern):
|
||||
|
||||
1. **Controller.hs** — Add constructors to `ChatCommand`:
|
||||
```haskell
|
||||
| APISetGroupCustomData GroupId (Maybe CustomData)
|
||||
| APISetContactCustomData ContactId (Maybe CustomData)
|
||||
```
|
||||
|
||||
2. **Commands.hs** — Add parser cases (using `optional` to support clearing):
|
||||
```haskell
|
||||
"/_set custom #" *> (APISetGroupCustomData <$> A.decimal <*> optional (A.space *> jsonP))
|
||||
"/_set custom @" *> (APISetContactCustomData <$> A.decimal <*> optional (A.space *> jsonP))
|
||||
```
|
||||
|
||||
`optional (A.space *> jsonP)` matches the UIThemes pattern (Commands.hs:4534-4535):
|
||||
- `/_set custom #123` → `APISetGroupCustomData 123 Nothing` (clear)
|
||||
- `/_set custom #123 {"bot":"support"}` → `APISetGroupCustomData 123 (Just (CustomData {...}))` (set)
|
||||
|
||||
`CustomData` is `newtype CustomData = CustomData J.Object` (Types.hs:249) — only parses JSON objects, not `null`. The `optional` wrapper handles clearing by omitting the JSON arg entirely.
|
||||
|
||||
3. **Commands.hs** — Add processor cases:
|
||||
```haskell
|
||||
APISetGroupCustomData groupId customData_ -> withUser $ \user -> do
|
||||
g <- withFastStore $ \db -> getGroupInfo db vr user groupId
|
||||
withFastStore' $ \db -> setGroupCustomData db user g customData_
|
||||
ok user
|
||||
APISetContactCustomData contactId customData_ -> withUser $ \user -> do
|
||||
ct <- withFastStore $ \db -> getContact db vr user contactId
|
||||
withFastStore' $ \db -> setContactCustomData db user ct customData_
|
||||
ok user
|
||||
```
|
||||
|
||||
Pattern follows `APISetChatUIThemes` (Commands.hs:1364-1382): fetch entity with `withFastStore`, write with `withFastStore'` (IO, no `StoreError`), return `ok user`.
|
||||
|
||||
**Notes:**
|
||||
- `setGroupCustomData :: DB.Connection -> User -> GroupInfo -> Maybe CustomData -> IO ()`
|
||||
- `setContactCustomData :: DB.Connection -> User -> Contact -> Maybe CustomData -> IO ()`
|
||||
- No new response type needed — `CRCmdOk` already exists
|
||||
|
||||
### 3.2 Bot API Docs — Auto-Generation Pipeline
|
||||
|
||||
The TypeScript types package (`packages/simplex-chat-client/types/typescript/src/`) is **auto-generated** from Haskell definitions via `bots/src/API/Docs/Generate/TypeScript.hs`. The files `commands.ts`, `responses.ts`, `events.ts`, and `types.ts` must NOT be edited manually — changes go in the Haskell doc sources:
|
||||
|
||||
| TypeScript file | Generated from |
|
||||
|----------------|----------------|
|
||||
| `commands.ts` | `bots/src/API/Docs/Commands.hs` → `chatCommandsDocsData` |
|
||||
| `responses.ts` | `bots/src/API/Docs/Responses.hs` → `chatResponsesDocs` |
|
||||
| `events.ts` | `bots/src/API/Docs/Events.hs` → `chatEventsDocs` |
|
||||
| `types.ts` | `bots/src/API/Docs/Types.hs` → `chatTypesDocs` |
|
||||
|
||||
The syntax expressions in `chatCommandsDocsData` drive `cmdString` generation via `jsSyntaxText` (Syntax.hs:102). The `funcCode` function (Generate/TypeScript.hs:131-143) transforms syntax to TypeScript. The type system (`API/TypeInfo.hs`) maps Haskell types to TypeScript types (e.g., `CustomData` → `JSONObject` → `object`, TypeInfo.hs:181).
|
||||
|
||||
**Required changes to bot API docs:**
|
||||
|
||||
#### 1. Add new commands to `chatCommandsDocsData` (Commands.hs)
|
||||
|
||||
Add to the "Chat commands" category (or create a "Custom data commands" category):
|
||||
|
||||
```haskell
|
||||
("APISetGroupCustomData", [], "Set group custom data.", ["CRCmdOk", "CRChatCmdError"], [], Nothing,
|
||||
"/_set custom #" <> Param "groupId" <> Optional "" (" " <> Json "$0") "customData"),
|
||||
("APISetContactCustomData", [], "Set contact custom data.", ["CRCmdOk", "CRChatCmdError"], [], Nothing,
|
||||
"/_set custom @" <> Param "contactId" <> Optional "" (" " <> Json "$0") "customData")
|
||||
```
|
||||
|
||||
The `Optional "" (" " <> Json "$0") "customData"` syntax expression generates:
|
||||
```typescript
|
||||
export function cmdString(self: APISetGroupCustomData): string {
|
||||
return '/_set custom #' + self.groupId + (self.customData ? ' ' + JSON.stringify(self.customData) : '')
|
||||
}
|
||||
```
|
||||
|
||||
When `customData` is undefined (omitted), no JSON arg is sent → Haskell `optional` parses as `Nothing` → clears.
|
||||
|
||||
#### 2. Add `APISetUserAutoAcceptMemberContacts` to `chatCommandsDocsData` (Commands.hs)
|
||||
|
||||
Currently only in the all-commands validation list (Commands.hs:395), not in the documented commands. Add:
|
||||
|
||||
```haskell
|
||||
("APISetUserAutoAcceptMemberContacts", [], "Set auto-accept member contacts.", ["CRCmdOk", "CRChatCmdError"], [], Nothing,
|
||||
"/_set accept member contacts " <> Param "userId" <> " " <> OnOff "autoAccept")
|
||||
```
|
||||
|
||||
#### 3. Fix `APIListGroups` space bug (Commands.hs:4647)
|
||||
|
||||
```diff
|
||||
- "/_groups" *> (APIListGroups <$> A.decimal <*> ...
|
||||
+ "/_groups " *> (APIListGroups <$> A.decimal <*> ...
|
||||
```
|
||||
|
||||
Add a space after `"/_groups"` in the Haskell parser. The docs syntax (Commands.hs:144) is correct: `"/_groups " <> Param "userId"` — the space belongs there for readability. The parser must match.
|
||||
|
||||
#### 4. Fix code generator `.toString()` bug (Syntax.hs:114-116)
|
||||
|
||||
The `toStringSyntax` function in `jsSyntaxText` generates `.toString()` for `Param` references to types with their own syntax. For enum types like `ChatType`, `.toString()` returns the enum value string (`"group"`) instead of the command prefix (`"#"`).
|
||||
|
||||
```diff
|
||||
toStringSyntax (APITypeDef typeName _)
|
||||
- | typeHasSyntax typeName = paramName' useSelf param p <> ".toString()"
|
||||
+ | typeHasSyntax typeName = typeName <> ".cmdString(" <> paramName' useSelf param p <> ")"
|
||||
| otherwise = paramName' useSelf param p
|
||||
```
|
||||
|
||||
This changes the generated `ChatRef.cmdString` from:
|
||||
```typescript
|
||||
return self.chatType.toString() + self.chatId + ... // "group123" ✗
|
||||
```
|
||||
to:
|
||||
```typescript
|
||||
return ChatType.cmdString(self.chatType) + self.chatId + ... // "#123" ✓
|
||||
```
|
||||
|
||||
Fixes issue #19 for `ChatRef.cmdString` and all commands that use `chatRefP` (`APIUpdateChatItem`, `APIDeleteChatItem`, `APIDeleteChat`, etc.).
|
||||
|
||||
#### 5. Uncomment `APIGetChat` in `chatCommandsDocsData` (Commands.hs:150)
|
||||
|
||||
Currently commented out with empty syntax. Uncomment and add syntax:
|
||||
|
||||
```haskell
|
||||
("APIGetChat", [], "Get chat.", ["CRApiChat", "CRChatCmdError"], [], Nothing,
|
||||
"/_get chat " <> Param "chatRef" <> Optional "" (" content=" <> Param "$0") "contentTag"
|
||||
<> " " <> Param "chatPagination" <> Optional "" (" search=" <> Param "$0") "search")
|
||||
```
|
||||
|
||||
This requires uncommenting the dependent types:
|
||||
|
||||
- **Responses.hs:93** — Uncomment `("CRApiChat", "Chat and messages")`
|
||||
- **Types.hs:367** — Uncomment `NavigationInfo` type definition and its `Generic` deriving (Types.hs:560)
|
||||
- **Types.hs:361** — Uncomment `ChatPagination` type definition and its `Generic` deriving (Types.hs:553)
|
||||
|
||||
**Alternative (simpler):** Keep `APIGetChat` out of the docs and add `apiGetChat` as a manual wrapper in `simplex-chat-nodejs` that constructs the command string directly — like existing `apiListMembers`, `apiAddMember`, etc. This avoids uncommenting the full dependency chain. The bot only needs `count=N` pagination anyway.
|
||||
|
||||
### 3.3 ChatApi / ChatClient Wrapper Methods
|
||||
|
||||
Two TypeScript API clients exist:
|
||||
- **Native addon `ChatApi`** (`simplex-chat-nodejs`, published as `simplex-chat`) — source in [simplex-chat repo](https://github.com/simplex-chat/simplex-chat/tree/master/packages/simplex-chat-nodejs). Used by the support bot.
|
||||
- **WebSocket `ChatClient`** (`packages/simplex-chat-client/typescript/src/client.ts`) — in this repo.
|
||||
|
||||
Both need matching wrappers for new commands. Some existing methods are already available on one but not the other.
|
||||
|
||||
| Method | Native `ChatApi` | WebSocket `ChatClient` | Action needed |
|
||||
|--------|-----------------|----------------------|---------------|
|
||||
| `apiGetChat(chatType, chatId, count)` | Missing (uses raw `sendChatCmd`) | Missing | Add to both — constructs `/_get chat <prefix><chatId> count=<count>`, parses `CRApiChat`, returns `AChat` |
|
||||
| `apiSetGroupCustomData(groupId, data?)` | Missing (new command) | Missing | Add to both — uses auto-generated `APISetGroupCustomData.cmdString` |
|
||||
| `apiSetContactCustomData(contactId, data?)` | Missing (new command) | Missing | Add to both — uses auto-generated `APISetContactCustomData.cmdString` |
|
||||
| `apiSetAutoAcceptMemberContacts(userId, onOff)` | Missing (uses raw `sendChatCmd`) | Missing | Add to both — uses auto-generated `APISetUserAutoAcceptMemberContacts.cmdString` |
|
||||
| `apiListContacts(userId)` | Already exists (index.ts:118) | Missing | Add to `ChatClient` only |
|
||||
| `apiListGroups(userId)` | Exists but broken (parser space bug) | Exists but broken (parser space bug) | Fix parser (step 1), then existing wrappers work |
|
||||
|
||||
### 3.4 No Raw Commands
|
||||
|
||||
The bot must not use `sendChatCmd` directly. All current raw command calls must be replaced with typed API wrappers:
|
||||
|
||||
| Current raw call | Location | Replacement |
|
||||
|------------------|----------|-------------|
|
||||
| `sendChatCmd("/_get chat #${groupId} count=${count}")` | bot.ts:107 | `apiGetChat(ChatType.Group, groupId, count)` |
|
||||
| `sendChatCmd("/_groups${userId}")` | index.ts:156 | `apiListGroups(userId)` (after parser fix) |
|
||||
| `sendChatCmd("/_set accept member contacts ${userId} on")` | index.ts:114 | `apiSetAutoAcceptMemberContacts(userId, true)` |
|
||||
|
||||
---
|
||||
|
||||
## 4. Persistent State via `customData`
|
||||
|
||||
No backward compatibility with `_state.json`. Clean start from `customData`.
|
||||
|
||||
### TypeScript Interfaces
|
||||
|
||||
`customData` is typed as `object | undefined` on `GroupInfo` and `Contact` in the auto-generated TypeScript. The bot must define typed interfaces for type-safe access:
|
||||
|
||||
```typescript
|
||||
interface SupportGroupData {
|
||||
bot: "support"
|
||||
type: "customer" | "team"
|
||||
contactId?: number // regular contact groups only: direct contact ID for reverse forwarding
|
||||
grokLocalGroupId?: number // Grok agent's local group ID (replaces grokGroupMap)
|
||||
forwardedItems?: Record<string, {teamItemId: number; prefix: string; supportGroupItemId?: number}> // edit tracking
|
||||
reverseForwardedItems?: Record<string, {directChatItemId: number}> // regular contacts only: group→direct tracking
|
||||
}
|
||||
|
||||
interface SupportContactData {
|
||||
bot: "support"
|
||||
role?: "grok" // Grok contact identification
|
||||
supportGroupId?: number // regular contacts: group ID of their support group
|
||||
}
|
||||
|
||||
function asSupportGroupData(cd: object | undefined): SupportGroupData | undefined {
|
||||
const d = cd as SupportGroupData | undefined
|
||||
return d?.bot === "support" ? d : undefined
|
||||
}
|
||||
|
||||
function asSupportContactData(cd: object | undefined): SupportContactData | undefined {
|
||||
const d = cd as SupportContactData | undefined
|
||||
return d?.bot === "support" ? d : undefined
|
||||
}
|
||||
```
|
||||
|
||||
The `bot: "support"` discriminator ensures the bot only reads its own `customData` and ignores data written by other bots sharing the same database.
|
||||
|
||||
### Schema
|
||||
|
||||
**Customer group** (`GroupInfo.customData`) — for business address groups:
|
||||
```json
|
||||
{
|
||||
"bot": "support",
|
||||
"type": "customer",
|
||||
"grokLocalGroupId": 200,
|
||||
"forwardedItems": {
|
||||
"15": {"teamItemId": 88, "prefix": "Alice:100: "}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Customer group** (`GroupInfo.customData`) — for regular contact groups:
|
||||
```json
|
||||
{
|
||||
"bot": "support",
|
||||
"type": "customer",
|
||||
"contactId": 42,
|
||||
"grokLocalGroupId": 200,
|
||||
"forwardedItems": {
|
||||
"15": {"teamItemId": 88, "prefix": "Alice:100: ", "supportGroupItemId": 55}
|
||||
},
|
||||
"reverseForwardedItems": {
|
||||
"70": {"directChatItemId": 20}
|
||||
}
|
||||
}
|
||||
```
|
||||
- `contactId`: direct contact ID for reverse forwarding (regular contacts only, omitted for business)
|
||||
- `grokLocalGroupId`: Grok agent's local group ID (replaces `grokGroupMap`)
|
||||
- `forwardedItems`: customer edit tracking (replaces in-memory map). Maps customer source item ID → forwarded targets. For regular contacts, includes `supportGroupItemId` (the forwarded message in the support group). Max 100 entries — evict oldest on overflow.
|
||||
- `reverseForwardedItems`: team/Grok edit tracking (regular contacts only). Maps support group item ID → customer direct chat item ID. When team member or Grok edits a message in the support group, the bot uses this to find and edit the corresponding message in the customer's direct chat. Max 100 entries — evict oldest on overflow.
|
||||
|
||||
**Team group** (`GroupInfo.customData`):
|
||||
```json
|
||||
{"bot": "support", "type": "team"}
|
||||
```
|
||||
|
||||
**Grok contact** (`Contact.customData`):
|
||||
```json
|
||||
{"bot": "support", "role": "grok"}
|
||||
```
|
||||
|
||||
**Regular customer contact** (`Contact.customData`):
|
||||
```json
|
||||
{"bot": "support", "supportGroupId": 100}
|
||||
```
|
||||
- `supportGroupId`: group ID of the support group created for this contact
|
||||
|
||||
### Startup Recovery
|
||||
|
||||
1. `apiListGroups(userId)` → iterate all groups, find team group by `customData.type === "team"`
|
||||
2. `apiListContacts(userId)` → find Grok contact by `customData.role === "grok"`
|
||||
3. Build `grokGroupMap` (RAM) from groups where `customData.grokLocalGroupId` is set
|
||||
4. Build `reverseGrokMap` (RAM) as inverse of `grokGroupMap`
|
||||
5. Build `contactToGroupMap` (RAM) from contacts where `customData.supportGroupId` is set
|
||||
6. `forwardedItems` loaded lazily per-group from `customData.forwardedItems` on first edit event
|
||||
|
||||
Eliminates `_state.json` — crash recovery is automatic from DB.
|
||||
|
||||
### `customData` Write Pattern
|
||||
|
||||
Every mutation goes through `apiSetGroupCustomData`/`apiSetContactCustomData`:
|
||||
|
||||
```typescript
|
||||
// Read current customData from GroupInfo (already in memory from event or apiGetChat)
|
||||
const cd: SupportGroupData = asSupportGroupData(groupInfo.customData) ?? {bot: "support", type: "customer"}
|
||||
// Mutate
|
||||
cd.grokLocalGroupId = grokLocalGroupId
|
||||
// Write back
|
||||
await mainChat.apiSetGroupCustomData(groupId, cd)
|
||||
```
|
||||
|
||||
For `forwardedItems` and `reverseForwardedItems`, the write happens on every forward and edit. To keep both bounded:
|
||||
```typescript
|
||||
function evictOldest(map: Record<string, unknown>, max: number) {
|
||||
const keys = Object.keys(map)
|
||||
if (keys.length >= max) {
|
||||
const oldest = keys.sort((a, b) => Number(a) - Number(b))[0]
|
||||
delete map[oldest]
|
||||
}
|
||||
}
|
||||
|
||||
// Customer message forward (business or regular contact)
|
||||
const items = cd.forwardedItems ?? {}
|
||||
evictOldest(items, 100)
|
||||
items[sourceItemId] = {teamItemId, prefix, supportGroupItemId} // supportGroupItemId only for regular contacts
|
||||
cd.forwardedItems = items
|
||||
|
||||
// Team/Grok message reverse forward (regular contacts only)
|
||||
if (cd.contactId) {
|
||||
const reverse = cd.reverseForwardedItems ?? {}
|
||||
evictOldest(reverse, 100)
|
||||
reverse[groupItemId] = {directChatItemId}
|
||||
cd.reverseForwardedItems = reverse
|
||||
}
|
||||
|
||||
await mainChat.apiSetGroupCustomData(groupId, cd)
|
||||
```
|
||||
|
||||
### Ephemeral State (RAM only)
|
||||
|
||||
| Map | Purpose | Recovery |
|
||||
|-----|---------|----------|
|
||||
| `pendingGrokJoins` | In-flight Grok invitations | User retries `/grok` if lost |
|
||||
| `grokJoinResolvers` | Promise callbacks for Grok join | Timeout after 30s |
|
||||
| `reverseGrokMap` | `grokLocalGroupId → mainGroupId` | Rebuilt from `grokGroupMap` on startup |
|
||||
| `contactToGroupMap` | `contactId → supportGroupId` | Rebuilt from contact `customData` on startup |
|
||||
| `pendingGroupCreations` | `Set<contactId>` — guards against duplicate group creation from concurrent messages | None needed — ephemeral, worst case is a dropped message |
|
||||
|
||||
---
|
||||
|
||||
## 5. Regular Contact Support
|
||||
|
||||
### Message Flow
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant C as Customer
|
||||
participant B as Bot (direct chat)
|
||||
participant G as Support Group
|
||||
participant T as Team Group
|
||||
|
||||
C->>B: sends message
|
||||
B->>G: forward (prefixed, as groupSnd)
|
||||
B->>T: forward (prefixed)
|
||||
Note over G: Team member sees<br/>forwarded message
|
||||
G->>G: Team member replies
|
||||
G->>B: bot reads team reply (groupRcv)
|
||||
B->>C: relay to direct chat
|
||||
|
||||
Note over C,T: /grok flow
|
||||
C->>B: /grok
|
||||
B->>G: invite Grok agent
|
||||
G->>G: Grok sends AI response
|
||||
G->>B: bot reads Grok response (groupRcv)
|
||||
B->>C: relay to direct chat
|
||||
```
|
||||
|
||||
### `processDirectMessage(contact, chatItem)`
|
||||
|
||||
New code path in `processChatItem`:
|
||||
```
|
||||
chatInfo.type === "direct" → processDirectMessage(contact, chatItem)
|
||||
```
|
||||
|
||||
1. Skip bot's own sent messages (`chatDir.type === "directSnd"`)
|
||||
2. Skip non-customer contacts: `contact.contactId === config.grokContactId` or `config.teamMembers.some(tm => tm.id === contact.contactId)` — prevents creating support groups for team members or the Grok agent messaging the bot directly
|
||||
3. Check `contact.customData?.supportGroupId` — determines if support group already exists
|
||||
4. If no group → `createSupportGroup(contact)`:
|
||||
- Guard: check `pendingGroupCreations` set to prevent duplicate creation from concurrent messages (add `contactId` before async operations, remove after `apiSetContactCustomData` completes)
|
||||
- `apiNewGroup(userId, {displayName: "Direct chat with <displayName>", groupPreferences: {files: {enable: On}, directMessages: {enable: On}}})` → get `groupId`
|
||||
- `apiSetGroupCustomData(groupId, {bot: "support", type: "customer", contactId: contact.contactId})`
|
||||
- `apiSetContactCustomData(contact.contactId, {bot: "support", supportGroupId: groupId})`
|
||||
- `apiAddMember(groupId, teamMembers[0].id, Owner)` → invite team member as owner
|
||||
- Update local `contactToGroupMap`, remove from `pendingGroupCreations`
|
||||
5. Extract text from `chatItem`
|
||||
6. If group was just created (step 4 was executed → first message):
|
||||
- Forward to support group with prefix
|
||||
- Forward to team group with prefix
|
||||
- Send queue reply to **direct chat** (not the group)
|
||||
- Send `/add` command to team group
|
||||
7. If group already existed (step 3 found existing group → subsequent message):
|
||||
- `/grok` → `activateGrok(supportGroupId, ...)` then relay Grok response to direct chat
|
||||
- `/team` → `activateTeam(supportGroupId, ...)`
|
||||
- Text → forward to support group + team group
|
||||
|
||||
**Why not `isFirstCustomerMessage`?** The business group version checks for "forwarded to the team" text in `groupSnd` messages. For regular contacts, this text is in the queue message sent to the **direct chat**, not the support group — so the check would always return `true`, treating every message as the first message. Using "was the group just created" is both simpler and correct. On restart, if the group already exists (found via `customData`), the customer has already received the queue message in the previous session, so subsequent messages are handled correctly.
|
||||
|
||||
### Reverse Forwarding (group → direct chat)
|
||||
|
||||
When a message arrives in a group with `customData.type === "customer"` and `customData.contactId`:
|
||||
- **Team member message** → bot forwards to customer's direct chat via `apiSendTextMessage([ChatType.Direct, contactId], text)`, records in `reverseForwardedItems` (mapping `groupItemId → directChatItemId`) for edit tracking
|
||||
- **Grok response** (detected by `sender.memberContactId === grokContactId`) → bot relays to customer's direct chat, records in `reverseForwardedItems` for edit tracking
|
||||
|
||||
### Direct Chat Edit Propagation (customer → group)
|
||||
|
||||
When `onChatItemUpdated` fires with `chatInfo.type === "direct"`:
|
||||
1. Check `contact.customData?.supportGroupId` — if not set, ignore
|
||||
2. Look up the forwarded item in the support group's `customData.forwardedItems` by source item ID
|
||||
3. If found, update the forwarded message in the support group (using `supportGroupItemId`) and the team group (using `teamItemId`)
|
||||
4. This mirrors the existing business group edit propagation but operates on direct chat → group direction instead of group → team group
|
||||
|
||||
### Group Edit Propagation (team/Grok → customer direct chat)
|
||||
|
||||
When `onChatItemUpdated` fires with `chatInfo.type === "group"` and `customData.contactId` is set (regular contact group):
|
||||
1. Identify the sender — skip if it's the bot's own message (`groupSnd`)
|
||||
2. Look up the edited item in the support group's `customData.reverseForwardedItems` by group item ID
|
||||
3. If found, update the corresponding message in the customer's direct chat (using `directChatItemId`)
|
||||
4. This only applies to regular contact groups — in business groups, the customer is in the group and sees edits directly
|
||||
|
||||
### `activateGrok` Adaptations for Regular Contacts
|
||||
|
||||
For regular contacts, customer messages in the support group are the bot's own forwarded messages (`groupSnd` with prefix). The `getCustomerMessages` method needs to:
|
||||
- For business groups: filter `groupRcv` by `memberId === customerId` (existing behavior)
|
||||
- For regular contact groups: filter `groupSnd` messages from bot, strip the `name:groupId: ` prefix using `/^[^:]+:\d+: /` regex, exclude known bot messages (queue reply, grok activated, team added, etc.)
|
||||
|
||||
Similarly, `getGrokHistory` needs:
|
||||
- For business groups: user messages from customer member, assistant messages from Grok member
|
||||
- For regular contact groups: user messages from bot's forwarded messages (stripped prefix via same regex), assistant messages from Grok member
|
||||
|
||||
### Code Changes Required
|
||||
|
||||
1. **Remove early returns**: bot.ts:259 (`chatInfo.type !== "group"`) and bot.ts:269 (`!groupInfo.businessChat`)
|
||||
2. **Add direct message routing**: `chatInfo.type === "direct"` → `processDirectMessage(contact, chatItem)`
|
||||
3. **Remove business-only guard in `onLeftMember`**: bot.ts:146 (`if (!bc) return`) → check `customData.type === "customer"` instead
|
||||
4. **Remove business-only guard in `onChatItemUpdated`**: bot.ts:179 (`if (!groupInfo.businessChat) return`) → check `customData.type === "customer"` instead
|
||||
5. **Replace `businessChat.customerId`** everywhere with `customData`-based identification:
|
||||
- bot.ts:275: `sender.memberId === groupInfo.businessChat.customerId` → for business groups use `businessChat.customerId`, for regular contact groups the concept of "customer member" doesn't apply (customer isn't in the group)
|
||||
- bot.ts:414: `groupInfo.businessChat!.customerId` in `activateGrok` → get from `customData` or `businessChat`
|
||||
- bot.ts:459: `groupInfo.businessChat!.customerId` in `forwardToGrok` → same
|
||||
6. **Add `processDirectMessage()` method** — filter out team/grok contacts, create support group, forward, handle commands
|
||||
7. **Add `createSupportGroup()` method** — group creation + customData tagging + file upload preferences (files: On, directMessages: On) + team member invitation as Owner
|
||||
8. **Modify `addOrFindTeamMember()` to accept a `role` parameter** — currently hardcoded to `T.GroupMemberRole.Member` (bot.ts:573); for regular contact groups, team members must be added as `Owner`. Default to `Member` for backward compatibility with business group callers.
|
||||
9. **Add reverse forwarding handler**: detect messages in groups with `customData.contactId`, forward to direct chat, track in `reverseForwardedItems` (mapping `groupItemId → directChatItemId`) for edit propagation
|
||||
10. **Adapt `getCustomerMessages`/`getGrokHistory`**: dual code path for business vs regular contact groups
|
||||
11. **Add `contactConnected` event subscription** (index.ts) — send welcome message to regular contacts on connection
|
||||
12. **Add `newChatItems` handling for direct chats** — currently only group messages are processed
|
||||
13. **Add `chatItemUpdated` handling for direct chats** — propagate customer edits from direct chat to support group (using `supportGroupItemId`) and team group (using `teamItemId`)
|
||||
14. **Add `chatItemUpdated` handling for regular contact group edits** — when team member or Grok edits a message in a group with `customData.contactId`, propagate to customer's direct chat (using `reverseForwardedItems`)
|
||||
|
||||
---
|
||||
|
||||
## 6. Implementation Plan
|
||||
|
||||
### Step 1: Haskell API — `customData` write commands + bug fixes
|
||||
|
||||
**Files:**
|
||||
- `src/Simplex/Chat/Controller.hs` — command type constructors
|
||||
- `src/Simplex/Chat/Library/Commands.hs` — parser + processor cases
|
||||
- `bots/src/API/Docs/Commands.hs` — add to `chatCommandsDocsData` for TypeScript auto-generation
|
||||
- `bots/src/API/Docs/Syntax.hs` — fix `.toString()` code generator bug
|
||||
|
||||
**Changes:**
|
||||
- Add `APISetGroupCustomData GroupId (Maybe CustomData)` to `ChatCommand` (Controller.hs)
|
||||
- Add `APISetContactCustomData ContactId (Maybe CustomData)` to `ChatCommand` (Controller.hs)
|
||||
- Add parsers: `"/_set custom #" *> ... <*> optional (A.space *> jsonP)` (Commands.hs)
|
||||
- Add processors: fetch entity → call store function → `ok user` (Commands.hs)
|
||||
- Add `APISetGroupCustomData`, `APISetContactCustomData` entries to `chatCommandsDocsData` (Commands.hs) with syntax: `"/_set custom #" <> Param "groupId" <> Optional "" (" " <> Json "$0") "customData"`
|
||||
- Add `APISetUserAutoAcceptMemberContacts` entry to `chatCommandsDocsData` (Commands.hs) — currently missing from documented commands
|
||||
- Fix `APIListGroups` parser: add missing space `"/_groups"` → `"/_groups "` (Commands.hs:4647)
|
||||
- Fix code generator `toStringSyntax`: `.toString()` → `TypeName.cmdString(...)` (Syntax.hs:114-116)
|
||||
- Regenerate TypeScript types (run the code generator)
|
||||
|
||||
**Blocked by:** nothing
|
||||
**Blocks:** steps 2, 3
|
||||
|
||||
### Step 2: ChatApi / ChatClient wrapper methods
|
||||
|
||||
**Files:** `simplex-chat-nodejs` ([simplex-chat repo](https://github.com/simplex-chat/simplex-chat/tree/master/packages/simplex-chat-nodejs)) + `packages/simplex-chat-client/typescript/src/client.ts` (this repo)
|
||||
|
||||
- Add `apiGetChat(chatType, chatId, count)` to both `ChatApi` and `ChatClient` → constructs `/_get chat <prefix><chatId> count=<count>`, returns `AChat`
|
||||
- Add `apiSetGroupCustomData(groupId, data | undefined)` to both → uses auto-generated `cmdString`
|
||||
- Add `apiSetContactCustomData(contactId, data | undefined)` to both → uses auto-generated `cmdString`
|
||||
- Add `apiSetAutoAcceptMemberContacts(userId, onOff)` to both → uses auto-generated `cmdString`
|
||||
- Add `apiListContacts(userId)` to `ChatClient` only (already exists on native `ChatApi`, confirmed by index.ts:118)
|
||||
- Verify `apiListGroups(userId)` works correctly on both after parser fix in step 1 (no new wrapper needed)
|
||||
|
||||
**Blocked by:** step 1 (for auto-generated TypeScript types)
|
||||
**Blocks:** step 3
|
||||
|
||||
### Step 3: Bot persistence overhaul
|
||||
|
||||
**Files:** `apps/simplex-support-bot/src/index.ts`, `bot.ts`
|
||||
**Delete:** `startup.ts`, `_state.json` support
|
||||
|
||||
- Add typed `customData` interfaces (`SupportGroupData`, `SupportContactData`) and discriminator helpers to bot source
|
||||
- Remove `_state.json` entirely: delete `readState`/`writeState` functions, `BotState` interface, `stateFilePath` variable
|
||||
- Remove `onGrokMapChanged` callback — `grokGroupMap` now persisted in each group's `customData.grokLocalGroupId`
|
||||
- Remove `startup.ts` — no direct DB access. `bot.run()` with `useBotProfile: true` already handles display name conflicts
|
||||
- Remove `import {resolveDisplayNameConflict}` and its call in index.ts:81
|
||||
- Replace all `sendChatCmd` calls with typed API wrappers (see section 3.4):
|
||||
- bot.ts:107 `sendChatCmd("/_get chat ...")` → `apiGetChat(ChatType.Group, groupId, count)`
|
||||
- index.ts:156 `sendChatCmd("/_groups...")` → `apiListGroups(userId)` (parser fixed in step 1)
|
||||
- index.ts:114 `sendChatCmd("/_set accept member contacts ...")` → `apiSetAutoAcceptMemberContacts(userId, true)`
|
||||
- Startup recovery from `customData` (see section 4: Startup Recovery):
|
||||
- Find team group by `customData.type === "team"` (replaces state file lookup)
|
||||
- Find Grok contact by `customData.role === "grok"` (replaces state file lookup)
|
||||
- Build `grokGroupMap`/`reverseGrokMap` from groups with `customData.grokLocalGroupId`
|
||||
- Tag team group with `customData` on creation: `apiSetGroupCustomData(teamGroupId, {bot: "support", type: "team"})`
|
||||
- Tag Grok contact with `customData` on establishment: `apiSetContactCustomData(grokContactId, {bot: "support", role: "grok"})`
|
||||
- Store `grokLocalGroupId` in group `customData` on Grok join (in `onGrokGroupInvitation`)
|
||||
- Store `forwardedItems` in group `customData` on forward (max 100 entries, evict oldest on overflow)
|
||||
- Update `forwardedItems` read in `onChatItemUpdated` — fetch from group's `customData` instead of in-memory map
|
||||
|
||||
**Blocked by:** step 2
|
||||
**Blocks:** step 4
|
||||
|
||||
### Step 4: Regular contact support
|
||||
|
||||
**Files:** `apps/simplex-support-bot/src/bot.ts`, `index.ts`
|
||||
|
||||
- Remove early returns for non-group / non-business messages (bot.ts:259, 269)
|
||||
- Remove business-only guards: `onLeftMember` (bot.ts:146), `onChatItemUpdated` (bot.ts:179)
|
||||
- Replace `businessChat.customerId` checks with `customData`-based routing throughout (bot.ts:275, 414, 459)
|
||||
- Add `contactConnected` event handler (index.ts: subscribe to `contactConnected` event)
|
||||
- Add `processDirectMessage(contact, chatItem)` — filter out team/grok contacts, create support group, forward, handle commands
|
||||
- Add `createSupportGroup(contact)` — group creation + customData tagging + file upload preferences (files: On, directMessages: On) + team member invitation as Owner
|
||||
- Modify `addOrFindTeamMember(groupId, contactId, role)` — accept `role` parameter (default `Member`) so `createSupportGroup` can pass `Owner`
|
||||
- Add reverse forwarding: group messages in regular contact groups → customer direct chat, with `reverseForwardedItems` tracking (mapping `groupItemId → directChatItemId`) for edit propagation
|
||||
- Add `chatItemUpdated` handling for direct chats: propagate customer edits from direct chat to support group (using `supportGroupItemId` from `forwardedItems`) + team group (using `teamItemId`)
|
||||
- Add `chatItemUpdated` handling for regular contact group edits: when team/Grok edits in a group with `customData.contactId`, propagate to customer's direct chat (using `reverseForwardedItems`)
|
||||
- Adapt `getCustomerMessages`/`getGrokHistory` for dual mode:
|
||||
- Business: filter `groupRcv` by `memberId === customerId`
|
||||
- Regular: filter `groupSnd` (bot's forwarded messages), strip prefix
|
||||
- Adapt `customerForwardPrefix` for regular contacts: use contact's `displayName` directly instead of group profile name
|
||||
- Add `contactToGroupMap` (RAM) for fast contact→group lookup, rebuilt on startup from contact `customData`
|
||||
|
||||
**Blocked by:** step 3
|
||||
**Blocks:** nothing
|
||||
|
||||
### Step 5: Media forwarding
|
||||
|
||||
**Files:** `apps/simplex-support-bot/src/bot.ts`
|
||||
|
||||
- Handle file/image/voice in `processChatItem` — check `chatItem.file` for received files
|
||||
- Accept incoming files: subscribe to `rcvFileAccepted` event, call `apiReceiveFile(fileId)` to download
|
||||
- Forward media via `apiSendMessages` with `ComposedMessage` containing `fileSource` for the received file
|
||||
- Both business and regular contact modes
|
||||
|
||||
**Blocked by:** nothing (can parallel with step 4)
|
||||
**Blocks:** nothing
|
||||
|
||||
### Step 6: Robustness fixes
|
||||
|
||||
**Files:** `apps/simplex-support-bot/src/grok.ts`, `bot.ts`, `config.ts`
|
||||
|
||||
- Add `AbortController` with 30s timeout to Grok API `fetch()`:
|
||||
```typescript
|
||||
const controller = new AbortController()
|
||||
const timer = setTimeout(() => controller.abort(), 30000)
|
||||
try {
|
||||
const resp = await fetch(url, {...opts, signal: controller.signal})
|
||||
...
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
```
|
||||
- Make Grok model configurable via `--grok-model` CLI arg (default `"grok-3"`)
|
||||
- Validate `grokGroupMap` entries on restore: verify groups still exist via `apiListMembers`, remove stale entries
|
||||
- Fix Grok join race (issue #22): in `onGrokMemberConnected`, if `reverseGrokMap` lookup fails, check `pendingGrokJoins` as fallback — the events may arrive out of order
|
||||
|
||||
**Blocked by:** nothing (can parallel with steps 4-5)
|
||||
**Blocks:** nothing
|
||||
|
||||
### Step 7: Tests
|
||||
|
||||
**Files:** `apps/simplex-support-bot/bot.test.ts`
|
||||
|
||||
- Update existing tests for persistence changes (mock `apiSetGroupCustomData`/`apiSetContactCustomData`, remove `_state.json` mocks)
|
||||
- Update mock infrastructure: add `apiGetChat` mock, `customData`-based group/contact factories
|
||||
- Regular contact flow end-to-end:
|
||||
- Direct message → group creation → forward → team reply → relay to direct chat
|
||||
- `/grok` in direct chat → Grok invite → response relayed to direct chat
|
||||
- `/team` in direct chat → team member added to group
|
||||
- Customer edit in direct chat → propagated to support group + team group (via `forwardedItems`)
|
||||
- Team member edit in support group → propagated to customer direct chat (via `reverseForwardedItems`)
|
||||
- Grok edit in support group → propagated to customer direct chat (via `reverseForwardedItems`)
|
||||
- Team member added as Owner (not Member) in regular contact groups
|
||||
- Contact filtering in `processDirectMessage`:
|
||||
- Team member direct message → ignored (no support group created)
|
||||
- Grok contact direct message → ignored (no support group created)
|
||||
- Persistence recovery from `customData`:
|
||||
- Simulate restart: rebuild `grokGroupMap`, `contactToGroupMap` from `customData`
|
||||
- `forwardedItems` recovery: customer edit after restart should work
|
||||
- `reverseForwardedItems` recovery: team/Grok edit after restart should work
|
||||
- `forwardedItems` eviction: verify max 100 entries, oldest evicted
|
||||
- `reverseForwardedItems` eviction: verify max 100 entries, oldest evicted
|
||||
- Media forwarding (both modes)
|
||||
- Both modes coexisting: business + regular contact sessions simultaneously
|
||||
- Grok join race conditions: out-of-order `receivedGroupInvitation`/`connectedToGroupMember` events
|
||||
- `customData` tagging: verify team group, Grok contact, customer groups all tagged correctly on creation
|
||||
|
||||
**Blocked by:** steps 3-6
|
||||
|
||||
### Dependency Graph
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
S1["Step 1<br/>Haskell API + docs"] --> S2["Step 2<br/>ChatApi wrappers"]
|
||||
S2 --> S3["Step 3<br/>Persistence overhaul"]
|
||||
S3 --> S4["Step 4<br/>Regular contacts"]
|
||||
|
||||
S5["Step 5<br/>Media forwarding"] -.->|parallel with 3-4| S7
|
||||
S6["Step 6<br/>Robustness fixes"] -.->|parallel with 3-5| S7
|
||||
|
||||
S3 --> S7["Step 7<br/>Tests"]
|
||||
S4 --> S7
|
||||
S5 --> S7
|
||||
S6 --> S7
|
||||
```
|
||||
Reference in New Issue
Block a user