From fde776dcf2e009d8cada638ca0ea5bf06cc9bce3 Mon Sep 17 00:00:00 2001 From: Narasimha-sc <166327228+Narasimha-sc@users.noreply.github.com> Date: Sat, 18 Apr 2026 12:39:03 +0000 Subject: [PATCH] support-bot: implement customData state --- apps/simplex-support-bot/bot.test.ts | 89 +++++++++++----------- apps/simplex-support-bot/src/bot.ts | 105 +++++++++++++++----------- apps/simplex-support-bot/src/cards.ts | 95 ++++++++--------------- 3 files changed, 143 insertions(+), 146 deletions(-) diff --git a/apps/simplex-support-bot/bot.test.ts b/apps/simplex-support-bot/bot.test.ts index 46525db78e..8480af5421 100644 --- a/apps/simplex-support-bot/bot.test.ts +++ b/apps/simplex-support-bot/bot.test.ts @@ -636,6 +636,7 @@ describe("/grok Activation", () => { await reachQueue() addBotMessage("The team will reply to your message") await bot.onNewChatItems(customerMessage("/grok")) + await bot.flush() expectSentToGroup(CUSTOMER_GROUP_ID, "temporarily unavailable") }) @@ -911,14 +912,15 @@ describe("Team Member Lifecycle", () => { expect(chat.roleChanges.length).toBe(0) }) - test("all team members leave before sending → reverts to QUEUE", async () => { + test("all team members leave before sending → state stays TEAM-PENDING", async () => { await reachTeamPending() addBotMessage("We will reply within 24 hours.") // Remove team members from the group chat.members.set(CUSTOMER_GROUP_ID, []) - // Customer sends another message — state should derive as QUEUE (no team members) + // State is authoritative and monotonic — composition changes never demote it. + // Customer is still waiting for the team's response. const state = await cards.deriveState(CUSTOMER_GROUP_ID) - expect(state).toBe("QUEUE") + expect(state).toBe("TEAM-PENDING") }) test("/team after all team members left (TEAM-PENDING, no msg sent) → re-adds members", async () => { @@ -1056,32 +1058,31 @@ describe("Card Debouncing", () => { describe("Card Format & State Derivation", () => { beforeEach(() => setup()) - test("QUEUE state derived when no Grok or team members", async () => { - addBotMessage("The team will reply to your message") + test("QUEUE state read from customData.state", async () => { + chat.customData.set(CUSTOMER_GROUP_ID, {cardItemId: 1234, state: "QUEUE"}) const state = await cards.deriveState(CUSTOMER_GROUP_ID) expect(state).toBe("QUEUE") }) - test("WELCOME state derived for first customer message (no bot messages yet)", async () => { + test("WELCOME state when customData.state is absent", async () => { const state = await cards.deriveState(CUSTOMER_GROUP_ID) expect(state).toBe("WELCOME") }) - test("GROK state derived when Grok member present", async () => { - chat.members.set(CUSTOMER_GROUP_ID, [makeGrokMember()]) + test("GROK state read from customData.state", async () => { + chat.customData.set(CUSTOMER_GROUP_ID, {cardItemId: 1234, state: "GROK"}) const state = await cards.deriveState(CUSTOMER_GROUP_ID) expect(state).toBe("GROK") }) - test("TEAM-PENDING derived when team member present but no team message", async () => { - chat.members.set(CUSTOMER_GROUP_ID, [makeTeamMember(TEAM_MEMBER_1_ID, "Alice")]) + test("TEAM-PENDING state read from customData.state", async () => { + chat.customData.set(CUSTOMER_GROUP_ID, {cardItemId: 1234, state: "TEAM-PENDING"}) const state = await cards.deriveState(CUSTOMER_GROUP_ID) expect(state).toBe("TEAM-PENDING") }) - test("TEAM derived when team member present AND has sent a message", async () => { - chat.members.set(CUSTOMER_GROUP_ID, [makeTeamMember(TEAM_MEMBER_1_ID, "Alice")]) - addTeamMemberMessageToHistory("Hi!", TEAM_MEMBER_1_ID) + test("TEAM state read from customData.state", async () => { + chat.customData.set(CUSTOMER_GROUP_ID, {cardItemId: 1234, state: "TEAM"}) const state = await cards.deriveState(CUSTOMER_GROUP_ID) expect(state).toBe("TEAM") }) @@ -1682,42 +1683,46 @@ describe("Message Templates", () => { }) }) -describe("isFirstCustomerMessage detection", () => { +describe("State persistence in customData", () => { beforeEach(() => setup()) - test("detects 'The team will reply to your message' as queue message", async () => { - addBotMessage("The team will reply to your message within 24 hours.") - const isFirst = await cards.isFirstCustomerMessage(CUSTOMER_GROUP_ID) - expect(isFirst).toBe(false) + test("first customer text writes state=QUEUE to customData", async () => { + await bot.onNewChatItems(customerMessage("Hello")) + expect(chat.customData.get(CUSTOMER_GROUP_ID)?.state).toBe("QUEUE") }) - test("detects 'You are chatting with Grok' as grok activation", async () => { - addBotMessage("*You are chatting with Grok* - use any language.") - const isFirst = await cards.isFirstCustomerMessage(CUSTOMER_GROUP_ID) - expect(isFirst).toBe(false) + test("/team writes state=TEAM-PENDING immediately (before team accepts)", async () => { + await reachQueue() + addBotMessage("The team will reply to your message") + await bot.onNewChatItems(customerMessage("/team")) + expect(chat.customData.get(CUSTOMER_GROUP_ID)?.state).toBe("TEAM-PENDING") }) - test("detects 'We will reply within' as team activation", async () => { + test("/grok writes state=GROK when activation succeeds", async () => { + await reachQueue() + addBotMessage("The team will reply to your message") + const joinPromise = simulateGrokJoinSuccess() + await bot.onNewChatItems(customerMessage("/grok")) + await joinPromise + await bot.flush() + expect(chat.customData.get(CUSTOMER_GROUP_ID)?.state).toBe("GROK") + }) + + test("/grok from QUEUE reverts state to QUEUE if activation fails", async () => { + await reachQueue() + addBotMessage("The team will reply to your message") + chat.apiAddMemberWillFail() + await bot.onNewChatItems(customerMessage("/grok")) + await bot.flush() + expect(chat.customData.get(CUSTOMER_GROUP_ID)?.state).toBe("QUEUE") + }) + + test("first team text writes state=TEAM via gate", async () => { + await reachTeamPending() addBotMessage("We will reply within 24 hours.") - const isFirst = await cards.isFirstCustomerMessage(CUSTOMER_GROUP_ID) - expect(isFirst).toBe(false) - }) - - test("detects 'team member has already been invited'", async () => { - addBotMessage("A team member has already been invited to this conversation.") - const isFirst = await cards.isFirstCustomerMessage(CUSTOMER_GROUP_ID) - expect(isFirst).toBe(false) - }) - - test("returns true when no bot messages present", async () => { - const isFirst = await cards.isFirstCustomerMessage(CUSTOMER_GROUP_ID) - expect(isFirst).toBe(true) - }) - - test("returns true when only unrelated bot messages present", async () => { - addBotMessage("Some other message") - const isFirst = await cards.isFirstCustomerMessage(CUSTOMER_GROUP_ID) - expect(isFirst).toBe(true) + chat.members.set(CUSTOMER_GROUP_ID, [makeTeamMember(TEAM_MEMBER_1_ID, "Alice")]) + await bot.onNewChatItems(teamMemberMessage("I'll help")) + expect(chat.customData.get(CUSTOMER_GROUP_ID)?.state).toBe("TEAM") }) }) diff --git a/apps/simplex-support-bot/src/bot.ts b/apps/simplex-support-bot/src/bot.ts index 4b1228cbff..709013d4bf 100644 --- a/apps/simplex-support-bot/src/bot.ts +++ b/apps/simplex-support-bot/src/bot.ts @@ -2,7 +2,7 @@ import {api, util} from "simplex-chat" import {T, CEvt} from "@simplex-chat/types" import {Config} from "./config.js" import {GrokMessage, GrokApiClient} from "./grok.js" -import {CardManager} from "./cards.js" +import {CardManager, ConversationState} from "./cards.js" import { queueMessage, grokInvitingMessage, grokActivatedMessage, teamAddedMessage, teamAlreadyInvitedMessage, teamLockedMessage, noTeamMembersMessage, @@ -374,18 +374,22 @@ export class SupportBot { const isTeam = this.config.teamMembers.some(tm => tm.id === sender.memberContactId) if (isTeam && util.ciContentText(chatItem)?.trim()) { - // Check one-way gate: first team text → remove Grok - const {grokMember} = await this.cards.getGroupComposition(groupId) - if (grokMember) { - log(`One-way gate: team message in group ${groupId}, removing Grok`) - try { - await this.withMainProfile(() => - this.chat.apiRemoveMembers(groupId, [grokMember.groupMemberId]) - ) - } catch { - // may have already left + // One-way gate: first team text → transition to TEAM + remove Grok + const data = await this.cards.getRawCustomData(groupId) + if (data?.state !== "TEAM") { + await this.cards.mergeCustomData(groupId, {state: "TEAM"}) + const {grokMember} = await this.cards.getGroupComposition(groupId) + if (grokMember) { + log(`One-way gate: team message in group ${groupId}, removing Grok`) + try { + await this.withMainProfile(() => + this.chat.apiRemoveMembers(groupId, [grokMember.groupMemberId]) + ) + } catch { + // may have already left + } + this.cleanupGrokMaps(groupId) } - this.cleanupGrokMaps(groupId) } } // Schedule card update for any non-customer message (team or Grok) @@ -403,21 +407,25 @@ export class SupportBot { switch (state) { case "WELCOME": if (cmd?.keyword === "grok") { - // WELCOME → GROK (skip queue msg) + // WELCOME → GROK (skip queue msg). Write state optimistically so the + // card renders with GROK icon/label; activateGrok will revert via + // setStateOnFail if activation fails. // Fire-and-forget: activateGrok awaits future events (waitForGrokJoin) // which would deadlock the sequential event loop if awaited here. - // sendQueueOnFail=true: if Grok activation fails, send queue message as fallback + await this.cards.mergeCustomData(groupId, {state: "GROK"}) await this.cards.createCard(groupId, groupInfo) - this.fireAndForget(this.activateGrok(groupId, true)) + this.fireAndForget(this.activateGrok(groupId, {sendQueueOnFail: true, setStateOnFail: "QUEUE"})) return } if (cmd?.keyword === "team") { + // activateTeam writes state=TEAM-PENDING before the add loop await this.activateTeam(groupId) await this.cards.createCard(groupId, groupInfo) return } // First regular message → QUEUE if (text) { + await this.cards.mergeCustomData(groupId, {state: "QUEUE"}) await this.sendToGroup(groupId, queueMessage(this.config.timezone, this.grokEnabled)) await this.cards.createCard(groupId, groupInfo) } @@ -425,7 +433,9 @@ export class SupportBot { case "QUEUE": if (cmd?.keyword === "grok") { - this.fireAndForget(this.activateGrok(groupId)) + // Write state optimistically; activateGrok reverts to QUEUE on failure + await this.cards.mergeCustomData(groupId, {state: "GROK"}) + this.fireAndForget(this.activateGrok(groupId, {setStateOnFail: "QUEUE"})) } else if (cmd?.keyword === "team") { await this.activateTeam(groupId) } @@ -446,14 +456,16 @@ export class SupportBot { case "TEAM-PENDING": if (cmd?.keyword === "grok") { - // Invite Grok if not present + // Invite Grok if not present; state stays TEAM-PENDING const {grokMember} = await this.cards.getGroupComposition(groupId) if (!grokMember) { this.fireAndForget(this.activateGrok(groupId)) } // else: already present, ignore } else if (cmd?.keyword === "team") { - await this.sendToGroup(groupId, teamAlreadyInvitedMessage) + // activateTeam handles "already invited" reply (team still present) + // or silent re-add (team has all left) + await this.activateTeam(groupId) } this.cards.scheduleUpdate(groupId) break @@ -461,6 +473,9 @@ export class SupportBot { case "TEAM": if (cmd?.keyword === "grok") { await this.sendToGroup(groupId, teamLockedMessage) + } else if (cmd?.keyword === "team") { + // Team still present → "already invited"; team all left → silent re-add + await this.activateTeam(groupId) } this.cards.scheduleUpdate(groupId) break @@ -541,12 +556,21 @@ export class SupportBot { // --- Grok activation --- - private async activateGrok(groupId: number, sendQueueOnFail = false): Promise { + private async activateGrok( + groupId: number, + opts: {sendQueueOnFail?: boolean; setStateOnFail?: ConversationState} = {}, + ): Promise { if (!this.grokApi) return const grokApi = this.grokApi + const revertStateOnFail = async () => { + if (opts.setStateOnFail) { + await this.cards.mergeCustomData(groupId, {state: opts.setStateOnFail}) + } + } if (this.config.grokContactId === null) { + await revertStateOnFail() await this.sendToGroup(groupId, grokUnavailableMessage) - if (sendQueueOnFail) await this.sendToGroup(groupId, queueMessage(this.config.timezone, this.grokEnabled)) + if (opts.sendQueueOnFail) await this.sendToGroup(groupId, queueMessage(this.config.timezone, this.grokEnabled)) this.cards.scheduleUpdate(groupId) return } @@ -566,8 +590,9 @@ export class SupportBot { return } logError(`Failed to invite Grok to group ${groupId}`, err) + await revertStateOnFail() await this.sendToGroup(groupId, grokUnavailableMessage) - if (sendQueueOnFail) await this.sendToGroup(groupId, queueMessage(this.config.timezone, this.grokEnabled)) + if (opts.sendQueueOnFail) await this.sendToGroup(groupId, queueMessage(this.config.timezone, this.grokEnabled)) this.cards.scheduleUpdate(groupId) return } @@ -593,8 +618,9 @@ export class SupportBot { ) } catch {} this.cleanupGrokMaps(groupId) + await revertStateOnFail() await this.sendToGroup(groupId, grokUnavailableMessage) - if (sendQueueOnFail) await this.sendToGroup(groupId, queueMessage(this.config.timezone, this.grokEnabled)) + if (opts.sendQueueOnFail) await this.sendToGroup(groupId, queueMessage(this.config.timezone, this.grokEnabled)) this.cards.scheduleUpdate(groupId) return } @@ -654,35 +680,30 @@ export class SupportBot { return } - // Check if team was already activated before (message sent or "added" text in history) - const hasTeamBefore = await this.cards.hasTeamMemberSentMessage(groupId) - if (hasTeamBefore) { + const data = await this.cards.getRawCustomData(groupId) + const alreadyActivated = data?.state === "TEAM-PENDING" || data?.state === "TEAM" + if (alreadyActivated) { const {teamMembers} = await this.cards.getGroupComposition(groupId) if (teamMembers.length > 0) { await this.sendToGroup(groupId, teamAlreadyInvitedMessage) return } - // Team members sent messages but all have left — re-add below - } - - if (!hasTeamBefore) { - // Check by scanning history for the teamAddedMessage AND verify team still present - const chat = await this.cards.getChat(groupId, 50) - const alreadyAdded = chat.chatItems.some((ci: T.ChatItem) => - ci.chatDir.type === "groupSnd" - && util.ciContentText(ci)?.includes("We will reply within") - ) - if (alreadyAdded) { - const {teamMembers} = await this.cards.getGroupComposition(groupId) - if (teamMembers.length > 0) { - await this.sendToGroup(groupId, teamAlreadyInvitedMessage) - return + // Team previously activated but all team members have since left — + // re-add silently (no teamAddedMessage). State stays TEAM-PENDING/TEAM. + for (const tm of this.config.teamMembers) { + try { + await this.addOrFindTeamMember(groupId, tm.id) + } catch (err) { + logError(`Failed to add team member ${tm.id} to group ${groupId}`, err) } - // Team was previously added but all members left — re-add below } + return } - // Add ALL configured team members — promoted to Owner on connectedToGroupMember + // First activation — write state BEFORE add loop so concurrent customer + // events observing mid-flight see TEAM-PENDING rather than stale state. + await this.cards.mergeCustomData(groupId, {state: "TEAM-PENDING"}) + for (const tm of this.config.teamMembers) { try { await this.addOrFindTeamMember(groupId, tm.id) diff --git a/apps/simplex-support-bot/src/cards.ts b/apps/simplex-support-bot/src/cards.ts index 5649eb5805..ff48a19cc3 100644 --- a/apps/simplex-support-bot/src/cards.ts +++ b/apps/simplex-support-bot/src/cards.ts @@ -6,13 +6,18 @@ import {profileMutex, log, logError} from "./util.js" // State derivation types export type ConversationState = "WELCOME" | "QUEUE" | "GROK" | "TEAM-PENDING" | "TEAM" +function isConversationState(x: unknown): x is ConversationState { + return x === "WELCOME" || x === "QUEUE" || x === "GROK" || x === "TEAM-PENDING" || x === "TEAM" +} + export interface GroupComposition { grokMember: T.GroupMember | undefined teamMembers: T.GroupMember[] } interface CardData { - cardItemId: number + state?: ConversationState + cardItemId?: number joinItemId?: number complete?: boolean } @@ -88,11 +93,9 @@ export class CardManager { {msgContent: {type: "text", text: joinCmd}, mentions: {}}, ]) ) - const data: CardData = {cardItemId: items[0].chatItem.meta.itemId} - if (items.length > 1) data.joinItemId = items[1].chatItem.meta.itemId - await this.withMainProfile(() => - this.chat.apiSetGroupCustomData(groupId, data) - ) + const patch: Partial = {cardItemId: items[0].chatItem.meta.itemId} + patch.joinItemId = items.length > 1 ? items[1].chatItem.meta.itemId : undefined + await this.mergeCustomData(groupId, patch) } async flush(): Promise { @@ -154,36 +157,8 @@ export class CardManager { } async deriveState(groupId: number): Promise { - const {grokMember, teamMembers} = await this.getGroupComposition(groupId) - if (teamMembers.length > 0) { - const hasTeamMsg = await this.hasTeamMemberSentMessage(groupId) - return hasTeamMsg ? "TEAM" : "TEAM-PENDING" - } - if (grokMember) return "GROK" - const isFirst = await this.isFirstCustomerMessage(groupId) - return isFirst ? "WELCOME" : "QUEUE" - } - - async isFirstCustomerMessage(groupId: number): Promise { - const chat = await this.getChat(groupId, 20) - return !chat.chatItems.some((ci: T.ChatItem) => { - if (ci.chatDir.type !== "groupSnd") return false - const text = util.ciContentText(ci) - return text?.includes("The team will reply to your message") - || text?.includes("chatting with Grok") - || text?.includes("We will reply within") - || text?.includes("team member has already been invited") - }) - } - - async hasTeamMemberSentMessage(groupId: number): Promise { - const chat = await this.getChat(groupId, 50) - return chat.chatItems.some((ci: T.ChatItem) => { - if (ci.chatDir.type !== "groupRcv") return false - const memberContactId = ci.chatDir.groupMember.memberContactId - return this.config.teamMembers.some(tm => tm.id === memberContactId) - && util.ciContentText(ci)?.trim() - }) + const data = await this.getRawCustomData(groupId) + return data?.state ?? "WELCOME" } async getLastCustomerMessageTime(groupId: number, customerId: string): Promise { @@ -216,21 +191,26 @@ export class CardManager { // --- Custom data --- - async getCustomData(groupId: number): Promise { + async getRawCustomData(groupId: number): Promise | null> { const groups = await this.withMainProfile(() => this.chat.apiListGroups(this.mainUserId)) const group = groups.find(g => g.groupId === groupId) if (!group?.customData) return null const data = group.customData as Record - if (typeof data.cardItemId === "number") { - const result: CardData = {cardItemId: data.cardItemId} - if (typeof data.joinItemId === "number") result.joinItemId = data.joinItemId - return result - } - return null + const result: Partial = {} + if (isConversationState(data.state)) result.state = data.state + if (typeof data.cardItemId === "number") result.cardItemId = data.cardItemId + if (typeof data.joinItemId === "number") result.joinItemId = data.joinItemId + if (data.complete === true) result.complete = true + return result } - async setCustomData(groupId: number, data: CardData): Promise { - await this.withMainProfile(() => this.chat.apiSetGroupCustomData(groupId, data)) + async mergeCustomData(groupId: number, patch: Partial): Promise { + const current = (await this.getRawCustomData(groupId)) ?? {} + const merged: Partial = {...current, ...patch} + for (const key of Object.keys(merged) as (keyof CardData)[]) { + if (merged[key] === undefined) delete merged[key] + } + await this.withMainProfile(() => this.chat.apiSetGroupCustomData(groupId, merged)) } async clearCustomData(groupId: number): Promise { @@ -277,12 +257,10 @@ export class CardManager { {msgContent: {type: "text", text: joinCmd}, mentions: {}}, ]) ) - const data: CardData = {cardItemId: items[0].chatItem.meta.itemId} - if (items.length > 1) data.joinItemId = items[1].chatItem.meta.itemId - if (complete) data.complete = true - await this.withMainProfile(() => - this.chat.apiSetGroupCustomData(groupId, data) - ) + const patch: Partial = {cardItemId: items[0].chatItem.meta.itemId} + patch.joinItemId = items.length > 1 ? items[1].chatItem.meta.itemId : undefined + patch.complete = complete ? true : undefined + await this.mergeCustomData(groupId, patch) } private async composeCard(groupId: number, groupInfo: T.GroupInfo): Promise<{text: string, joinCmd: string, complete: boolean}> { @@ -291,17 +269,10 @@ export class CardManager { const bc = groupInfo.businessChat const customerId = bc?.customerId - // State derivation - const {grokMember, teamMembers} = await this.getGroupComposition(groupId) - let state: ConversationState - if (teamMembers.length > 0) { - const hasTeamMsg = await this.hasTeamMemberSentMessage(groupId) - state = hasTeamMsg ? "TEAM" : "TEAM-PENDING" - } else if (grokMember) { - state = "GROK" - } else { - state = "QUEUE" - } + // State is written into customData at event time by the bot's dispatch handlers. + const state = await this.deriveState(groupId) + // Composition is needed for the agent-names list only. + const {teamMembers} = await this.getGroupComposition(groupId) // Icon const icon = await this.computeIcon(groupId, state, customerId ?? undefined)