49 KiB
Support Bot: Analysis & Implementation Plan
Table of Contents
- 1. Architecture
- 2. Issues
- 3. Required API Changes
- 4. Persistent State via customData
- 5. Regular Contact Support
- 6. Implementation Plan
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" |
src/util.ts |
isWeekend(timezone), log(), logError() helpers |
State Machine (both modes)
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)
- Customer connects to business address → platform auto-creates business group
- Platform sends welcome message (auto-reply)
- Customer sends first message → bot forwards to team group with
CustomerName:groupId:prefix, sends queue reply, sends/addcommand to team group /grok→ bot invites Grok agent into group, calls xAI API, Grok sends response as group member/team→ bot removes Grok, invites team member- Team member messages in customer group → bot forwards to team group
- Team member sends message → "team locked" (no more
/grok)
Regular Contact Flow (not yet implemented)
- Customer connects to bot's regular address →
contactConnectedevent - Customer sends direct message → bot creates group "Direct chat with CustomerName"
- Bot invites team member as owner
- Bot forwards customer direct messages into the group (as bot's own
groupSndmessages with customer prefix) - Team member replies in group → bot forwards back to customer's direct chat
/grokin direct chat → bot invites Grok into support group, gathers forwarded customer messages, calls API, Grok sends response to group, bot relays to direct chat/teamin 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):
- Main bot:
apiAddMember(groupId, grokContactId, "member")→ getsmember.memberId - Stores
pendingGrokJoins.set(memberId, mainGroupId) - Grok receives
receivedGroupInvitation→ matchesmemberId→apiJoinGroup(localGroupId)→ sets bidirectional maps - Grok receives
connectedToGroupMember→ resolves waiter viareverseGrokMap - 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)
- Parse config from CLI args +
GROK_API_KEYenv var - Read persisted state from
{dbPrefix}_state.json(teamGroupId,grokContactId,grokGroupMap) - Init Grok agent:
ChatApi.init(grokDbPrefix)→ get/create user →startChat() - Init main bot:
bot.run()with business address, auto-accept, commands, event handlers resolveDisplayNameConflict()— direct SQLite access (problematic, see issue #21)sendChatCmd("/_set accept member contacts ${userId} on")— raw command- Resolve Grok contact from state or auto-establish via invitation link
sendChatCmd("/_groups${userId}")— raw command workaround for parser space bug (Commands.hs:4647)- Resolve/create team group, ensure
directMessages: On - Create ephemeral team group invite link (10-min TTL, scheduled deletion)
- Validate team member contacts against CLI args
- Load Grok context docs from
docs/simplex-context.md - Create
SupportBotinstance, restoregrokGroupMapfrom state, wire persistence callback - Subscribe Grok event handlers, set up graceful shutdown (SIGINT/SIGTERM)
2. Issues
Functional Gaps
-
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. -
Text-only forwarding: Files, images, voice silently dropped — only
ciContentTextused. BothforwardToTeamandforwardToGrokextract text only.ComposedMessagewithfileSourcenever used. -
Team member selection: Always picks
teamMembers[0](bot.ts:513 inactivateTeam, bot.ts:564 inaddReplacementTeamMember). No round-robin, load-based, or random distribution.
Business-Only Guards (block regular contact support)
-
onLeftMemberbusiness-only:if (!bc) return(bot.ts:146) — member leave events for non-business groups silently ignored. Grok cleanup and team replacement won't trigger. -
onChatItemUpdatedbusiness-only:if (!groupInfo.businessChat) return(bot.ts:179) — edits in non-business groups won't forward to team group. -
onBusinessRequestnot applicable to regular contacts:acceptingBusinessRequestevent only fires for business address connections. For bot-created regular contact groups, file upload preferences must be set duringcreateSupportGroup()viaapiNewGroup/apiUpdateGroupProfile. -
activateGrokusesbusinessChat!.customerId(bot.ts:414): Non-null assertion onbusinessChat— crashes if called for non-business groups. Same issue inforwardToGrok(bot.ts:459). -
getGrokHistory/getCustomerMessagesassume customer is group member: These methods filter bygroupMember.memberId === customerId(bot.ts:80, 92). For regular contacts, customer messages are forwarded by the bot asgroupSndmessages (with prefix), not sent directly by the customer. These methods need a separate code path for regular contacts. -
isCustomercheck assumesbusinessChat(bot.ts:275):sender.memberId === groupInfo.businessChat.customerId— fails for non-business groups.
Persistence
-
Fragile file-based persistence:
_state.jsonuses non-atomicwriteFileSync(index.ts:24). Crash during write = corruption. No temp-file + rename pattern. -
State lost on restart:
forwardedItems(edit tracking) is in-memory only (bot.ts:28). Edits after restart silently fail — theonChatItemUpdatedhandler returns early because the forwarded item lookup misses. -
forwardedItemsunbounded: Every forward adds an entry (bot.ts:490), never removed. Memory grows linearly with conversation volume. -
grokGroupMapnot validated on restore: Stale entries (deleted groups) are loaded from_state.json(index.ts:256). Operations on deleted groups cause silent API failures. -
_state.jsonstores infrastructure IDs:teamGroupIdandgrokContactIdare 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
-
apiGetChatmissing fromChatApi: Uses rawsendChatCmd("/_get chat #${groupId} count=${count}")withas anycast (bot.ts:107). The Haskell command exists (APIGetChatin Controller.hs:313, parser in Commands.hs:4489):/_get chat <chatRef> [content=<tag>] <pagination> [search=<term>]→CRApiChat. No typed wrapper available. -
apiListGroupsbroken — 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 TypeScriptcmdStringas'/_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 rawsendChatCmd("/_groups${userId}")(index.ts:156). -
No
customDatawrite API:customDatafield exists onGroupInfo(types.ts:2445) andContact(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 bysimplex-directory-service(Service.hs:790) via direct DB access — wiring to a chat command would expose it to all bots. -
Raw
sendChatCmdfor member contact auto-accept:/_set accept member contacts ${userId} on(index.ts:114). Haskell commandAPISetUserAutoAcceptMemberContactsexists (Controller.hs:274, Commands.hs:4432) but is NOT inchatCommandsDocsData(only listed in the all-commands validation list at Commands.hs:395). No TypeScript interface orChatApiwrapper exists. -
ChatRef.cmdString()code generator bug: Auto-generatedChatRef.cmdString(types.ts:1551-1553) producesself.chatType.toString() + self.chatIdwhich yields"group123"instead of"#123". The root cause is in the TypeScript code generator (bots/src/API/Docs/Syntax.hs:114-116):toStringSyntaxemits.toString()for types with syntax, but TypeScript enum.toString()returns the enum value string ("group"), not the command prefix ("#"). Should callChatType.cmdString(self.chatType)instead. TheChatReftype syntax expression (Types.hs:217) usesParam "chatType"which triggers this code path. AffectsAPISendMessages.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 addonChatApiconstructs commands internally, not viacmdString.
Robustness
-
No Grok API timeout:
fetch()(grok.ts:23) has noAbortController— hangs indefinitely on network issues. Should timeout after 30s. -
Direct SQLite access:
startup.tsshells out tosqlite3CLI forresolveDisplayNameConflict(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()withuseBotProfile: truealready handles display name conflicts by retrying with suffixed names. -
Grok join race: If
connectedToGroupMemberfires beforeonGrokGroupInvitationsets maps (bot.ts:243-252),reverseGrokMap.get(grokGroupId)returnsundefined→ resolver not found → 30s timeout. This can happen if the Grok agent processes events out of order. -
Grok model hardcoded:
grok-3is hardcoded ingrok.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):
-
Controller.hs — Add constructors to
ChatCommand:| APISetGroupCustomData GroupId (Maybe CustomData) | APISetContactCustomData ContactId (Maybe CustomData) -
Commands.hs — Add parser cases (using
optionalto support clearing):"/_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)
CustomDataisnewtype CustomData = CustomData J.Object(Types.hs:249) — only parses JSON objects, notnull. Theoptionalwrapper handles clearing by omitting the JSON arg entirely. -
Commands.hs — Add processor cases:
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 userPattern follows
APISetChatUIThemes(Commands.hs:1364-1382): fetch entity withwithFastStore, write withwithFastStore'(IO, noStoreError), returnok user.
Notes:
setGroupCustomData :: DB.Connection -> User -> GroupInfo -> Maybe CustomData -> IO ()setContactCustomData :: DB.Connection -> User -> Contact -> Maybe CustomData -> IO ()- No new response type needed —
CRCmdOkalready 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):
("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:
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:
("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)
- "/_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 ("#").
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:
return self.chatType.toString() + self.chatId + ... // "group123" ✗
to:
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:
("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
NavigationInfotype definition and itsGenericderiving (Types.hs:560) - Types.hs:361 — Uncomment
ChatPaginationtype definition and itsGenericderiving (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 assimplex-chat) — source in simplex-chat repo. 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:
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:
{
"bot": "support",
"type": "customer",
"grokLocalGroupId": 200,
"forwardedItems": {
"15": {"teamItemId": 88, "prefix": "Alice:100: "}
}
}
Customer group (GroupInfo.customData) — for regular contact groups:
{
"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 (replacesgrokGroupMap)forwardedItems: customer edit tracking (replaces in-memory map). Maps customer source item ID → forwarded targets. For regular contacts, includessupportGroupItemId(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):
{"bot": "support", "type": "team"}
Grok contact (Contact.customData):
{"bot": "support", "role": "grok"}
Regular customer contact (Contact.customData):
{"bot": "support", "supportGroupId": 100}
supportGroupId: group ID of the support group created for this contact
Startup Recovery
apiListGroups(userId)→ iterate all groups, find team group bycustomData.type === "team"apiListContacts(userId)→ find Grok contact bycustomData.role === "grok"- Build
grokGroupMap(RAM) from groups wherecustomData.grokLocalGroupIdis set - Build
reverseGrokMap(RAM) as inverse ofgrokGroupMap - Build
contactToGroupMap(RAM) from contacts wherecustomData.supportGroupIdis set forwardedItemsloaded lazily per-group fromcustomData.forwardedItemson first edit event
Eliminates _state.json — crash recovery is automatic from DB.
customData Write Pattern
Every mutation goes through apiSetGroupCustomData/apiSetContactCustomData:
// 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:
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
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)
- Skip bot's own sent messages (
chatDir.type === "directSnd") - Skip non-customer contacts:
contact.contactId === config.grokContactIdorconfig.teamMembers.some(tm => tm.id === contact.contactId)— prevents creating support groups for team members or the Grok agent messaging the bot directly - Check
contact.customData?.supportGroupId— determines if support group already exists - If no group →
createSupportGroup(contact):- Guard: check
pendingGroupCreationsset to prevent duplicate creation from concurrent messages (addcontactIdbefore async operations, remove afterapiSetContactCustomDatacompletes) apiNewGroup(userId, {displayName: "Direct chat with <displayName>", groupPreferences: {files: {enable: On}, directMessages: {enable: On}}})→ getgroupIdapiSetGroupCustomData(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 frompendingGroupCreations
- Guard: check
- Extract text from
chatItem - 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
/addcommand to team group
- 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 inreverseForwardedItems(mappinggroupItemId → directChatItemId) for edit tracking - Grok response (detected by
sender.memberContactId === grokContactId) → bot relays to customer's direct chat, records inreverseForwardedItemsfor edit tracking
Direct Chat Edit Propagation (customer → group)
When onChatItemUpdated fires with chatInfo.type === "direct":
- Check
contact.customData?.supportGroupId— if not set, ignore - Look up the forwarded item in the support group's
customData.forwardedItemsby source item ID - If found, update the forwarded message in the support group (using
supportGroupItemId) and the team group (usingteamItemId) - 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):
- Identify the sender — skip if it's the bot's own message (
groupSnd) - Look up the edited item in the support group's
customData.reverseForwardedItemsby group item ID - If found, update the corresponding message in the customer's direct chat (using
directChatItemId) - 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
groupRcvbymemberId === customerId(existing behavior) - For regular contact groups: filter
groupSndmessages from bot, strip thename: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
- Remove early returns: bot.ts:259 (
chatInfo.type !== "group") and bot.ts:269 (!groupInfo.businessChat) - Add direct message routing:
chatInfo.type === "direct"→processDirectMessage(contact, chatItem) - Remove business-only guard in
onLeftMember: bot.ts:146 (if (!bc) return) → checkcustomData.type === "customer"instead - Remove business-only guard in
onChatItemUpdated: bot.ts:179 (if (!groupInfo.businessChat) return) → checkcustomData.type === "customer"instead - Replace
businessChat.customerIdeverywhere withcustomData-based identification:- bot.ts:275:
sender.memberId === groupInfo.businessChat.customerId→ for business groups usebusinessChat.customerId, for regular contact groups the concept of "customer member" doesn't apply (customer isn't in the group) - bot.ts:414:
groupInfo.businessChat!.customerIdinactivateGrok→ get fromcustomDataorbusinessChat - bot.ts:459:
groupInfo.businessChat!.customerIdinforwardToGrok→ same
- bot.ts:275:
- Add
processDirectMessage()method — filter out team/grok contacts, create support group, forward, handle commands - Add
createSupportGroup()method — group creation + customData tagging + file upload preferences (files: On, directMessages: On) + team member invitation as Owner - Modify
addOrFindTeamMember()to accept aroleparameter — currently hardcoded toT.GroupMemberRole.Member(bot.ts:573); for regular contact groups, team members must be added asOwner. Default toMemberfor backward compatibility with business group callers. - Add reverse forwarding handler: detect messages in groups with
customData.contactId, forward to direct chat, track inreverseForwardedItems(mappinggroupItemId → directChatItemId) for edit propagation - Adapt
getCustomerMessages/getGrokHistory: dual code path for business vs regular contact groups - Add
contactConnectedevent subscription (index.ts) — send welcome message to regular contacts on connection - Add
newChatItemshandling for direct chats — currently only group messages are processed - Add
chatItemUpdatedhandling for direct chats — propagate customer edits from direct chat to support group (usingsupportGroupItemId) and team group (usingteamItemId) - Add
chatItemUpdatedhandling for regular contact group edits — when team member or Grok edits a message in a group withcustomData.contactId, propagate to customer's direct chat (usingreverseForwardedItems)
6. Implementation Plan
Step 1: Haskell API — customData write commands + bug fixes
Files:
src/Simplex/Chat/Controller.hs— command type constructorssrc/Simplex/Chat/Library/Commands.hs— parser + processor casesbots/src/API/Docs/Commands.hs— add tochatCommandsDocsDatafor TypeScript auto-generationbots/src/API/Docs/Syntax.hs— fix.toString()code generator bug
Changes:
- Add
APISetGroupCustomData GroupId (Maybe CustomData)toChatCommand(Controller.hs) - Add
APISetContactCustomData ContactId (Maybe CustomData)toChatCommand(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,APISetContactCustomDataentries tochatCommandsDocsData(Commands.hs) with syntax:"/_set custom #" <> Param "groupId" <> Optional "" (" " <> Json "$0") "customData" - Add
APISetUserAutoAcceptMemberContactsentry tochatCommandsDocsData(Commands.hs) — currently missing from documented commands - Fix
APIListGroupsparser: 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) + packages/simplex-chat-client/typescript/src/client.ts (this repo)
- Add
apiGetChat(chatType, chatId, count)to bothChatApiandChatClient→ constructs/_get chat <prefix><chatId> count=<count>, returnsAChat - Add
apiSetGroupCustomData(groupId, data | undefined)to both → uses auto-generatedcmdString - Add
apiSetContactCustomData(contactId, data | undefined)to both → uses auto-generatedcmdString - Add
apiSetAutoAcceptMemberContacts(userId, onOff)to both → uses auto-generatedcmdString - Add
apiListContacts(userId)toChatClientonly (already exists on nativeChatApi, 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
customDatainterfaces (SupportGroupData,SupportContactData) and discriminator helpers to bot source - Remove
_state.jsonentirely: deletereadState/writeStatefunctions,BotStateinterface,stateFilePathvariable - Remove
onGrokMapChangedcallback —grokGroupMapnow persisted in each group'scustomData.grokLocalGroupId - Remove
startup.ts— no direct DB access.bot.run()withuseBotProfile: truealready handles display name conflicts - Remove
import {resolveDisplayNameConflict}and its call in index.ts:81 - Replace all
sendChatCmdcalls 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)
- bot.ts:107
- 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/reverseGrokMapfrom groups withcustomData.grokLocalGroupId
- Find team group by
- Tag team group with
customDataon creation:apiSetGroupCustomData(teamGroupId, {bot: "support", type: "team"}) - Tag Grok contact with
customDataon establishment:apiSetContactCustomData(grokContactId, {bot: "support", role: "grok"}) - Store
grokLocalGroupIdin groupcustomDataon Grok join (inonGrokGroupInvitation) - Store
forwardedItemsin groupcustomDataon forward (max 100 entries, evict oldest on overflow) - Update
forwardedItemsread inonChatItemUpdated— fetch from group'scustomDatainstead 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.customerIdchecks withcustomData-based routing throughout (bot.ts:275, 414, 459) - Add
contactConnectedevent handler (index.ts: subscribe tocontactConnectedevent) - 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)— acceptroleparameter (defaultMember) socreateSupportGroupcan passOwner - Add reverse forwarding: group messages in regular contact groups → customer direct chat, with
reverseForwardedItemstracking (mappinggroupItemId → directChatItemId) for edit propagation - Add
chatItemUpdatedhandling for direct chats: propagate customer edits from direct chat to support group (usingsupportGroupItemIdfromforwardedItems) + team group (usingteamItemId) - Add
chatItemUpdatedhandling for regular contact group edits: when team/Grok edits in a group withcustomData.contactId, propagate to customer's direct chat (usingreverseForwardedItems) - Adapt
getCustomerMessages/getGrokHistoryfor dual mode:- Business: filter
groupRcvbymemberId === customerId - Regular: filter
groupSnd(bot's forwarded messages), strip prefix
- Business: filter
- Adapt
customerForwardPrefixfor regular contacts: use contact'sdisplayNamedirectly instead of group profile name - Add
contactToGroupMap(RAM) for fast contact→group lookup, rebuilt on startup from contactcustomData
Blocked by: step 3 Blocks: nothing
Step 5: Media forwarding
Files: apps/simplex-support-bot/src/bot.ts
- Handle file/image/voice in
processChatItem— checkchatItem.filefor received files - Accept incoming files: subscribe to
rcvFileAcceptedevent, callapiReceiveFile(fileId)to download - Forward media via
apiSendMessageswithComposedMessagecontainingfileSourcefor 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
AbortControllerwith 30s timeout to Grok APIfetch():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-modelCLI arg (default"grok-3") - Validate
grokGroupMapentries on restore: verify groups still exist viaapiListMembers, remove stale entries - Fix Grok join race (issue #22): in
onGrokMemberConnected, ifreverseGrokMaplookup fails, checkpendingGrokJoinsas 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.jsonmocks) - Update mock infrastructure: add
apiGetChatmock,customData-based group/contact factories - Regular contact flow end-to-end:
- Direct message → group creation → forward → team reply → relay to direct chat
/grokin direct chat → Grok invite → response relayed to direct chat/teamin 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,contactToGroupMapfromcustomData forwardedItemsrecovery: customer edit after restart should workreverseForwardedItemsrecovery: team/Grok edit after restart should workforwardedItemseviction: verify max 100 entries, oldest evictedreverseForwardedItemseviction: verify max 100 entries, oldest evicted
- Simulate restart: rebuild
- Media forwarding (both modes)
- Both modes coexisting: business + regular contact sessions simultaneously
- Grok join race conditions: out-of-order
receivedGroupInvitation/connectedToGroupMemberevents customDatatagging: verify team group, Grok contact, customer groups all tagged correctly on creation
Blocked by: steps 3-6
Dependency Graph
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