From 2e082f2b777223d5da45ff9b77875236194152fa Mon Sep 17 00:00:00 2001 From: Narasimha-sc <166327228+Narasimha-sc@users.noreply.github.com> Date: Sat, 18 Apr 2026 11:25:05 +0000 Subject: [PATCH] support-bot: Update state machine plans --- .../20260207-support-bot-implementation.md | 65 ++++++++++--------- .../plans/20260207-support-bot.md | 10 +-- 2 files changed, 39 insertions(+), 36 deletions(-) diff --git a/apps/simplex-support-bot/plans/20260207-support-bot-implementation.md b/apps/simplex-support-bot/plans/20260207-support-bot-implementation.md index e31a663447..14fb9ffb24 100644 --- a/apps/simplex-support-bot/plans/20260207-support-bot-implementation.md +++ b/apps/simplex-support-bot/plans/20260207-support-bot-implementation.md @@ -88,7 +88,7 @@ interface Config { {"teamGroupId": 123, "grokContactId": 4} ``` -Only two keys. All other state is derived from chat history, group metadata, or `customData`. +Only two keys. All other state is persisted in the group's `customData` (per-conversation state, card IDs) or derived from group metadata (`apiListMembers`). Display data like message counts is read from chat history on demand. **Grok contact resolution** (state-file lookup always runs; contact establishment only when enabled): 1. Read `grokContactId` from state file → validate via `apiListContacts` → set `config.grokContactId` (this always runs, even when `grokApiKey === null`, so the one-way gate can identify and remove Grok members from groups) @@ -111,28 +111,28 @@ Only two keys. All other state is derived from chat history, group metadata, or ## 5. State Derivation (Stateless) -State is derived from group composition (`apiListMembers`), the group's `customData` (persisted in SimpleX's database), and chat history (only for the TEAM vs TEAM-PENDING discrimination). No in-memory conversations map — survives restarts. +Per-conversation state is stored in the group's `customData` and written at the moment the bot handles each transition (customer's first message, `/grok`, `/team`, team member's first message). On subsequent events `deriveState` returns the stored state as-is — composition changes (team members leaving, Grok leaving) do **not** demote the stored state. The customer's mode (e.g. "waiting for a team response") is meaningful even when no team member is currently present; keeping the state preserves that. Composition is read only by specific handlers (e.g. the `/team` duplicate-invite guard). No chat-history scans for state decisions. No in-memory conversations map — survives restarts. -**First message detection:** the group's `customData` has no `cardItemId` until the bot has produced its first response in the group. `isFirstCustomerMessage(groupId)` returns `true` iff `customData.cardItemId` is absent. No message-text introspection. +**WELCOME detection:** customData has no `state` field until the bot handles the first transition. `deriveState` returns `WELCOME` precisely when `customData.state` is absent. -**Derived states:** +**State-write matrix:** -| Condition | State | -|-----------|-------| -| No `cardItemId` in `customData` | WELCOME | -| `cardItemId` present, no Grok member, no team member | QUEUE | -| Grok member present, no team member present | GROK | -| Team member present, no team member has sent a message | TEAM-PENDING | -| Team member present, team member has sent a message | TEAM | +| Bot-observed event | `customData.state` written | +|---|---| +| *(initial — no customData yet)* | *(absent ⇒ WELCOME)* | +| Customer's first non-command message | `QUEUE` | +| `/grok` handled — Grok invited | `GROK` | +| `/team` handled — team members added (written at handler time; does not wait for team acceptance) | `TEAM-PENDING` | +| First team-member text message observed | `TEAM` | + +**State is authoritative and monotonic.** Once written, `customData.state` persists across member leave/join events. The only path that clears it is the existing `onLeftMember` handler when the customer themselves leaves — at that point the entire customData is cleared. TEAM-PENDING takes priority over GROK when both Grok and team are present (after `/team` but before team member's first message). `/grok` remains available in TEAM-PENDING — if Grok is not yet in the group, it gets invited; if already present, the command is ignored. **State derivation helpers:** -- `getGroupComposition(groupId)` → `{grokMember, teamMembers}` from `apiListMembers` -- `isFirstCustomerMessage(groupId)` → returns `true` iff `customData.cardItemId` is absent -- `hasTeamMemberSentMessage(groupId)` → TEAM-PENDING vs TEAM from chat history -- `getLastCustomerMessageTime(groupId)` → for card wait time calculation -- `getLastTeamOrGrokMessageTime(groupId)` → for auto-complete threshold check +- `getGroupComposition(groupId)` → `{grokMember, teamMembers}` from `apiListMembers` — used for card rendering and the `/team` duplicate-invite guard. +- `deriveState(groupId)` → reads `customData.state`. Returns `WELCOME` iff `customData.state` is absent. No composition lookup. +- `getLastCustomerMessageTime(groupId)` / `getLastTeamOrGrokMessageTime(groupId)` → chat-history timestamp reads used by the card renderer for wait-time and auto-complete only (display, not state). **Transitions:** ``` @@ -144,7 +144,7 @@ QUEUE ──(/team)──────────> TEAM-PENDING (add team member GROK ──(/team)───────────> TEAM-PENDING (add all team members, Grok stays, update card) GROK ──(user msg)────────> GROK (Grok responds, update card) TEAM-PENDING ──(/grok)───> invite Grok if not present, else ignore (state stays TEAM-PENDING) -TEAM-PENDING ──(/team)───> reply "already invited" (guarded by customData.teamInvited) +TEAM-PENDING ──(/team)───> reply "already invited" (if team members still present; else re-add silently) TEAM-PENDING ──(team msg)> TEAM (remove Grok, disable /grok permanently, update card) TEAM ──(/grok)───────────> reply "team mode", stay TEAM ``` @@ -192,7 +192,7 @@ Card is two messages. **Message 1 (card text):** ### Card lifecycle -**Tracking:** `{cardItemId, joinItemId, complete?, teamInvited?}` stored in customer group's `customData` via `apiSetGroupCustomData`. `cardItemId`/`joinItemId` are message IDs; `complete` flags the auto-completed state; `teamInvited` flags that `/team` has been invoked at least once (gates the duplicate-invite path). Read back from `groupInfo.customData`. Single source of truth — survives restarts. All writes go through `CardManager.mergeCustomData` to preserve fields across independent write paths. +**Tracking:** `{state, cardItemId, joinItemId, complete?}` stored in customer group's `customData` via `apiSetGroupCustomData`. `state` is the canonical conversation state (`QUEUE | GROK | TEAM-PENDING | TEAM`); `cardItemId`/`joinItemId` are message IDs; `complete` flags the auto-completed state. Absence of `state` means WELCOME. Written at event time by the dispatch handlers — `/grok` handler writes `GROK` on invite; `/team` handler writes `TEAM-PENDING` immediately (does not wait for team acceptance); first observed team-member text message writes `TEAM`; first customer text message writes `QUEUE`. Read back from `groupInfo.customData` — single source of truth, survives restarts. All writes go through `CardManager.mergeCustomData` to preserve fields across independent write paths. **Create** — on first customer message (→ QUEUE) or `/grok` as first message (→ GROK): 1. Compose card text + `/join` command @@ -462,26 +462,26 @@ chat.on("connectedToGroupMember", (evt) => { // - WELCOME: create card, send queue msg (or handle /grok first msg → WELCOME→GROK, skip queue) // - QUEUE: /grok → invite Grok; /team → add ALL configured team members; else schedule card update // - GROK: /team → add ALL configured team members (Grok stays); else schedule card update -// - TEAM-PENDING: /grok → invite Grok if not present, else ignore; /team → reply "already invited" (check `customData.teamInvited`); else no action +// - TEAM-PENDING: /grok → invite Grok if not present, else ignore; /team → if team members still present, reply "already invited"; if all team members have left, re-add silently (state stays TEAM-PENDING); else no action // - TEAM: /grok → reply "team mode"; else no action ``` ## 9. One-Way Gate -The gate is stateless — derived from group composition + chat history. The initial `/team` guard against duplicate invites now uses the `customData.teamInvited` flag rather than history scanning. +The gate is event-driven and persists its transitions. The initial `/team` guard reads `customData.state` AND group composition: if state is already `TEAM-PENDING`/`TEAM` **and** team members are still present, the bot replies `teamAlreadyInvitedMessage` without re-adding. If state is `TEAM-PENDING`/`TEAM` but all team members have left, the bot re-adds them (state stays `TEAM-PENDING`). The first-team-message detection writes `state: 'TEAM'` into customData at the moment the bot observes the message, then removes Grok and disables `/grok`. 1. User sends `/team` → ALL configured `--auto-add-team-members` (`-a`) added to group (promoted to Owner on connect) → Grok stays if present → TEAM-PENDING -2. Repeat `/team` → detected via `customData.teamInvited === true` → reply with `teamAlreadyInvitedMessage` +2. Repeat `/team` → detected via `customData.state ∈ {TEAM-PENDING, TEAM}` **and team members still present** → reply with `teamAlreadyInvitedMessage`. If team members have since left, re-add them silently (state stays `TEAM-PENDING`). 3. `/grok` still works in TEAM-PENDING (if Grok not present, invite it; if present, ignore — Grok responds to customer messages) 4. Any team member sends first text message in customer group → **gate triggers**: - Remove Grok from group (`apiRemoveMembers`) - `/grok` permanently disabled → replies: "You are now in team mode. A team member will reply to your message." - - State = TEAM (derived via `hasTeamMemberSentMessage`) -5. Detection: in `onNewChatItems`, when sender is a team member, check `hasTeamMemberSentMessage` — if this is the first, trigger gate. + - State = `TEAM` (written as `customData.state = 'TEAM'` at observation time) +5. Detection: in `onNewChatItems`, when sender is a team member and `customData.state !== 'TEAM'`, trigger the gate and write `state: 'TEAM'` via `mergeCustomData`. **Edge cases:** -- All team members leave before sending → reverts to QUEUE (stateless) -- Team member leaves after sending → state stays TEAM (derived from chat history); customer can send `/team` again to re-add team members +- All team members leave before sending → state stays `TEAM-PENDING` (customer is still waiting for a response); sending `/team` re-adds them without the "already invited" reply. +- Team member leaves after sending → state stays `TEAM` (`customData.state` persists). Customer can send `/team` again to re-add team members. ## 10. Grok Integration @@ -694,7 +694,7 @@ If a user contacts the bot via a regular direct-message address (not business ad | State | Where it lives | |-------|---------------| -| `cardItemId`, `joinItemId`, `complete`, `teamInvited` | Customer group's `customData` | +| `state`, `cardItemId`, `joinItemId`, `complete` | Customer group's `customData` | | User profile IDs | Resolved via `apiListUsers()` by display name | | Message counts, timestamps | Derived from chat history | | Customer name | Group display name | @@ -719,8 +719,8 @@ If a user contacts the bot via a regular direct-message address (not business ad | `apiRemoveMembers` fails | Ignore (member may have left) | | `apiDeleteChatItems` fails (card) | Ignore, post new card, overwrite `customData` | | Customer leaves | Cleanup in-memory state, card remains | -| Team member leaves (no message sent) | Revert to QUEUE (stateless) | -| Team member leaves (message sent) | Logged; customer can `/team` to re-add | +| Team member leaves (no message sent) | State stays `TEAM-PENDING` (`customData.state` persists). Customer's next `/team` re-adds silently. | +| Team member leaves (message sent) | State stays `TEAM` (`customData.state` persists). Customer's next `/team` re-adds silently. | | No `--auto-add-team-members` (`-a`) configured | `/team` → "no team members available yet" | | `grokContactId` unavailable | `/grok` → "temporarily unavailable" | | `groupDuplicateMember` | Catch, `apiListMembers` to find existing member | @@ -1123,9 +1123,12 @@ Called as: `const p = simulateGrokJoinSuccess(); await bot.onNewChatItems(...); - teamLockedMessage content - queueMessage mentions hours -#### 24. isFirstCustomerMessage detection (2 tests) -- returns true when `customData` is empty -- returns false once `cardItemId` is set +#### 24. State persistence in customData (5 tests) +- `deriveState` returns `WELCOME` when `customData.state` is absent +- first customer non-command message → handler writes `customData.state = "QUEUE"` +- `/grok` handler → writes `customData.state = "GROK"` +- `/team` handler → writes `customData.state = "TEAM-PENDING"` immediately (before team member accepts) +- first team-member text message → gate writes `customData.state = "TEAM"`; state persists when team member subsequently leaves (not demoted to `QUEUE`) #### 25. Card Preview Sender Prefixes (14 tests) - single customer message → name prefix diff --git a/apps/simplex-support-bot/plans/20260207-support-bot.md b/apps/simplex-support-bot/plans/20260207-support-bot.md index fa38afd0e9..c24b68563e 100644 --- a/apps/simplex-support-bot/plans/20260207-support-bot.md +++ b/apps/simplex-support-bot/plans/20260207-support-bot.md @@ -88,7 +88,7 @@ Grok is prompted as a privacy expert and support assistant who knows SimpleX Cha #### Step 4 — `/team` (Team mode, one-way gate) -Available in WELCOME, QUEUE, or GROK state. If `/team` is the customer's first message, the bot transitions directly from WELCOME → TEAM-PENDING — it creates the card with 👋 icon and does not send the queue message. Bot adds all configured `--auto-add-team-members` (`-a`) to the support group (promoted to Owner once connected — the bot promotes every non-customer, non-Grok member to Owner on `memberConnected`; safe to repeat). If team was already activated (detected via the `customData.teamInvited` flag), sends the "already invited" message instead. +Available in WELCOME, QUEUE, or GROK state. If `/team` is the customer's first message, the bot transitions directly from WELCOME → TEAM-PENDING — it creates the card with 👋 icon and does not send the queue message. Bot adds all configured `--auto-add-team-members` (`-a`) to the support group (promoted to Owner once connected — the bot promotes every non-customer, non-Grok member to Owner on `memberConnected`; safe to repeat). If team was already activated (`customData.state` is already `TEAM-PENDING` or `TEAM` **and** team members are still present), sends the "already invited" message instead. If the team was previously activated but all team members have since left, the bot re-adds them silently; state remains `TEAM-PENDING`. Bot replies: > We will reply within 24 hours. @@ -324,7 +324,7 @@ When a team member taps `/join`, the bot first verifies that the target `groupId | Situation | What happens | |-----------|-------------| -| All team members leave before any sends a message | State reverts to QUEUE (stateless derivation — no team member present) | +| All team members leave before any sends a message | State stays `TEAM-PENDING` (customer is still waiting for a response). Next `/team` re-adds them silently. | | Customer leaves | All in-memory state cleaned up; card remains (TBD) | | No `--auto-add-team-members` (`-a`) configured | `/team` tells customer "no team members available yet" | | Team member already in customer group | `apiListMembers` lookup finds existing member — no error | @@ -477,7 +477,7 @@ The bot writes a single JSON file (`{dbPrefix}_state.json`) that survives restar #### Why a state file at all? -SimpleX Chat's own database stores the full message history and group membership, but it does not store the bot's derived knowledge — things like which team group was created on first run, or which contact is the established bot↔Grok link. All other derived state (message counts, timestamps, last sender) is re-derived from chat history or group metadata on demand. +SimpleX Chat's own database stores the full message history and group membership, but it does not store the bot's derived knowledge — things like which team group was created on first run, or which contact is the established bot↔Grok link. Per-conversation state (QUEUE/GROK/TEAM-PENDING/TEAM) is written into the customer group's `customData` at the moment the bot handles each transition — it observes its own `/grok` invite, `/team` add, team message, first customer message. Only display data (message counts, timestamps, sender names) is re-derived from chat history on demand. #### What is persisted and why @@ -490,11 +490,11 @@ User profile IDs (`mainUserId`, `grokUserId`) are **not** persisted — they are #### What is NOT persisted and why -Per-group state flags (`cardItemId`, `joinItemId`, `complete`, `teamInvited`) live in SimpleX's database as the group's `customData` — persisted there rather than in the bot's state file. +Per-group state (`state`, `cardItemId`, `joinItemId`, `complete`) lives in SimpleX's database as the group's `customData` — persisted there rather than in the bot's state file. | State | Where it lives instead | |-------|----------------------| -| `cardItemId, joinItemId, complete, teamInvited` (per group) | Stored in the group's customData — card message IDs, auto-completed flag, and whether `/team` has been invoked | +| `state, cardItemId, joinItemId, complete` (per group) | Stored in the group's customData — conversation state, card message IDs, auto-completed flag. `state` is written at event time (first customer message, `/grok`, `/team`, team's first message); the bot never re-derives it by scanning chat history. | | Last customer message time | Derived from most recent customer message in chat history | | Message count | Derived from message count in chat history (all messages except the bot's own) | | Customer name | Always available from the group's display name |