From eb477658569c890f46c8a7a04f952ccd6d0954e8 Mon Sep 17 00:00:00 2001 From: Narasimha-sc <166327228+Narasimha-sc@users.noreply.github.com> Date: Sat, 14 Feb 2026 18:32:39 +0200 Subject: [PATCH] apps: support-bot /add command & fixes --- apps/simplex-support-bot/bot.test.ts | 1487 ++++++++++++----- .../docs/simplex-context.md | 24 +- .../20260207-support-bot-implementation.md | 245 ++- .../plans/20260207-support-bot.md | 10 +- .../plans/20260209-moderation-bot.md | 34 - apps/simplex-support-bot/src/bot.ts | 483 ++++-- apps/simplex-support-bot/src/grok.ts | 2 +- apps/simplex-support-bot/src/index.ts | 55 +- apps/simplex-support-bot/src/messages.ts | 2 +- apps/simplex-support-bot/src/startup.ts | 41 + apps/simplex-support-bot/src/state.ts | 7 - 11 files changed, 1613 insertions(+), 777 deletions(-) delete mode 100644 apps/simplex-support-bot/plans/20260209-moderation-bot.md create mode 100644 apps/simplex-support-bot/src/startup.ts diff --git a/apps/simplex-support-bot/bot.test.ts b/apps/simplex-support-bot/bot.test.ts index 512f13c05f..eb7afac64d 100644 --- a/apps/simplex-support-bot/bot.test.ts +++ b/apps/simplex-support-bot/bot.test.ts @@ -1,16 +1,14 @@ // ═══════════════════════════════════════════════════════════════════ -// SimpleX Support Bot — Acceptance Tests +// SimpleX Support Bot — Acceptance Tests (Stateless) // ═══════════════════════════════════════════════════════════════════ // -// Human-readable TypeScript tests for the support bot. -// Uses a conversation DSL: users are variables, actions use await, -// assertions use .received() / .stateIs(). -// -// Grok API is mocked. All scenarios from the product specification -// and implementation plan are covered. +// Tests for the stateless support bot. State is derived from group +// composition (apiListMembers) and chat history (apiGetChat via +// sendChatCmd). All assertions verify observable behavior (messages +// sent, members added/removed) rather than internal state. // ═══════════════════════════════════════════════════════════════════ -import {describe, test, expect, beforeEach, vi} from "vitest" +import {describe, test, expect, beforeEach, afterEach, vi} from "vitest" // ─── Module Mocks (hoisted by vitest) ──────────────────────────── @@ -24,7 +22,19 @@ vi.mock("simplex-chat", () => ({ })) vi.mock("@simplex-chat/types", () => ({ - T: {ChatType: {Group: "group"}, GroupMemberRole: {Member: "member"}}, + T: { + ChatType: {Group: "group"}, + GroupMemberRole: {Member: "member"}, + GroupMemberStatus: { + Connected: "connected", + Complete: "complete", + Announced: "announced", + }, + GroupFeatureEnabled: { + On: "on", + Off: "off", + }, + }, CEvt: {}, })) @@ -34,11 +44,23 @@ vi.mock("./src/util", () => ({ logError: vi.fn(), })) +vi.mock("fs", () => ({ + existsSync: vi.fn(() => false), +})) + +vi.mock("child_process", () => ({ + execSync: vi.fn(() => ""), +})) + // ─── Imports (after mocks) ─────────────────────────────────────── import {SupportBot} from "./src/bot" +import {GrokApiClient} from "./src/grok" +import {resolveDisplayNameConflict} from "./src/startup" import type {GrokMessage} from "./src/state" import {isWeekend} from "./src/util" +import {existsSync} from "fs" +import {execSync} from "child_process" // ─── Mock Grok API ────────────────────────────────────────────── @@ -76,18 +98,42 @@ class MockChatApi { removed: RemovedMembers[] = [] joined: number[] = [] members: Map = new Map() // groupId → members list + chatItems: Map = new Map() // groupId → chat items (simulates DB) + updatedProfiles: {groupId: number; profile: any}[] = [] + updatedChatItems: {chatType: string; chatId: number; chatItemId: number; msgContent: any}[] = [] private addMemberFail = false private addMemberDuplicate = false private nextMemberGId = 50 + private nextItemId = 1000 apiAddMemberWillFail() { this.addMemberFail = true } apiAddMemberWillDuplicate() { this.addMemberDuplicate = true } setNextGroupMemberId(id: number) { this.nextMemberGId = id } setGroupMembers(groupId: number, members: any[]) { this.members.set(groupId, members) } + setChatItems(groupId: number, items: any[]) { this.chatItems.set(groupId, items) } async apiSendTextMessage(chat: [string, number], text: string) { this.sent.push({chat, text}) + // Track bot-sent messages as groupSnd chat items (for isFirstCustomerMessage detection) + const groupId = chat[1] + if (!this.chatItems.has(groupId)) this.chatItems.set(groupId, []) + this.chatItems.get(groupId)!.push({ + chatDir: {type: "groupSnd"}, + _text: text, + }) + const itemId = this.nextItemId++ + return [{chatItem: {meta: {itemId}}}] + } + + async apiUpdateGroupProfile(groupId: number, profile: any) { + this.updatedProfiles.push({groupId, profile}) + return {groupId, groupProfile: profile} + } + + async apiUpdateChatItem(chatType: string, chatId: number, chatItemId: number, msgContent: any, _live: false) { + this.updatedChatItems.push({chatType, chatId, chatItemId, msgContent}) + return {meta: {itemId: chatItemId}} } async apiAddMember(groupId: number, contactId: number, role: string) { @@ -100,11 +146,16 @@ class MockChatApi { } const gid = this.nextMemberGId++ this.added.push({groupId, contactId, role}) - return {groupMemberId: gid, memberId: `member-${gid}`} + return {groupMemberId: gid, memberId: `member-${gid}`, memberContactId: contactId} } async apiRemoveMembers(groupId: number, memberIds: number[]) { this.removed.push({groupId, memberIds}) + // Remove from members list to reflect DB state + const currentMembers = this.members.get(groupId) + if (currentMembers) { + this.members.set(groupId, currentMembers.filter(m => !memberIds.includes(m.groupMemberId))) + } } async apiJoinGroup(groupId: number) { @@ -115,6 +166,24 @@ class MockChatApi { return this.members.get(groupId) || [] } + // sendChatCmd is used by apiGetChat (interim approach) + async sendChatCmd(cmd: string) { + // Parse "/_get chat # count=" + const match = cmd.match(/\/_get chat #(\d+) count=(\d+)/) + if (match) { + const groupId = parseInt(match[1]) + return { + type: "apiChat", + chat: { + chatInfo: {type: "group"}, + chatItems: this.chatItems.get(groupId) || [], + chatStats: {}, + }, + } + } + return {type: "cmdOk"} + } + sentTo(groupId: number): string[] { return this.sent.filter(m => m.chat[1] === groupId).map(m => m.text) } @@ -126,8 +195,9 @@ class MockChatApi { reset() { this.sent = []; this.added = []; this.removed = []; this.joined = [] - this.members.clear() - this.addMemberFail = false; this.addMemberDuplicate = false; this.nextMemberGId = 50 + this.members.clear(); this.chatItems.clear() + this.updatedProfiles = []; this.updatedChatItems = [] + this.addMemberFail = false; this.addMemberDuplicate = false; this.nextMemberGId = 50; this.nextItemId = 1000 } } @@ -148,7 +218,10 @@ function businessGroupInfo(groupId = GROUP_ID, displayName = "Alice") { } as any } +let nextChatItemId = 500 + function customerChatItem(text: string | null, command: string | null = null) { + const itemId = nextChatItemId++ return { chatInfo: {type: "group", groupInfo: businessGroupInfo()}, chatItem: { @@ -156,6 +229,7 @@ function customerChatItem(text: string | null, command: string | null = null) { type: "groupRcv", groupMember: {memberId: CUSTOMER_ID, groupMemberId: 10}, }, + meta: {itemId}, content: {type: "text", text: text ?? ""}, _botCommand: command, _text: text, @@ -164,13 +238,15 @@ function customerChatItem(text: string | null, command: string | null = null) { } function teamMemberChatItem(teamMemberGId: number, text: string) { + const itemId = nextChatItemId++ return { chatInfo: {type: "group", groupInfo: businessGroupInfo()}, chatItem: { chatDir: { type: "groupRcv", - groupMember: {memberId: "team-member-1", groupMemberId: teamMemberGId}, + groupMember: {memberId: "team-member-1", groupMemberId: teamMemberGId, memberContactId: 2, memberProfile: {displayName: "Bob"}}, }, + meta: {itemId}, content: {type: "text", text}, _text: text, }, @@ -183,7 +259,7 @@ function grokMemberChatItem(grokMemberGId: number, text: string) { chatItem: { chatDir: { type: "groupRcv", - groupMember: {memberId: "grok-1", groupMemberId: grokMemberGId}, + groupMember: {memberId: "grok-1", groupMemberId: grokMemberGId, memberContactId: 4}, }, content: {type: "text", text}, _text: text, @@ -200,17 +276,6 @@ function botOwnChatItem(text: string) { // ─── Test DSL ─────────────────────────────────────────────────── -// Thin wrappers that make test bodies read like conversations. -// -// IMPORTANT: activateGrok internally blocks on waitForGrokJoin. -// When testing /grok activation, do NOT await customer.sends("/grok") -// before grokAgent.joins(). Instead use: -// -// const p = customer.sends("/grok") // starts, blocks at waitForGrokJoin -// await grokAgent.joins() // resolves the join -// await p // activateGrok completes -// -// All assertions must come after `await p`. let bot: SupportBot let mainChat: MockChatApi @@ -220,16 +285,23 @@ let lastTeamMemberGId: number let lastGrokMemberGId: number const customer = { - async connects(groupId = GROUP_ID) { - bot.onBusinessRequest({groupInfo: businessGroupInfo(groupId)} as any) - }, - async sends(text: string, groupId = GROUP_ID) { const isGrokCmd = text === "/grok" const isTeamCmd = text === "/team" const command = isGrokCmd ? "grok" : isTeamCmd ? "team" : null const ci = customerChatItem(text, command) ci.chatInfo.groupInfo = businessGroupInfo(groupId) + // Track customer message in mock chat items (simulates DB) + if (!mainChat.chatItems.has(groupId)) mainChat.chatItems.set(groupId, []) + const storedItem: any = { + chatDir: { + type: "groupRcv", + groupMember: {memberId: CUSTOMER_ID, groupMemberId: 10}, + }, + _text: text, + } + if (command) storedItem._botCommand = command + mainChat.chatItems.get(groupId)!.push(storedItem) await bot.onNewChatItems({chatItems: [ci]} as any) }, @@ -261,6 +333,14 @@ const customer = { }, } +// Format helpers for expected forwarded messages +function fmtCustomer(text: string, name = "Alice", groupId = GROUP_ID) { + return `${name}:${groupId}: ${text}` +} +function fmtTeamMember(tmContactId: number, text: string, tmName = "Bob", customerName = "Alice", groupId = GROUP_ID) { + return `${tmName}:${tmContactId} > ${customerName}:${groupId}: ${text}` +} + const teamGroup = { received(expected: string) { const msgs = mainChat.sentTo(TEAM_GRP_ID) @@ -281,13 +361,22 @@ const teamMember = { async sends(text: string, groupId = GROUP_ID) { const ci = teamMemberChatItem(lastTeamMemberGId, text) ci.chatInfo.groupInfo = businessGroupInfo(groupId) + // Track team member message in mock chat items + if (!mainChat.chatItems.has(groupId)) mainChat.chatItems.set(groupId, []) + mainChat.chatItems.get(groupId)!.push({ + chatDir: { + type: "groupRcv", + groupMember: {memberId: "team-member-1", groupMemberId: lastTeamMemberGId, memberContactId: 2}, + }, + _text: text, + }) await bot.onNewChatItems({chatItems: [ci]} as any) }, async leaves(groupId = GROUP_ID) { await bot.onLeftMember({ groupInfo: businessGroupInfo(groupId), - member: {memberId: "team-member-1", groupMemberId: lastTeamMemberGId}, + member: {memberId: "team-member-1", groupMemberId: lastTeamMemberGId, memberContactId: 2}, } as any) }, } @@ -299,9 +388,6 @@ const grokAgent = { }, async joins() { - // Flush microtasks so activateGrok reaches waitForGrokJoin before we resolve it. - // activateGrok does: await apiAddMember → pendingGrokJoins.set → await sendToGroup → await waitForGrokJoin - // Each await creates a microtask. setTimeout(r, 0) fires after all microtasks drain. await new Promise(r => setTimeout(r, 0)) const memberId = `member-${lastGrokMemberGId}` await bot.onGrokGroupInvitation({ @@ -310,7 +396,6 @@ const grokAgent = { membership: {memberId}, }, } as any) - // Waiter resolves on connectedToGroupMember, not on apiJoinGroup bot.onGrokMemberConnected({ groupInfo: {groupId: GROK_LOCAL}, member: {memberProfile: {displayName: "Bot"}}, @@ -318,9 +403,6 @@ const grokAgent = { }, async timesOut() { - // Advance fake timers past the 30s join timeout. - // advanceTimersByTimeAsync interleaves microtask processing, so activateGrok's - // internal awaits (apiAddMember, sendToGroup) complete before the 30s timeout fires. await vi.advanceTimersByTimeAsync(30_001) }, @@ -332,35 +414,23 @@ const grokAgent = { }, async leaves(groupId = GROUP_ID) { + // Remove Grok from members list (simulates DB state after leave) + const currentMembers = mainChat.members.get(groupId) || [] + mainChat.members.set(groupId, currentMembers.filter(m => m.groupMemberId !== lastGrokMemberGId)) await bot.onLeftMember({ groupInfo: businessGroupInfo(groupId), - member: {memberId: "grok-1", groupMemberId: lastGrokMemberGId}, + member: {memberId: "grok-1", groupMemberId: lastGrokMemberGId, memberContactId: 4}, } as any) }, } -function stateIs(groupId: number, expectedType: string) { - const state = (bot as any).conversations.get(groupId) - expect(state).toBeDefined() - expect(state.type).toBe(expectedType) -} - -function hasNoState(groupId: number) { - expect((bot as any).conversations.has(groupId)).toBe(false) -} - // ─── Constants ────────────────────────────────────────────────── const TEAM_QUEUE_24H = - `Thank you for your message, it is forwarded to the team.\n` + - `It may take a team member up to 24 hours to reply.\n\n` + - `Click /grok if your question is about SimpleX apps or network, is not sensitive, ` + - `and you want Grok LLM to answer it right away. *Your previous message and all ` + - `subsequent messages will be forwarded to Grok* until you click /team. You can ask ` + - `Grok questions in any language and it will not see your profile name.\n\n` + - `We appreciate if you try Grok: you can learn a lot about SimpleX Chat from it. ` + - `It is objective, answers the way our team would, and it saves our team time.` + `Your message is forwarded to the team. A reply may take up to 24 hours.\n\n` + + `If your question is about SimpleX Chat, click /grok for an instant AI answer ` + + `(non-sensitive questions only). Click /team to switch back any time.` const TEAM_QUEUE_48H = TEAM_QUEUE_24H.replace("24 hours", "48 hours") @@ -402,44 +472,57 @@ beforeEach(() => { mainChat = new MockChatApi() grokChat = new MockChatApi() grokApi = new MockGrokApi() - // Track the groupMemberIds that apiAddMember returns mainChat.setNextGroupMemberId(50) lastTeamMemberGId = 50 lastGrokMemberGId = 50 + nextChatItemId = 500 + // Simulate the welcome message that the platform auto-sends on business connect + mainChat.setChatItems(GROUP_ID, [{chatDir: {type: "groupSnd"}, _text: "Welcome!"}]) bot = new SupportBot(mainChat as any, grokChat as any, grokApi as any, config as any) - // Reset isWeekend mock to default (weekday) vi.mocked(isWeekend).mockReturnValue(false) }) -// ─── State Helpers ────────────────────────────────────────────── +// ─── State Setup Helpers ──────────────────────────────────────── +// Reach teamQueue: customer sends first message → bot sends queue reply (groupSnd in DB) async function reachTeamQueue(...messages: string[]) { - await customer.connects() await customer.sends(messages[0] || "Hello") for (const msg of messages.slice(1)) { await customer.sends(msg) } } +// Reach grokMode: teamQueue → /grok → Grok joins → API responds async function reachGrokMode(grokResponse = "Grok answer") { mainChat.setNextGroupMemberId(60) lastGrokMemberGId = 60 await reachTeamQueue("Hello") grokApi.willRespond(grokResponse) - // Non-awaiting pattern: activateGrok blocks on waitForGrokJoin const p = customer.sends("/grok") + // After apiAddMember, register Grok as active member in the DB mock + mainChat.setGroupMembers(GROUP_ID, [ + {groupMemberId: 60, memberContactId: 4, memberStatus: "connected"}, + ]) await grokAgent.joins() await p } +// Reach teamPending: teamQueue → /team → team member added async function reachTeamPending() { mainChat.setNextGroupMemberId(50) lastTeamMemberGId = 50 await reachTeamQueue("Hello") + // Before /team, ensure no special members + mainChat.setGroupMembers(GROUP_ID, []) await customer.sends("/team") + // After /team, team member is now in the group + mainChat.setGroupMembers(GROUP_ID, [ + {groupMemberId: 50, memberContactId: 2, memberStatus: "connected"}, + ]) } +// Reach teamLocked: teamPending → team member sends message async function reachTeamLocked() { await reachTeamPending() await teamMember.sends("I'll help you") @@ -455,28 +538,18 @@ async function reachTeamLocked() { describe("Connection & Welcome", () => { - test("new customer connects → welcome state", async () => { - await customer.connects() - - stateIs(GROUP_ID, "welcome") - }) - - test("first message → forwarded to team, queue reply, teamQueue state", async () => { - await customer.connects() - + test("first message → forwarded to team, queue reply sent", async () => { + // No prior bot messages → isFirstCustomerMessage returns true → welcome flow await customer.sends("How do I create a group?") - teamGroup.received("[Alice #100]\nHow do I create a group?") + teamGroup.received(fmtCustomer("How do I create a group?")) customer.received(TEAM_QUEUE_24H) - stateIs(GROUP_ID, "teamQueue") }) - test("non-text message in welcome → ignored", async () => { - await customer.connects() - + test("non-text message when no bot messages → ignored", async () => { await customer.sendsNonText() - stateIs(GROUP_ID, "welcome") + expect(mainChat.sent.length).toBe(0) }) }) @@ -487,29 +560,13 @@ describe("Team Queue", () => { test("additional messages forwarded to team, no second queue reply", async () => { await reachTeamQueue("First question") - mainChat.sent = [] // clear previous messages + mainChat.sent = [] await customer.sends("More details about my issue") - teamGroup.received("[Alice #100]\nMore details about my issue") - // No queue message sent again — only on first message + teamGroup.received(fmtCustomer("More details about my issue")) + // No queue message sent again — bot already sent a message (groupSnd in DB) expect(mainChat.sentTo(GROUP_ID).length).toBe(0) - stateIs(GROUP_ID, "teamQueue") - }) - - test("multiple messages accumulate in userMessages", async () => { - await customer.connects() - - await customer.sends("Question 1") - await customer.sends("Question 2") - await customer.sends("Question 3") - - teamGroup.received("[Alice #100]\nQuestion 1") - teamGroup.received("[Alice #100]\nQuestion 2") - teamGroup.received("[Alice #100]\nQuestion 3") - - const state = (bot as any).conversations.get(GROUP_ID) - expect(state.userMessages).toEqual(["Question 1", "Question 2", "Question 3"]) }) test("non-text message in teamQueue → ignored", async () => { @@ -519,7 +576,6 @@ describe("Team Queue", () => { await customer.sendsNonText() expect(mainChat.sent.length).toBe(0) - stateIs(GROUP_ID, "teamQueue") }) test("unrecognized /command treated as normal text message", async () => { @@ -528,8 +584,7 @@ describe("Team Queue", () => { await customer.sends("/unknown") - teamGroup.received("[Alice #100]\n/unknown") - stateIs(GROUP_ID, "teamQueue") + teamGroup.received(fmtCustomer("/unknown")) }) }) @@ -544,8 +599,11 @@ describe("Grok Activation", () => { await reachTeamQueue("How do I create a group?") grokApi.willRespond("To create a group, go to Settings > New Group.") - // Non-awaiting pattern: activateGrok blocks on waitForGrokJoin const p = customer.sends("/grok") + // After invite, set Grok as active member in mock + mainChat.setGroupMembers(GROUP_ID, [ + {groupMemberId: 60, memberContactId: 4, memberStatus: "connected"}, + ]) await grokAgent.joins() await p @@ -558,8 +616,6 @@ describe("Grok Activation", () => { // Grok response sent via Grok identity customer.receivedFromGrok("To create a group, go to Settings > New Group.") - - stateIs(GROUP_ID, "grokMode") }) test("/grok with multiple accumulated messages → joined with newline", async () => { @@ -569,6 +625,9 @@ describe("Grok Activation", () => { grokApi.willRespond("Here's how to do both...") const p = customer.sends("/grok") + mainChat.setGroupMembers(GROUP_ID, [ + {groupMemberId: 60, memberContactId: 4, memberStatus: "connected"}, + ]) await grokAgent.joins() await p @@ -576,7 +635,6 @@ describe("Grok Activation", () => { "Question about groups\nAlso, how do I add members?" ) customer.receivedFromGrok("Here's how to do both...") - stateIs(GROUP_ID, "grokMode") }) }) @@ -587,43 +645,27 @@ describe("Grok Mode Conversation", () => { test("user messages forwarded to both Grok API and team group", async () => { await reachGrokMode("Initial answer") + // Add the Grok response to chat items so history builds correctly + mainChat.chatItems.get(GROUP_ID)!.push({ + chatDir: { + type: "groupRcv", + groupMember: {memberId: "grok-1", groupMemberId: 60, memberContactId: 4}, + }, + _text: "Initial answer", + }) mainChat.sent = [] grokApi.willRespond("Follow-up answer from Grok") await customer.sends("What about encryption?") - teamGroup.received("[Alice #100]\nWhat about encryption?") + teamGroup.received(fmtCustomer("What about encryption?")) - expect(grokApi.lastCall().history).toEqual([ - {role: "user", content: "Hello"}, - {role: "assistant", content: "Initial answer"}, - ]) - expect(grokApi.lastCall().message).toBe("What about encryption?") + // History should include the initial exchange (from chat items in DB) + const lastCall = grokApi.lastCall() + expect(lastCall.history.length).toBeGreaterThanOrEqual(1) + expect(lastCall.message).toBe("What about encryption?") customer.receivedFromGrok("Follow-up answer from Grok") - stateIs(GROUP_ID, "grokMode") - }) - - test("conversation history grows with each exchange", async () => { - await reachGrokMode("Answer 1") - - grokApi.willRespond("Answer 2") - await customer.sends("Follow-up 1") - - expect(grokApi.lastCall().history).toEqual([ - {role: "user", content: "Hello"}, - {role: "assistant", content: "Answer 1"}, - ]) - - grokApi.willRespond("Answer 3") - await customer.sends("Follow-up 2") - - expect(grokApi.lastCall().history).toEqual([ - {role: "user", content: "Hello"}, - {role: "assistant", content: "Answer 1"}, - {role: "user", content: "Follow-up 1"}, - {role: "assistant", content: "Answer 2"}, - ]) }) test("/grok in grokMode → silently ignored", async () => { @@ -635,7 +677,6 @@ describe("Grok Mode Conversation", () => { expect(mainChat.sent.length).toBe(0) expect(grokApi.callCount()).toBe(0) - stateIs(GROUP_ID, "grokMode") }) test("non-text message in grokMode → ignored", async () => { @@ -647,7 +688,6 @@ describe("Grok Mode Conversation", () => { expect(mainChat.sent.length).toBe(0) expect(grokApi.callCount()).toBe(0) - stateIs(GROUP_ID, "grokMode") }) }) @@ -656,7 +696,7 @@ describe("Grok Mode Conversation", () => { describe("Team Activation", () => { - test("/team from teamQueue → team member invited, teamPending", async () => { + test("/team from teamQueue → team member invited, team added message", async () => { mainChat.setNextGroupMemberId(50) lastTeamMemberGId = 50 await reachTeamQueue("Hello") @@ -666,7 +706,6 @@ describe("Team Activation", () => { teamMember.wasInvited() customer.received(TEAM_ADDED_24H) - stateIs(GROUP_ID, "teamPending") }) test("/team from grokMode → Grok removed, team member added", async () => { @@ -680,7 +719,6 @@ describe("Team Activation", () => { grokAgent.wasRemoved() teamMember.wasInvited() customer.received(TEAM_ADDED_24H) - stateIs(GROUP_ID, "teamPending") }) }) @@ -696,15 +734,6 @@ describe("One-Way Gate", () => { await customer.sends("/grok") customer.received(TEAM_LOCKED_MSG) - stateIs(GROUP_ID, "teamPending") - }) - - test("team member sends message → teamLocked", async () => { - await reachTeamPending() - - await teamMember.sends("I'll help you with that") - - stateIs(GROUP_ID, "teamLocked") }) test("/grok in teamLocked → 'team mode' reply", async () => { @@ -714,7 +743,6 @@ describe("One-Way Gate", () => { await customer.sends("/grok") customer.received(TEAM_LOCKED_MSG) - stateIs(GROUP_ID, "teamLocked") }) test("/team in teamPending → silently ignored", async () => { @@ -723,8 +751,7 @@ describe("One-Way Gate", () => { await customer.sends("/team") - expect(mainChat.sent.length).toBe(0) - stateIs(GROUP_ID, "teamPending") + expect(mainChat.sentTo(GROUP_ID).length).toBe(0) }) test("/team in teamLocked → silently ignored", async () => { @@ -733,8 +760,7 @@ describe("One-Way Gate", () => { await customer.sends("/team") - expect(mainChat.sent.length).toBe(0) - stateIs(GROUP_ID, "teamLocked") + expect(mainChat.sentTo(GROUP_ID).length).toBe(0) }) test("customer text in teamPending → no forwarding, no reply", async () => { @@ -743,8 +769,7 @@ describe("One-Way Gate", () => { await customer.sends("Here's more info about my issue") - expect(mainChat.sent.length).toBe(0) - stateIs(GROUP_ID, "teamPending") + expect(mainChat.sentTo(GROUP_ID).length).toBe(0) }) test("customer text in teamLocked → no forwarding, no reply", async () => { @@ -753,8 +778,7 @@ describe("One-Way Gate", () => { await customer.sends("Thank you!") - expect(mainChat.sent.length).toBe(0) - stateIs(GROUP_ID, "teamLocked") + expect(mainChat.sentTo(GROUP_ID).length).toBe(0) }) }) @@ -763,51 +787,62 @@ describe("One-Way Gate", () => { describe("Gate Reversal vs Irreversibility", () => { - test("team member leaves in teamPending → revert to teamQueue", async () => { + test("team member leaves in teamPending → reverting to queue (no replacement)", async () => { await reachTeamPending() + // Remove team member from mock members (simulates leave) + mainChat.setGroupMembers(GROUP_ID, []) + mainChat.added = [] await teamMember.leaves() - stateIs(GROUP_ID, "teamQueue") + // No replacement added — teamPending revert means no action + expect(mainChat.added.length).toBe(0) }) test("after teamPending revert, /grok works again", async () => { await reachTeamPending() + // Remove team member from mock members + mainChat.setGroupMembers(GROUP_ID, []) await teamMember.leaves() - // Now back in teamQueue + + // Now back in teamQueue equivalent — /grok should work mainChat.setNextGroupMemberId(61) lastGrokMemberGId = 61 grokApi.willRespond("Grok is back") const p = customer.sends("/grok") + mainChat.setGroupMembers(GROUP_ID, [ + {groupMemberId: 61, memberContactId: 4, memberStatus: "connected"}, + ]) await grokAgent.joins() await p customer.receivedFromGrok("Grok is back") - stateIs(GROUP_ID, "grokMode") }) - test("team member leaves in teamLocked → replacement added, stays locked", async () => { + test("team member leaves in teamLocked → replacement added", async () => { await reachTeamLocked() mainChat.added = [] await teamMember.leaves() - // Replacement team member invited, state stays teamLocked + // Replacement team member invited expect(mainChat.added.length).toBe(1) expect(mainChat.added[0].contactId).toBe(2) - stateIs(GROUP_ID, "teamLocked") }) test("/grok still rejected after replacement in teamLocked", async () => { await reachTeamLocked() await teamMember.leaves() + // Replacement added, set in members + mainChat.setGroupMembers(GROUP_ID, [ + {groupMemberId: 51, memberContactId: 2, memberStatus: "connected"}, + ]) mainChat.sent = [] await customer.sends("/grok") customer.received(TEAM_LOCKED_MSG) - stateIs(GROUP_ID, "teamLocked") }) }) @@ -816,56 +851,47 @@ describe("Gate Reversal vs Irreversibility", () => { describe("Member Leave & Cleanup", () => { - test("customer leaves → state deleted", async () => { + test("customer leaves → grok maps cleaned up", async () => { await reachTeamQueue("Hello") await customer.leaves() - hasNoState(GROUP_ID) + // No crash, grok maps cleaned + expect((bot as any).grokGroupMap.has(GROUP_ID)).toBe(false) }) - test("customer leaves in grokMode → state and grok maps cleaned", async () => { + test("customer leaves in grokMode → grok maps cleaned", async () => { await reachGrokMode() await customer.leaves() - hasNoState(GROUP_ID) - // grokGroupMap also cleaned (internal) expect((bot as any).grokGroupMap.has(GROUP_ID)).toBe(false) }) - test("Grok leaves during grokMode → revert to teamQueue", async () => { + test("Grok leaves during grokMode → next customer message goes to teamQueue", async () => { await reachGrokMode() await grokAgent.leaves() + mainChat.sent = [] + grokApi.reset() - stateIs(GROUP_ID, "teamQueue") + // Next customer message: no grok, no team → handleNoSpecialMembers → teamQueue + // Bot has already sent messages (groupSnd), so not welcome → forward to team + await customer.sends("Another question") + + teamGroup.received(fmtCustomer("Another question")) expect((bot as any).grokGroupMap.has(GROUP_ID)).toBe(false) }) - test("bot removed from group → state deleted", async () => { - await reachTeamQueue("Hello") - - bot.onDeletedMemberUser({groupInfo: businessGroupInfo()} as any) - - hasNoState(GROUP_ID) + test("bot removed from group → no crash", async () => { + // onDeletedMemberUser no longer exists — just verify no crash + // The bot simply won't receive events for that group anymore }) - test("group deleted → state deleted", async () => { - await reachGrokMode() - - bot.onGroupDeleted({groupInfo: businessGroupInfo()} as any) - - hasNoState(GROUP_ID) - expect((bot as any).grokGroupMap.has(GROUP_ID)).toBe(false) - }) - - test("customer leaves in welcome → state deleted", async () => { - await customer.connects() - + test("customer leaves in welcome → no crash", async () => { + // No prior messages sent — just leave await customer.leaves() - - hasNoState(GROUP_ID) + // No crash expected }) }) @@ -874,7 +900,7 @@ describe("Member Leave & Cleanup", () => { describe("Error Handling", () => { - test("Grok invitation (apiAddMember) fails → error msg, stay in teamQueue", async () => { + test("Grok invitation (apiAddMember) fails → error msg, stays in queue", async () => { await reachTeamQueue("Hello") mainChat.apiAddMemberWillFail() mainChat.sent = [] @@ -883,10 +909,9 @@ describe("Error Handling", () => { customer.received(GROK_UNAVAILABLE) expect(grokApi.callCount()).toBe(0) - stateIs(GROUP_ID, "teamQueue") }) - test("Grok join timeout → error msg, stay in teamQueue", async () => { + test("Grok join timeout → error msg", async () => { vi.useFakeTimers() mainChat.setNextGroupMemberId(60) lastGrokMemberGId = 60 @@ -894,14 +919,11 @@ describe("Error Handling", () => { mainChat.sent = [] const sendPromise = customer.sends("/grok") - // advanceTimersByTimeAsync flushes microtasks (so activateGrok reaches waitForGrokJoin) - // then fires the 30s timeout await grokAgent.timesOut() await sendPromise customer.received(GROK_UNAVAILABLE) expect(grokApi.callCount()).toBe(0) - stateIs(GROUP_ID, "teamQueue") vi.useRealTimers() }) @@ -913,15 +935,17 @@ describe("Error Handling", () => { mainChat.sent = [] const p = customer.sends("/grok") + mainChat.setGroupMembers(GROUP_ID, [ + {groupMemberId: 60, memberContactId: 4, memberStatus: "connected"}, + ]) await grokAgent.joins() await p grokAgent.wasRemoved() customer.received(GROK_UNAVAILABLE) - stateIs(GROUP_ID, "teamQueue") }) - test("Grok API error during conversation → remove Grok, revert to teamQueue", async () => { + test("Grok API error during conversation → remove Grok, error msg", async () => { await reachGrokMode() grokApi.willFail() mainChat.sent = [] @@ -930,14 +954,14 @@ describe("Error Handling", () => { grokAgent.wasRemoved() customer.received(GROK_UNAVAILABLE) - stateIs(GROUP_ID, "teamQueue") }) test("after Grok API failure revert, /team still works", async () => { await reachGrokMode() grokApi.willFail() await customer.sends("Failing question") - // Now back in teamQueue + // After Grok removal, members list should be empty + mainChat.setGroupMembers(GROUP_ID, []) mainChat.setNextGroupMemberId(51) lastTeamMemberGId = 51 mainChat.sent = [] @@ -946,10 +970,9 @@ describe("Error Handling", () => { teamMember.wasInvited() customer.received(TEAM_ADDED_24H) - stateIs(GROUP_ID, "teamPending") }) - test("team member add fails from teamQueue → error, stay in teamQueue", async () => { + test("team member add fails from teamQueue → error, stays in queue", async () => { await reachTeamQueue("Hello") mainChat.apiAddMemberWillFail() mainChat.sent = [] @@ -957,10 +980,9 @@ describe("Error Handling", () => { await customer.sends("/team") customer.received(TEAM_ADD_ERROR) - stateIs(GROUP_ID, "teamQueue") }) - test("team member add fails after Grok removal → revert to teamQueue", async () => { + test("team member add fails after Grok removal → error msg", async () => { await reachGrokMode() mainChat.apiAddMemberWillFail() mainChat.sent = [] @@ -969,8 +991,6 @@ describe("Error Handling", () => { grokAgent.wasRemoved() customer.received(TEAM_ADD_ERROR) - // grokMode state is stale (Grok removed) → explicitly reverted to teamQueue - stateIs(GROUP_ID, "teamQueue") }) test("Grok failure then retry succeeds", async () => { @@ -981,20 +1001,26 @@ describe("Error Handling", () => { // First attempt — API fails grokApi.willFail() const p1 = customer.sends("/grok") + mainChat.setGroupMembers(GROUP_ID, [ + {groupMemberId: 60, memberContactId: 4, memberStatus: "connected"}, + ]) await grokAgent.joins() await p1 - stateIs(GROUP_ID, "teamQueue") + // After failure, Grok removed from members + mainChat.setGroupMembers(GROUP_ID, []) // Second attempt — succeeds mainChat.setNextGroupMemberId(61) lastGrokMemberGId = 61 grokApi.willRespond("Hello! How can I help?") const p2 = customer.sends("/grok") + mainChat.setGroupMembers(GROUP_ID, [ + {groupMemberId: 61, memberContactId: 4, memberStatus: "connected"}, + ]) await grokAgent.joins() await p2 customer.receivedFromGrok("Hello! How can I help?") - stateIs(GROUP_ID, "grokMode") }) }) @@ -1011,21 +1037,28 @@ describe("Race Conditions", () => { // Start /grok — hangs on waitForGrokJoin grokApi.willRespond("answer") const grokPromise = customer.sends("/grok") + // Flush microtasks so activateGrok reaches waitForGrokJoin before we change nextMemberGId + await new Promise(r => setTimeout(r, 0)) - // While waiting, /team is processed concurrently + // While waiting, /team is processed concurrently (no special members yet) mainChat.setNextGroupMemberId(70) lastTeamMemberGId = 70 + mainChat.setGroupMembers(GROUP_ID, []) await customer.sends("/team") - stateIs(GROUP_ID, "teamPending") + customer.received(TEAM_ADDED_24H) - // Grok join completes — but state changed + // After /team, team member is now in the group + mainChat.setGroupMembers(GROUP_ID, [ + {groupMemberId: 70, memberContactId: 2, memberStatus: "connected"}, + ]) + + // Grok join completes — but team member is now present await grokAgent.joins() await grokPromise - // Bot detects state mismatch, removes Grok + // Bot detects team member, removes Grok grokAgent.wasRemoved() expect(grokApi.callCount()).toBe(0) - stateIs(GROUP_ID, "teamPending") }) test("state change during Grok API call → abort", async () => { @@ -1038,23 +1071,28 @@ describe("Race Conditions", () => { grokApi.chat = async () => new Promise(r => { resolveGrokCall = r }) const grokPromise = customer.sends("/grok") + mainChat.setGroupMembers(GROUP_ID, [ + {groupMemberId: 60, memberContactId: 4, memberStatus: "connected"}, + ]) await grokAgent.joins() // Flush microtasks so activateGrok proceeds past waitForGrokJoin into grokApi.chat await new Promise(r => setTimeout(r, 0)) - // activateGrok now blocked on grokApi.chat - // While API call is pending, /team changes state + // While API call is pending, /team changes composition mainChat.setNextGroupMemberId(70) lastTeamMemberGId = 70 + // Update members to include team member (Grok still there from DB perspective) + mainChat.setGroupMembers(GROUP_ID, [ + {groupMemberId: 60, memberContactId: 4, memberStatus: "connected"}, + {groupMemberId: 70, memberContactId: 2, memberStatus: "connected"}, + ]) await customer.sends("/team") - stateIs(GROUP_ID, "teamPending") - // API call completes — but state changed + // API call completes — but team member appeared resolveGrokCall("Grok answer") await grokPromise grokAgent.wasRemoved() - stateIs(GROUP_ID, "teamPending") }) }) @@ -1066,7 +1104,6 @@ describe("Weekend Hours", () => { test("weekend: 48 hours in queue message", async () => { vi.mocked(isWeekend).mockReturnValue(true) - await customer.connects() await customer.sends("Hello") customer.received(TEAM_QUEUE_48H) @@ -1087,12 +1124,10 @@ describe("Weekend Hours", () => { describe("Team Forwarding", () => { - test("format: [displayName #groupId]\\ntext", async () => { - await customer.connects() - + test("format: CustomerName:groupId: text", async () => { await customer.sends("My app crashes on startup") - teamGroup.received("[Alice #100]\nMy app crashes on startup") + teamGroup.received(fmtCustomer("My app crashes on startup")) }) test("grokMode messages also forwarded to team", async () => { @@ -1102,22 +1137,21 @@ describe("Team Forwarding", () => { grokApi.willRespond("Try clearing app data") await customer.sends("App keeps crashing") - teamGroup.received("[Alice #100]\nApp keeps crashing") + teamGroup.received(fmtCustomer("App keeps crashing")) customer.receivedFromGrok("Try clearing app data") }) test("fallback displayName when empty → group-{id}", async () => { const emptyNameGroup = {...businessGroupInfo(101), groupProfile: {displayName: ""}} - bot.onBusinessRequest({groupInfo: emptyNameGroup} as any) mainChat.sent = [] - // Send message in group 101 with empty display name const ci = customerChatItem("Hello", null) ci.chatInfo.groupInfo = emptyNameGroup ci.chatItem.chatDir.groupMember.memberId = emptyNameGroup.businessChat.customerId + // No prior bot messages for group 101 → welcome flow await bot.onNewChatItems({chatItems: [ci]} as any) - teamGroup.received("[group-101 #101]\nHello") + teamGroup.received(fmtCustomer("Hello", "group-101", 101)) }) }) @@ -1133,7 +1167,6 @@ describe("Edge Cases", () => { await bot.onNewChatItems({chatItems: [botOwnChatItem("queue reply")]} as any) expect(mainChat.sent.length).toBe(0) - stateIs(GROUP_ID, "teamQueue") }) test("non-business-chat group → ignored", async () => { @@ -1149,23 +1182,31 @@ describe("Edge Cases", () => { await bot.onNewChatItems({chatItems: [ci]} as any) - hasNoState(999) + expect(mainChat.sent.length).toBe(0) }) - test("message in business chat with no state → re-initialized to teamQueue", async () => { - // Group 888 never had onBusinessRequest called (e.g., bot restarted) - const ci = customerChatItem("Hello", null) - ci.chatInfo.groupInfo = businessGroupInfo(888) + test("message in business chat after restart → correctly handled", async () => { + // Simulate restart: no prior state. Bot has already sent messages (we simulate groupSnd in DB) + mainChat.setChatItems(888, [ + {chatDir: {type: "groupSnd"}, _text: "Welcome!"}, + {chatDir: {type: "groupSnd"}, _text: "Your message is forwarded to the team."}, + ]) mainChat.sent = [] + const ci = customerChatItem("I had a question earlier", null) + ci.chatInfo.groupInfo = businessGroupInfo(888) + // Track customer message in mock + mainChat.chatItems.get(888)!.push({ + chatDir: {type: "groupRcv", groupMember: {memberId: CUSTOMER_ID, groupMemberId: 10}}, + _text: "I had a question earlier", + }) await bot.onNewChatItems({chatItems: [ci]} as any) - // Re-initialized as teamQueue, message forwarded to team (no queue reply — already past welcome) - stateIs(888, "teamQueue") - teamGroup.received("[Alice #888]\nHello") + // Handled as teamQueue (not welcome, since bot already has groupSnd), forwarded to team + teamGroup.received(fmtCustomer("I had a question earlier", "Alice", 888)) }) - test("Grok's own messages in grokMode → ignored by bot", async () => { + test("Grok's own messages in grokMode → ignored by bot (non-customer)", async () => { await reachGrokMode() mainChat.sent = [] grokApi.reset() @@ -1177,25 +1218,6 @@ describe("Edge Cases", () => { expect(mainChat.sent.length).toBe(0) }) - test("bot passes full history to GrokApiClient (client truncates internally)", async () => { - await reachGrokMode("Answer 0") - - // Build up 12 more exchanges → 26 history entries total - for (let i = 1; i <= 12; i++) { - grokApi.willRespond(`Answer ${i}`) - await customer.sends(`Question ${i}`) - } - - // 13th exchange — history passed to MockGrokApi has 26 entries - // The real GrokApiClient.chat() does history.slice(-20) before calling the API - grokApi.willRespond("Answer 13") - await customer.sends("Question 13") - - const lastCall = grokApi.lastCall() - expect(lastCall.history.length).toBe(26) - expect(lastCall.message).toBe("Question 13") - }) - test("unexpected Grok group invitation → ignored", async () => { await bot.onGrokGroupInvitation({ groupInfo: { @@ -1204,7 +1226,6 @@ describe("Edge Cases", () => { }, } as any) - // No crash, no state change, no maps updated expect(grokChat.joined.length).toBe(0) }) @@ -1212,62 +1233,64 @@ describe("Edge Cases", () => { const GROUP_A = 100 const GROUP_B = 300 - // Customer A connects - bot.onBusinessRequest({groupInfo: businessGroupInfo(GROUP_A, "Alice")} as any) - stateIs(GROUP_A, "welcome") - - // Customer B connects - bot.onBusinessRequest({groupInfo: businessGroupInfo(GROUP_B, "Charlie")} as any) - stateIs(GROUP_B, "welcome") - - // Customer A sends message → teamQueue + // Customer A sends message → welcome → teamQueue const ciA = customerChatItem("Question A", null) ciA.chatInfo.groupInfo = businessGroupInfo(GROUP_A, "Alice") + mainChat.chatItems.set(GROUP_A, [{ + chatDir: {type: "groupRcv", groupMember: {memberId: CUSTOMER_ID, groupMemberId: 10}}, + _text: "Question A", + }]) await bot.onNewChatItems({chatItems: [ciA]} as any) - stateIs(GROUP_A, "teamQueue") - // Customer B still in welcome - stateIs(GROUP_B, "welcome") + // Customer A got queue reply + customer.received(TEAM_QUEUE_24H, GROUP_A) + + // Customer B's first message in group 300 + const ciB = customerChatItem("Question B", null) + ciB.chatInfo.groupInfo = businessGroupInfo(GROUP_B, "Charlie") + ciB.chatItem.chatDir.groupMember.memberId = CUSTOMER_ID + mainChat.chatItems.set(GROUP_B, [{ + chatDir: {type: "groupRcv", groupMember: {memberId: CUSTOMER_ID, groupMemberId: 10}}, + _text: "Question B", + }]) + await bot.onNewChatItems({chatItems: [ciB]} as any) + + // Customer B also got queue reply + customer.received(TEAM_QUEUE_24H, GROUP_B) }) test("Grok leaves during grokMode, customer retries → works", async () => { await reachGrokMode() await grokAgent.leaves() - stateIs(GROUP_ID, "teamQueue") // Retry /grok mainChat.setNextGroupMemberId(62) lastGrokMemberGId = 62 grokApi.willRespond("I'm back!") const p = customer.sends("/grok") + mainChat.setGroupMembers(GROUP_ID, [ + {groupMemberId: 62, memberContactId: 4, memberStatus: "connected"}, + ]) await grokAgent.joins() await p customer.receivedFromGrok("I'm back!") - stateIs(GROUP_ID, "grokMode") }) - test("/grok in welcome state → treated as regular text", async () => { - await customer.connects() - + test("/grok as first message → treated as text (welcome state)", async () => { await customer.sends("/grok") - // welcome state has no command handling — /grok is treated as text - teamGroup.received("[Alice #100]\n/grok") + // In welcome state, /grok is treated as a regular text message + teamGroup.received(fmtCustomer("/grok")) customer.received(TEAM_QUEUE_24H) - stateIs(GROUP_ID, "teamQueue") }) - test("/team in welcome state → treated as regular text", async () => { - await customer.connects() - + test("/team as first message → treated as text (welcome state)", async () => { await customer.sends("/team") - // welcome state has no command handling — /team is treated as text - teamGroup.received("[Alice #100]\n/team") + teamGroup.received(fmtCustomer("/team")) customer.received(TEAM_QUEUE_24H) - stateIs(GROUP_ID, "teamQueue") }) test("non-text message in teamPending → ignored", async () => { @@ -1277,7 +1300,6 @@ describe("Edge Cases", () => { await customer.sendsNonText() expect(mainChat.sent.length).toBe(0) - stateIs(GROUP_ID, "teamPending") }) test("non-text message in teamLocked → ignored", async () => { @@ -1287,16 +1309,6 @@ describe("Edge Cases", () => { await customer.sendsNonText() expect(mainChat.sent.length).toBe(0) - stateIs(GROUP_ID, "teamLocked") - }) - - test("team member message in teamLocked → no state change", async () => { - await reachTeamLocked() - - // onTeamMemberMessage checks state.type !== "teamPending" → returns - await teamMember.sends("Just checking in") - - stateIs(GROUP_ID, "teamLocked") }) test("unknown member message → silently ignored", async () => { @@ -1304,7 +1316,6 @@ describe("Edge Cases", () => { mainChat.sent = [] grokApi.reset() - // A member who is neither customer, nor identified team member, nor Grok const ci = { chatInfo: {type: "group", groupInfo: businessGroupInfo()}, chatItem: { @@ -1320,7 +1331,6 @@ describe("Edge Cases", () => { expect(mainChat.sent.length).toBe(0) expect(grokApi.callCount()).toBe(0) - stateIs(GROUP_ID, "teamQueue") }) test("Grok apiJoinGroup failure → maps not set", async () => { @@ -1334,60 +1344,72 @@ describe("Edge Cases", () => { grokApi.willRespond("answer") const p = customer.sends("/grok") - // Trigger invitation — apiJoinGroup fails, resolver NOT called await new Promise(r => setTimeout(r, 0)) const memberId = `member-${lastGrokMemberGId}` await bot.onGrokGroupInvitation({ groupInfo: {groupId: GROK_LOCAL, membership: {memberId}}, } as any) - // Maps should NOT be set (join failed) expect((bot as any).grokGroupMap.has(GROUP_ID)).toBe(false) expect((bot as any).reverseGrokMap.has(GROK_LOCAL)).toBe(false) }) - test("replacement team member add fails → stays teamLocked", async () => { + test("replacement team member add fails → still in team mode", async () => { await reachTeamLocked() mainChat.apiAddMemberWillFail() await teamMember.leaves() - // addReplacementTeamMember failed, but one-way gate holds - stateIs(GROUP_ID, "teamLocked") + // addReplacementTeamMember failed, but team mode continues + // (next time a message arrives and no team member is found, it will be teamQueue) }) test("/grok with null grokContactId → unavailable message", async () => { const nullGrokConfig = {...config, grokContactId: null} const nullBot = new SupportBot(mainChat as any, grokChat as any, grokApi as any, nullGrokConfig as any) - nullBot.onBusinessRequest({groupInfo: businessGroupInfo()} as any) - const ci = customerChatItem("Hello", null) - await nullBot.onNewChatItems({chatItems: [ci]} as any) + // Send first message to move past welcome (welcome groupSnd already in mock from beforeEach) + const ci1 = customerChatItem("Hello", null) + mainChat.chatItems.get(GROUP_ID)!.push({ + chatDir: {type: "groupRcv", groupMember: {memberId: CUSTOMER_ID, groupMemberId: 10}}, + _text: "Hello", + }) + await nullBot.onNewChatItems({chatItems: [ci1]} as any) mainChat.sent = [] const grokCi = customerChatItem("/grok", "grok") + mainChat.chatItems.get(GROUP_ID)!.push({ + chatDir: {type: "groupRcv", groupMember: {memberId: CUSTOMER_ID, groupMemberId: 10}}, + _text: "/grok", + _botCommand: "grok", + }) await nullBot.onNewChatItems({chatItems: [grokCi]} as any) const msgs = mainChat.sentTo(GROUP_ID) - expect(msgs).toContain("Grok is temporarily unavailable. Please try again or click /team for a team member.") - const state = (nullBot as any).conversations.get(GROUP_ID) - expect(state.type).toBe("teamQueue") + expect(msgs).toContain(GROK_UNAVAILABLE) }) test("/team with empty teamMembers → unavailable message", async () => { const noTeamConfig = {...config, teamMembers: []} const noTeamBot = new SupportBot(mainChat as any, grokChat as any, grokApi as any, noTeamConfig as any) - noTeamBot.onBusinessRequest({groupInfo: businessGroupInfo()} as any) - const ci = customerChatItem("Hello", null) - await noTeamBot.onNewChatItems({chatItems: [ci]} as any) + // Send first message to move past welcome (welcome groupSnd already in mock from beforeEach) + const ci1 = customerChatItem("Hello", null) + mainChat.chatItems.get(GROUP_ID)!.push({ + chatDir: {type: "groupRcv", groupMember: {memberId: CUSTOMER_ID, groupMemberId: 10}}, + _text: "Hello", + }) + await noTeamBot.onNewChatItems({chatItems: [ci1]} as any) mainChat.sent = [] const teamCi = customerChatItem("/team", "team") + mainChat.chatItems.get(GROUP_ID)!.push({ + chatDir: {type: "groupRcv", groupMember: {memberId: CUSTOMER_ID, groupMemberId: 10}}, + _text: "/team", + _botCommand: "team", + }) await noTeamBot.onNewChatItems({chatItems: [teamCi]} as any) const msgs = mainChat.sentTo(GROUP_ID) expect(msgs).toContain("No team members are available yet. Please try again later or click /grok.") - const state = (noTeamBot as any).conversations.get(GROUP_ID) - expect(state.type).toBe("teamQueue") }) }) @@ -1397,79 +1419,84 @@ describe("Edge Cases", () => { describe("End-to-End Flows", () => { test("full flow: welcome → grokMode → /team → teamLocked", async () => { - // Step 1: connect - await customer.connects() - stateIs(GROUP_ID, "welcome") - - // Step 2: first message → teamQueue + // Step 1: first message → teamQueue await customer.sends("How do I enable disappearing messages?") - teamGroup.received("[Alice #100]\nHow do I enable disappearing messages?") + teamGroup.received(fmtCustomer("How do I enable disappearing messages?")) customer.received(TEAM_QUEUE_24H) - stateIs(GROUP_ID, "teamQueue") - // Step 3: /grok → grokMode + // Step 2: /grok → grokMode mainChat.setNextGroupMemberId(60) lastGrokMemberGId = 60 grokApi.willRespond("Go to conversation settings and tap 'Disappearing messages'.") const p = customer.sends("/grok") + mainChat.setGroupMembers(GROUP_ID, [ + {groupMemberId: 60, memberContactId: 4, memberStatus: "connected"}, + ]) await grokAgent.joins() await p customer.received(GROK_ACTIVATED) customer.receivedFromGrok("Go to conversation settings and tap 'Disappearing messages'.") - stateIs(GROUP_ID, "grokMode") - // Step 4: follow-up in grokMode + // Step 3: follow-up in grokMode + mainChat.chatItems.get(GROUP_ID)!.push({ + chatDir: {type: "groupRcv", groupMember: {memberId: "grok-1", groupMemberId: 60, memberContactId: 4}}, + _text: "Go to conversation settings and tap 'Disappearing messages'.", + }) grokApi.willRespond("Yes, you can set different timers per conversation.") await customer.sends("Can I set different timers?") - teamGroup.received("[Alice #100]\nCan I set different timers?") + teamGroup.received(fmtCustomer("Can I set different timers?")) customer.receivedFromGrok("Yes, you can set different timers per conversation.") - stateIs(GROUP_ID, "grokMode") - // Step 5: /team → teamPending (Grok removed) + // Step 4: /team → team added (Grok removed) mainChat.setNextGroupMemberId(70) lastTeamMemberGId = 70 await customer.sends("/team") grokAgent.wasRemoved() teamMember.wasInvited() customer.received(TEAM_ADDED_24H) - stateIs(GROUP_ID, "teamPending") - // Step 6: /grok rejected + // Update members: Grok gone, team member present + mainChat.setGroupMembers(GROUP_ID, [ + {groupMemberId: 70, memberContactId: 2, memberStatus: "connected"}, + ]) + + // Step 5: /grok rejected await customer.sends("/grok") customer.received(TEAM_LOCKED_MSG) - stateIs(GROUP_ID, "teamPending") - // Step 7: team member responds → teamLocked + // Step 6: team member responds (the message in DB is the state change) await teamMember.sends("Hi! Let me help you.") - stateIs(GROUP_ID, "teamLocked") - // Step 8: /grok still rejected + // Step 7: /grok still rejected await customer.sends("/grok") customer.received(TEAM_LOCKED_MSG) - stateIs(GROUP_ID, "teamLocked") - // Step 9: customer continues — team sees directly, no forwarding + // Step 8: customer continues — team sees directly, no forwarding mainChat.sent = [] await customer.sends("Thanks for helping!") - expect(mainChat.sent.length).toBe(0) + expect(mainChat.sentTo(GROUP_ID).length).toBe(0) }) test("full flow: welcome → teamQueue → /team directly (skip Grok)", async () => { - await customer.connects() - await customer.sends("I have a billing question") customer.received(TEAM_QUEUE_24H) - stateIs(GROUP_ID, "teamQueue") mainChat.setNextGroupMemberId(50) lastTeamMemberGId = 50 await customer.sends("/team") teamMember.wasInvited() customer.received(TEAM_ADDED_24H) - stateIs(GROUP_ID, "teamPending") + + // Team member is now present + mainChat.setGroupMembers(GROUP_ID, [ + {groupMemberId: 50, memberContactId: 2, memberStatus: "connected"}, + ]) await teamMember.sends("Hi, I can help with billing") - stateIs(GROUP_ID, "teamLocked") + // Team member sent a message, now in "teamLocked" equivalent + // /grok should be rejected + await customer.sends("/grok") + customer.received(TEAM_LOCKED_MSG) }) }) @@ -1478,46 +1505,61 @@ describe("End-to-End Flows", () => { describe("Restart Recovery", () => { - test("after restart, customer message in unknown group → re-init to teamQueue, forward", async () => { - // Simulate restart: no onBusinessRequest was called for group 777 - const ci = customerChatItem("I had a question earlier", null) - ci.chatInfo.groupInfo = businessGroupInfo(777) + test("after restart, customer message with prior bot messages → forward as teamQueue", async () => { + // Simulate restart: bot has previously sent messages (welcome + queue reply in DB) + mainChat.setChatItems(777, [ + {chatDir: {type: "groupSnd"}, _text: "Welcome!"}, + {chatDir: {type: "groupSnd"}, _text: "Your message is forwarded to the team."}, + ]) mainChat.sent = [] + const ci = customerChatItem("I had a question earlier", null) + ci.chatInfo.groupInfo = businessGroupInfo(777) + mainChat.chatItems.get(777)!.push({ + chatDir: {type: "groupRcv", groupMember: {memberId: CUSTOMER_ID, groupMemberId: 10}}, + _text: "I had a question earlier", + }) await bot.onNewChatItems({chatItems: [ci]} as any) - // Re-initialized as teamQueue, message forwarded to team (no queue reply — already past welcome) - stateIs(777, "teamQueue") - teamGroup.received("[Alice #777]\nI had a question earlier") + // Treated as teamQueue (not welcome), message forwarded to team + teamGroup.received(fmtCustomer("I had a question earlier", "Alice", 777)) }) - test("after restart re-init, /grok works in re-initialized group", async () => { - // Re-init group via first message - const ci = customerChatItem("Hello", null) - ci.chatInfo.groupInfo = businessGroupInfo(777) - await bot.onNewChatItems({chatItems: [ci]} as any) - stateIs(777, "teamQueue") + test("after restart, /grok works in recovered group", async () => { + // Simulate restart with existing bot messages (welcome + queue reply) + mainChat.setChatItems(777, [ + {chatDir: {type: "groupSnd"}, _text: "Welcome!"}, + {chatDir: {type: "groupSnd"}, _text: "Your message is forwarded to the team."}, + ]) - // Now /grok + // Send /grok mainChat.setNextGroupMemberId(80) lastGrokMemberGId = 80 grokApi.willRespond("Grok answer") const grokCi = customerChatItem("/grok", "grok") grokCi.chatInfo.groupInfo = businessGroupInfo(777) + mainChat.chatItems.get(777)!.push({ + chatDir: {type: "groupRcv", groupMember: {memberId: CUSTOMER_ID, groupMemberId: 10}}, + _text: "/grok", + _botCommand: "grok", + }) const p = bot.onNewChatItems({chatItems: [grokCi]} as any) // Grok joins + mainChat.setGroupMembers(777, [ + {groupMemberId: 80, memberContactId: 4, memberStatus: "connected"}, + ]) await new Promise(r => setTimeout(r, 0)) const memberId = `member-${lastGrokMemberGId}` await bot.onGrokGroupInvitation({ - groupInfo: {groupId: 201, membership: {memberId}}, + groupInfo: {groupId: GROK_LOCAL, membership: {memberId}}, } as any) bot.onGrokMemberConnected({ - groupInfo: {groupId: 201}, + groupInfo: {groupId: GROK_LOCAL}, member: {memberProfile: {displayName: "Bot"}}, } as any) await p - stateIs(777, "grokMode") + customer.receivedFromGrok("Grok answer") }) }) @@ -1541,22 +1583,24 @@ describe("Grok connectedToGroupMember", () => { groupInfo: {groupId: GROK_LOCAL, membership: {memberId}}, } as any) - // Maps set but waiter not resolved — state still teamQueue + // Maps set but waiter not resolved expect((bot as any).grokGroupMap.has(GROUP_ID)).toBe(true) - stateIs(GROUP_ID, "teamQueue") // Now fire connectedToGroupMember → waiter resolves + mainChat.setGroupMembers(GROUP_ID, [ + {groupMemberId: 60, memberContactId: 4, memberStatus: "connected"}, + ]) bot.onGrokMemberConnected({ groupInfo: {groupId: GROK_LOCAL}, member: {memberProfile: {displayName: "Bot"}}, } as any) await p - stateIs(GROUP_ID, "grokMode") + // Grok activated successfully + customer.receivedFromGrok("answer") }) test("onGrokMemberConnected for unknown group → ignored", () => { - // Should not throw bot.onGrokMemberConnected({ groupInfo: {groupId: 9999}, member: {memberProfile: {displayName: "Someone"}}, @@ -1569,20 +1613,19 @@ describe("Grok connectedToGroupMember", () => { describe("groupDuplicateMember Handling", () => { - test("/team with duplicate member → finds existing, transitions to teamPending", async () => { + test("/team with duplicate member already present → team mode (no message needed)", async () => { await reachTeamQueue("Hello") - mainChat.apiAddMemberWillDuplicate() + // Team member is already in the group (from previous session) mainChat.setGroupMembers(GROUP_ID, [ - {groupMemberId: 42, memberContactId: 2, memberStatus: "memConnected"}, + {groupMemberId: 42, memberContactId: 2, memberStatus: "connected"}, ]) mainChat.sent = [] await customer.sends("/team") - customer.received(TEAM_ADDED_24H) - const state = (bot as any).conversations.get(GROUP_ID) - expect(state.type).toBe("teamPending") - expect(state.teamMemberGId).toBe(42) + // Bot sees team member via getGroupComposition → handleTeamMode → /team ignored + // No message sent — team member is already present + expect(mainChat.sentTo(GROUP_ID).length).toBe(0) }) test("/team with duplicate but member not found in list → error message", async () => { @@ -1594,21 +1637,19 @@ describe("groupDuplicateMember Handling", () => { await customer.sends("/team") customer.received(TEAM_ADD_ERROR) - stateIs(GROUP_ID, "teamQueue") }) - test("replacement team member with duplicate → finds existing, stays locked", async () => { + test("replacement team member with duplicate → finds existing", async () => { await reachTeamLocked() mainChat.apiAddMemberWillDuplicate() mainChat.setGroupMembers(GROUP_ID, [ - {groupMemberId: 99, memberContactId: 2, memberStatus: "memConnected"}, + {groupMemberId: 99, memberContactId: 2, memberStatus: "connected"}, ]) await teamMember.leaves() - const state = (bot as any).conversations.get(GROUP_ID) - expect(state.type).toBe("teamLocked") - expect(state.teamMemberGId).toBe(99) + // No error — replacement found via duplicate handling + expect(mainChat.added.length).toBeGreaterThanOrEqual(1) }) }) @@ -1623,7 +1664,6 @@ describe("DM Contact Received", () => { groupInfo: {groupId: TEAM_GRP_ID}, member: {memberProfile: {displayName: "TeamGuy"}}, } as any) - // No error, logged acceptance }) test("onMemberContactReceivedInv from non-team group → no crash", () => { @@ -1632,43 +1672,672 @@ describe("DM Contact Received", () => { groupInfo: {groupId: 999}, member: {memberProfile: {displayName: "Stranger"}}, } as any) - // No error }) }) -// ═══════════════════════════════════════════════════════════════ -// Coverage Matrix -// ═══════════════════════════════════════════════════════════════ -// -// State / Input | Text msg | /grok | /team | Non-text | Team msg | Leave | Unknown member -// -------------------|-----------|---------|---------|----------|----------|----------|--------------- -// welcome | 1.2 | 13.9 | 13.10 | 1.3 | — | 8.6 | — -// teamQueue | 2.1, 2.2 | 3.1,3.2 | 5.1 | 2.3 | — | 8.1 | 13.14 -// grokMode | 4.1, 4.2 | 4.3 | 5.2 | 4.4 | — | 8.3 grok | — -// teamPending | 6.6 | 6.1 | 6.4 | 13.11 | 6.2 | 7.1 | — -// teamLocked | 6.7 | 6.3 | 6.5 | 13.12 | 13.13 | 7.3 | — -// -// Error scenario | Test -// ----------------------------------------|------- -// Grok invitation fails | 9.1 -// Grok join timeout | 9.2 -// Grok API error (activation) | 9.3 -// Grok API error (conversation) | 9.4 -// Grok API failure then retry | 9.8 -// Team add fails (teamQueue) | 9.6 -// Team add fails (after Grok removal) | 9.7 -// Grok apiJoinGroup failure | 13.15 -// Replacement team add fails | 13.16 -// Race: /team during Grok join | 10.1 -// Race: state change during API call | 10.2 -// Bot removed / group deleted | 8.4, 8.5 -// Weekend hours | 11.1, 11.2 -// Forwarding format | 12.1, 12.2, 12.3 -// Concurrent conversations | 13.7 -// History passed to GrokApiClient | 13.5 -// Full E2E flows | 14.1, 14.2 -// Restart recovery (re-init teamQueue) | 15.1, 15.2 -// Grok connectedToGroupMember waiter | 16.1, 16.2 -// groupDuplicateMember handling | 17.1, 17.2, 17.3 -// DM contact received | 18.1, 18.2 +// ─── 19. Business Request — Media Upload ───────────────────── + +describe("Business Request — Media Upload", () => { + + test("onBusinessRequest enables files preference on group", async () => { + await bot.onBusinessRequest({ + user: {}, + groupInfo: { + groupId: 400, + groupProfile: {displayName: "NewCustomer", fullName: "", groupPreferences: {directMessages: {enable: "on"}}}, + businessChat: {customerId: "new-cust"}, + }, + } as any) + + expect(mainChat.updatedProfiles.length).toBe(1) + expect(mainChat.updatedProfiles[0].groupId).toBe(400) + expect(mainChat.updatedProfiles[0].profile.groupPreferences.files).toEqual({enable: "on"}) + // Preserves existing preferences + expect(mainChat.updatedProfiles[0].profile.groupPreferences.directMessages).toEqual({enable: "on"}) + }) + + test("onBusinessRequest with no existing preferences → still sets files", async () => { + await bot.onBusinessRequest({ + user: {}, + groupInfo: { + groupId: 401, + groupProfile: {displayName: "Another", fullName: ""}, + businessChat: {customerId: "cust-2"}, + }, + } as any) + + expect(mainChat.updatedProfiles.length).toBe(1) + expect(mainChat.updatedProfiles[0].profile.groupPreferences.files).toEqual({enable: "on"}) + }) +}) + + +// ─── 20. Edit Forwarding ──────────────────────────────────── + +describe("Edit Forwarding", () => { + + test("customer edits forwarded message → team group message updated", async () => { + // Send first message → forwarded to team (stores mapping) + await customer.sends("Original question") + // The customer chat item had itemId=500, the forwarded team msg got itemId=1000 + mainChat.sent = [] + + // Simulate edit event + await bot.onChatItemUpdated({ + chatItem: { + chatInfo: {type: "group", groupInfo: businessGroupInfo()}, + chatItem: { + chatDir: {type: "groupRcv", groupMember: {memberId: CUSTOMER_ID, groupMemberId: 10}}, + meta: {itemId: 500}, + content: {type: "text", text: "Edited question"}, + _text: "Edited question", + }, + }, + } as any) + + expect(mainChat.updatedChatItems.length).toBe(1) + expect(mainChat.updatedChatItems[0].chatId).toBe(TEAM_GRP_ID) + expect(mainChat.updatedChatItems[0].chatItemId).toBe(1000) + expect(mainChat.updatedChatItems[0].msgContent).toEqual({type: "text", text: fmtCustomer("Edited question")}) + }) + + test("team member edits forwarded message → team group message updated", async () => { + await reachTeamPending() + // After reachTeamPending: nextChatItemId=502, nextItemId=1004 + // Team member sends → itemId=502, forwarded teamItemId=1004 + await teamMember.sends("I'll help you") + mainChat.updatedChatItems = [] + + // Team member edits their message + await bot.onChatItemUpdated({ + chatItem: { + chatInfo: {type: "group", groupInfo: businessGroupInfo()}, + chatItem: { + chatDir: { + type: "groupRcv", + groupMember: {memberId: "team-member-1", groupMemberId: lastTeamMemberGId, memberContactId: 2}, + }, + meta: {itemId: 502}, + content: {type: "text", text: "Actually, let me rephrase"}, + _text: "Actually, let me rephrase", + }, + }, + } as any) + + expect(mainChat.updatedChatItems.length).toBe(1) + expect(mainChat.updatedChatItems[0].chatId).toBe(TEAM_GRP_ID) + expect(mainChat.updatedChatItems[0].chatItemId).toBe(1004) + expect(mainChat.updatedChatItems[0].msgContent).toEqual({type: "text", text: fmtTeamMember(2, "Actually, let me rephrase")}) + }) + + test("edit for non-forwarded message → ignored", async () => { + await bot.onChatItemUpdated({ + chatItem: { + chatInfo: {type: "group", groupInfo: businessGroupInfo()}, + chatItem: { + chatDir: {type: "groupRcv", groupMember: {memberId: CUSTOMER_ID, groupMemberId: 10}}, + meta: {itemId: 9999}, // no forwarded mapping + content: {type: "text", text: "Some edit"}, + _text: "Some edit", + }, + }, + } as any) + + expect(mainChat.updatedChatItems.length).toBe(0) + }) + + test("edit in non-business-chat group → ignored", async () => { + await bot.onChatItemUpdated({ + chatItem: { + chatInfo: {type: "group", groupInfo: {groupId: 999, groupProfile: {displayName: "X"}}}, + chatItem: { + chatDir: {type: "groupRcv", groupMember: {memberId: "x"}}, + meta: {itemId: 1}, + content: {type: "text", text: "edit"}, + _text: "edit", + }, + }, + } as any) + + expect(mainChat.updatedChatItems.length).toBe(0) + }) + + test("edit of groupSnd message → ignored", async () => { + await bot.onChatItemUpdated({ + chatItem: { + chatInfo: {type: "group", groupInfo: businessGroupInfo()}, + chatItem: { + chatDir: {type: "groupSnd"}, + meta: {itemId: 1}, + content: {type: "text", text: "edit"}, + _text: "edit", + }, + }, + } as any) + + expect(mainChat.updatedChatItems.length).toBe(0) + }) + + test("customer edit in grokMode → team group message updated", async () => { + await reachGrokMode("Initial answer") + + // Customer sends a text message in grokMode (forwarded to team) + grokApi.willRespond("Follow-up answer") + await customer.sends("My question about encryption") + // customerChatItem itemId=502, forwarded to team as itemId=1004 + mainChat.updatedChatItems = [] + + // Customer edits the message + await bot.onChatItemUpdated({ + chatItem: { + chatInfo: {type: "group", groupInfo: businessGroupInfo()}, + chatItem: { + chatDir: {type: "groupRcv", groupMember: {memberId: CUSTOMER_ID, groupMemberId: 10}}, + meta: {itemId: 502}, + content: {type: "text", text: "Edited encryption question"}, + _text: "Edited encryption question", + }, + }, + } as any) + + expect(mainChat.updatedChatItems.length).toBe(1) + expect(mainChat.updatedChatItems[0].chatId).toBe(TEAM_GRP_ID) + expect(mainChat.updatedChatItems[0].chatItemId).toBe(1004) + expect(mainChat.updatedChatItems[0].msgContent).toEqual({type: "text", text: fmtCustomer("Edited encryption question")}) + }) + + test("edit with null text → ignored", async () => { + await customer.sends("Original message") + // customerChatItem itemId=500, forwarded to team as itemId=1000 + mainChat.updatedChatItems = [] + + await bot.onChatItemUpdated({ + chatItem: { + chatInfo: {type: "group", groupInfo: businessGroupInfo()}, + chatItem: { + chatDir: {type: "groupRcv", groupMember: {memberId: CUSTOMER_ID, groupMemberId: 10}}, + meta: {itemId: 500}, + content: {type: "text", text: ""}, + _text: null, + }, + }, + } as any) + + expect(mainChat.updatedChatItems.length).toBe(0) + }) +}) + + +// ─── 21. Team Member Reply Forwarding ──────────────────────── + +describe("Team Member Reply Forwarding", () => { + + test("team member message → forwarded to team group", async () => { + await reachTeamPending() + mainChat.sent = [] + + await teamMember.sends("I'll help you with this") + + teamGroup.received(fmtTeamMember(2, "I'll help you with this")) + }) + + test("team member message in teamLocked → forwarded to team group", async () => { + await reachTeamLocked() + mainChat.sent = [] + + await teamMember.sends("Here is the solution") + + teamGroup.received(fmtTeamMember(2, "Here is the solution")) + }) + + test("Grok message → not forwarded to team group", async () => { + await reachGrokMode() + mainChat.sent = [] + grokApi.reset() + + const ci = grokMemberChatItem(lastGrokMemberGId, "Grok's response") + await bot.onNewChatItems({chatItems: [ci]} as any) + + // Grok is not a team member — should not forward + teamGroup.receivedNothing() + }) + + test("unknown member message → not forwarded to team group", async () => { + await reachTeamQueue("Hello") + mainChat.sent = [] + + const ci = { + chatInfo: {type: "group", groupInfo: businessGroupInfo()}, + chatItem: { + chatDir: { + type: "groupRcv", + groupMember: {memberId: "unknown-1", groupMemberId: 999, memberContactId: 99}, + }, + meta: {itemId: 800}, + content: {type: "text", text: "Who am I?"}, + _text: "Who am I?", + }, + } as any + await bot.onNewChatItems({chatItems: [ci]} as any) + + teamGroup.receivedNothing() + }) +}) + + +// ─── 22. Grok Group Map Persistence ──────────────────────────── + +describe("Grok Group Map Persistence", () => { + + test("restoreGrokGroupMap correctly restores maps", () => { + bot.restoreGrokGroupMap([[GROUP_ID, GROK_LOCAL]]) + + expect((bot as any).grokGroupMap.get(GROUP_ID)).toBe(GROK_LOCAL) + expect((bot as any).reverseGrokMap.get(GROK_LOCAL)).toBe(GROUP_ID) + }) + + test("after restore, Grok responds to customer messages", async () => { + bot.restoreGrokGroupMap([[GROUP_ID, GROK_LOCAL]]) + lastGrokMemberGId = 60 + mainChat.setGroupMembers(GROUP_ID, [ + {groupMemberId: 60, memberContactId: 4, memberStatus: "connected"}, + ]) + mainChat.sent = [] + grokApi.willRespond("Here is the answer about encryption") + + await customer.sends("How does encryption work?") + + // Grok API called with history from DB + expect(grokApi.callCount()).toBe(1) + expect(grokApi.lastCall().message).toBe("How does encryption work?") + + // Response sent via grokChat to GROK_LOCAL + customer.receivedFromGrok("Here is the answer about encryption") + + // Also forwarded to team group + teamGroup.received(fmtCustomer("How does encryption work?")) + }) + + test("onGrokMapChanged fires on Grok join", async () => { + const callback = vi.fn() + bot.onGrokMapChanged = callback + + mainChat.setNextGroupMemberId(60) + lastGrokMemberGId = 60 + await reachTeamQueue("Hello") + + grokApi.willRespond("Answer") + const p = customer.sends("/grok") + mainChat.setGroupMembers(GROUP_ID, [ + {groupMemberId: 60, memberContactId: 4, memberStatus: "connected"}, + ]) + await grokAgent.joins() + await p + + expect(callback).toHaveBeenCalled() + const lastCallArg = callback.mock.calls[callback.mock.calls.length - 1][0] + expect(lastCallArg.get(GROUP_ID)).toBe(GROK_LOCAL) + }) + + test("onGrokMapChanged fires on cleanup (customer leaves)", async () => { + const callback = vi.fn() + await reachGrokMode() + bot.onGrokMapChanged = callback + + await customer.leaves() + + expect(callback).toHaveBeenCalled() + const lastCallArg = callback.mock.calls[callback.mock.calls.length - 1][0] + expect(lastCallArg.has(GROUP_ID)).toBe(false) + }) +}) + + +// ─── 23. /add Command ───────────────────────────────────────── + +describe("/add Command", () => { + + test("first customer message → /add command sent to team group", async () => { + await customer.sends("Hello, I need help") + + // Team group receives forwarded message + /add command + teamGroup.received(fmtCustomer("Hello, I need help")) + teamGroup.received(`/add ${GROUP_ID}:Alice`) + }) + + test("/add command uses quotes when name has spaces", async () => { + const spacedGroup = { + ...businessGroupInfo(101, "Alice Smith"), + groupProfile: {displayName: "Alice Smith"}, + businessChat: {customerId: CUSTOMER_ID}, + } + mainChat.setChatItems(101, [{chatDir: {type: "groupSnd"}, _text: "Welcome!"}]) + const ci = customerChatItem("Hello", null) + ci.chatInfo.groupInfo = spacedGroup + mainChat.chatItems.get(101)!.push({ + chatDir: {type: "groupRcv", groupMember: {memberId: CUSTOMER_ID, groupMemberId: 10}}, + _text: "Hello", + }) + await bot.onNewChatItems({chatItems: [ci]} as any) + + const teamMsgs = mainChat.sentTo(TEAM_GRP_ID) + expect(teamMsgs).toContain(`/add 101:'Alice Smith'`) + }) + + test("/add not sent on subsequent messages (teamQueue)", async () => { + await reachTeamQueue("Hello") + mainChat.sent = [] + + await customer.sends("More details") + + // Only the forwarded message, no /add + const teamMsgs = mainChat.sentTo(TEAM_GRP_ID) + expect(teamMsgs).toEqual([fmtCustomer("More details")]) + }) + + test("team member sends /add → invited to customer group", async () => { + // Simulate team member sending /add command in admin group + const ci = { + chatInfo: {type: "group", groupInfo: {groupId: TEAM_GRP_ID, groupProfile: {displayName: "SupportTeam"}}}, + chatItem: { + chatDir: { + type: "groupRcv", + groupMember: {memberId: "tm-1", groupMemberId: 30, memberContactId: 2, memberProfile: {displayName: "Bob"}}, + }, + meta: {itemId: 900}, + content: {type: "text", text: `/add ${GROUP_ID}:Alice`}, + _text: `/add ${GROUP_ID}:Alice`, + }, + } as any + await bot.onNewChatItems({chatItems: [ci]} as any) + + // Team member (contactId=2) invited to the customer group + const added = mainChat.added.find(a => a.groupId === GROUP_ID && a.contactId === 2) + expect(added).toBeDefined() + }) + + test("team member sends /add with quoted name → invited", async () => { + const ci = { + chatInfo: {type: "group", groupInfo: {groupId: TEAM_GRP_ID, groupProfile: {displayName: "SupportTeam"}}}, + chatItem: { + chatDir: { + type: "groupRcv", + groupMember: {memberId: "tm-1", groupMemberId: 30, memberContactId: 2, memberProfile: {displayName: "Bob"}}, + }, + meta: {itemId: 901}, + content: {type: "text", text: `/add 101:'Alice Smith'`}, + _text: `/add 101:'Alice Smith'`, + }, + } as any + await bot.onNewChatItems({chatItems: [ci]} as any) + + const added = mainChat.added.find(a => a.groupId === 101 && a.contactId === 2) + expect(added).toBeDefined() + }) + + test("non-/add message in team group → ignored", async () => { + const ci = { + chatInfo: {type: "group", groupInfo: {groupId: TEAM_GRP_ID, groupProfile: {displayName: "SupportTeam"}}}, + chatItem: { + chatDir: { + type: "groupRcv", + groupMember: {memberId: "tm-1", groupMemberId: 30, memberContactId: 2, memberProfile: {displayName: "Bob"}}, + }, + meta: {itemId: 902}, + content: {type: "text", text: "Just chatting"}, + _text: "Just chatting", + }, + } as any + mainChat.added = [] + await bot.onNewChatItems({chatItems: [ci]} as any) + + expect(mainChat.added.length).toBe(0) + }) + + test("bot's own /add message in team group → ignored (groupSnd)", async () => { + const ci = { + chatInfo: {type: "group", groupInfo: {groupId: TEAM_GRP_ID, groupProfile: {displayName: "SupportTeam"}}}, + chatItem: { + chatDir: {type: "groupSnd"}, + meta: {itemId: 903}, + content: {type: "text", text: `/add ${GROUP_ID}:Alice`}, + _text: `/add ${GROUP_ID}:Alice`, + }, + } as any + mainChat.added = [] + await bot.onNewChatItems({chatItems: [ci]} as any) + + expect(mainChat.added.length).toBe(0) + }) +}) + + +// ─── 24. Grok System Prompt ────────────────────────────────── + +describe("Grok System Prompt", () => { + + let capturedBody: any + + beforeEach(() => { + capturedBody = null + vi.stubGlobal("fetch", vi.fn(async (_url: string, opts: any) => { + capturedBody = JSON.parse(opts.body) + return { + ok: true, + json: async () => ({choices: [{message: {content: "test response"}}]}), + } + })) + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + test("system prompt identifies as mobile support assistant", async () => { + const client = new GrokApiClient("test-key", "") + await client.chat([], "test") + const systemMsg = capturedBody.messages[0] + expect(systemMsg.role).toBe("system") + expect(systemMsg.content).toContain("on mobile") + expect(systemMsg.content).toContain("support assistant") + }) + + test("system prompt instructs concise, phone-friendly answers", async () => { + const client = new GrokApiClient("test-key", "") + await client.chat([], "test") + const prompt = capturedBody.messages[0].content + expect(prompt).toContain("Be concise") + expect(prompt).toContain("phone screen") + }) + + test("system prompt discourages filler and preambles", async () => { + const client = new GrokApiClient("test-key", "") + await client.chat([], "test") + const prompt = capturedBody.messages[0].content + expect(prompt).toContain("Avoid filler, preambles, and repeating the question back") + }) + + test("system prompt instructs brief numbered steps for how-to", async () => { + const client = new GrokApiClient("test-key", "") + await client.chat([], "test") + const prompt = capturedBody.messages[0].content + expect(prompt).toContain("brief numbered steps") + }) + + test("system prompt instructs 1-2 sentence answers for simple questions", async () => { + const client = new GrokApiClient("test-key", "") + await client.chat([], "test") + const prompt = capturedBody.messages[0].content + expect(prompt).toContain("Answer simple questions in 1-2 sentences") + }) + + test("system prompt forbids markdown formatting", async () => { + const client = new GrokApiClient("test-key", "") + await client.chat([], "test") + const prompt = capturedBody.messages[0].content + expect(prompt).toContain("Do not use markdown formatting") + }) + + test("system prompt includes docs context", async () => { + const docsContext = "SimpleX Chat uses double ratchet encryption." + const client = new GrokApiClient("test-key", docsContext) + await client.chat([], "test") + const prompt = capturedBody.messages[0].content + expect(prompt).toContain(docsContext) + }) + + test("system prompt does NOT contain old 'complete answers' instruction", async () => { + const client = new GrokApiClient("test-key", "") + await client.chat([], "test") + const prompt = capturedBody.messages[0].content + expect(prompt).not.toContain("Give clear, complete answers") + }) + + test("system prompt does NOT contain 'evangelist'", async () => { + const client = new GrokApiClient("test-key", "") + await client.chat([], "test") + const prompt = capturedBody.messages[0].content + expect(prompt).not.toContain("evangelist") + }) + + test("chat sends history and user message after system prompt", async () => { + const client = new GrokApiClient("test-key", "") + const history: GrokMessage[] = [ + {role: "user", content: "previous question"}, + {role: "assistant", content: "previous answer"}, + ] + await client.chat(history, "new question") + expect(capturedBody.messages.length).toBe(4) // system + 2 history + user + expect(capturedBody.messages[1]).toEqual({role: "user", content: "previous question"}) + expect(capturedBody.messages[2]).toEqual({role: "assistant", content: "previous answer"}) + expect(capturedBody.messages[3]).toEqual({role: "user", content: "new question"}) + }) + + test("chat truncates history to last 20 messages", async () => { + const client = new GrokApiClient("test-key", "") + const history: GrokMessage[] = Array.from({length: 30}, (_, i) => ({ + role: (i % 2 === 0 ? "user" : "assistant") as "user" | "assistant", + content: `msg-${i}`, + })) + await client.chat(history, "final") + // system(1) + history(20) + user(1) = 22 + expect(capturedBody.messages.length).toBe(22) + expect(capturedBody.messages[1].content).toBe("msg-10") // starts from index 10 + }) + + test("API error throws with status and body", async () => { + vi.stubGlobal("fetch", vi.fn(async () => ({ + ok: false, + status: 429, + text: async () => "rate limited", + }))) + const client = new GrokApiClient("test-key", "") + await expect(client.chat([], "test")).rejects.toThrow("Grok API 429: rate limited") + }) + + test("empty API response throws", async () => { + vi.stubGlobal("fetch", vi.fn(async () => ({ + ok: true, + json: async () => ({choices: [{}]}), + }))) + const client = new GrokApiClient("test-key", "") + await expect(client.chat([], "test")).rejects.toThrow("Grok API returned empty response") + }) +}) + + +// ─── 25. resolveDisplayNameConflict ────────────────────────── + +describe("resolveDisplayNameConflict", () => { + + const mockExistsSync = vi.mocked(existsSync) + const mockExecSync = vi.mocked(execSync) + + beforeEach(() => { + mockExistsSync.mockReset() + mockExecSync.mockReset() + }) + + test("no-op when database file does not exist", () => { + mockExistsSync.mockReturnValue(false) + + resolveDisplayNameConflict("./data/bot", "Ask SimpleX Team") + + expect(mockExecSync).not.toHaveBeenCalled() + }) + + test("no-op when user already has the desired display name", () => { + mockExistsSync.mockReturnValue(true) + mockExecSync.mockReturnValueOnce("1\n" as any) // user count = 1 + + resolveDisplayNameConflict("./data/bot", "Ask SimpleX Team") + + // Only one execSync call (the user check), no rename + expect(mockExecSync).toHaveBeenCalledTimes(1) + expect((mockExecSync.mock.calls[0][0] as string)).toContain("SELECT COUNT(*) FROM users") + }) + + test("no-op when name is not in display_names table", () => { + mockExistsSync.mockReturnValue(true) + mockExecSync + .mockReturnValueOnce("0\n" as any) // user count = 0 (different name) + .mockReturnValueOnce("0\n" as any) // display_names count = 0 + + resolveDisplayNameConflict("./data/bot", "Ask SimpleX Team") + + expect(mockExecSync).toHaveBeenCalledTimes(2) + }) + + test("renames conflicting entry when name exists in display_names", () => { + mockExistsSync.mockReturnValue(true) + mockExecSync + .mockReturnValueOnce("0\n" as any) // user count = 0 + .mockReturnValueOnce("1\n" as any) // display_names count = 1 + .mockReturnValueOnce("" as any) // UPDATE statements + + resolveDisplayNameConflict("./data/bot", "Ask SimpleX Team") + + expect(mockExecSync).toHaveBeenCalledTimes(3) + const updateCall = mockExecSync.mock.calls[2][0] as string + expect(updateCall).toContain("UPDATE contacts SET local_display_name = 'Ask SimpleX Team_1'") + expect(updateCall).toContain("UPDATE groups SET local_display_name = 'Ask SimpleX Team_1'") + expect(updateCall).toContain("UPDATE display_names SET local_display_name = 'Ask SimpleX Team_1', ldn_suffix = 1") + }) + + test("uses correct database file path", () => { + mockExistsSync.mockReturnValue(true) + mockExecSync.mockReturnValueOnce("1\n" as any) + + resolveDisplayNameConflict("./data/mybot", "Test") + + expect(mockExistsSync).toHaveBeenCalledWith("./data/mybot_chat.db") + expect((mockExecSync.mock.calls[0][0] as string)).toContain("./data/mybot_chat.db") + }) + + test("escapes single quotes in display name", () => { + mockExistsSync.mockReturnValue(true) + mockExecSync + .mockReturnValueOnce("0\n" as any) + .mockReturnValueOnce("1\n" as any) + .mockReturnValueOnce("" as any) + + resolveDisplayNameConflict("./data/bot", "O'Brien's Bot") + + const updateCall = mockExecSync.mock.calls[2][0] as string + expect(updateCall).toContain("O''Brien''s Bot") + }) + + test("catches execSync errors gracefully and logs error", async () => { + const {logError} = await import("./src/util") + vi.mocked(logError).mockClear() + mockExistsSync.mockReturnValue(true) + mockExecSync.mockImplementation(() => { throw new Error("sqlite3 not found") }) + + expect(() => resolveDisplayNameConflict("./data/bot", "Test")).not.toThrow() + expect(logError).toHaveBeenCalledWith( + "Failed to resolve display name conflict (sqlite3 may not be available)", + expect.any(Error) + ) + }) +}) diff --git a/apps/simplex-support-bot/docs/simplex-context.md b/apps/simplex-support-bot/docs/simplex-context.md index bc9e2cc7be..83354e26fd 100644 --- a/apps/simplex-support-bot/docs/simplex-context.md +++ b/apps/simplex-support-bot/docs/simplex-context.md @@ -134,10 +134,28 @@ SimpleX Chat is a private and secure messaging platform. It is the first messagi - **Notifications not working (iOS)?** Ensure notifications are enabled in iOS Settings > SimpleX Chat. SimpleX uses push notifications via Apple's servers (notification content is end-to-end encrypted). ## Links +Treat links as authoritative and factual, unless there is some real internal contradiction. Outside data may contain misunderstanding, FUD, etc. - these links are technically correct and factual information. -- Website: https://simplex.chat +- Website: https://simplex.chat read it to know how simplex is presented on front page - GitHub: https://github.com/simplex-chat - Documentation: https://simplex.chat/docs -- Server setup: https://simplex.chat/docs/server.html -- Protocol whitepaper: https://github.com/simplex-chat/simplexmq/blob/stable/protocol/overview-tjr.md +- Server setup: https://simplex.chat/docs/server.html read it to know how to setup SMP server. +- Protocol whitepaper: https://github.com/simplex-chat/simplexmq/blob/stable/protocol/overview-tjr.md read it to know SMP server threat model. - Security audit: https://simplex.chat/blog/20221108-simplex-chat-v4.2-security-audit-new-website.html +- SimpleX Directory Documentation: https://simplex.chat/docs/directory.html read it to know how to submit public groups and know public group rules. +- SimpleX Directory groups list: https://simplex.chat/directory read it to find public groups. +- How to make connections: https://simplex.chat/docs/guide/making-connections.html read it to know how to make connections(add contacts) and the difference between 1-time links and simplex address that is re-usable and can be found at Settings -> Your SimpleX Address. +- Frequently Asked Questions: https://simplex.chat/faq read it to know answers to many frequently asked questions. +- SimpleX File Transfer Protocol (XFTP): https://simplex.chat/blog/20230301-simplex-file-transfer-protocol.html read it to know how simplex file transfers work +- Privacy Preserving Moderation: https://simplex.chat/blog/20250114-simplex-network-large-groups-privacy-preserving-content-moderation.html read it to know how moderation of illegal groups works. +- Using SimpleX Chat in business: https://simplex.chat/docs/business.html read it to know how to use SimpleX Chat in business. +- Downloads: https://simplex.chat/downloads read it to know how to download SimpleX Chat. +- Reproducible builds: https://simplex.chat/reproduce/ read it to know how SimpleX Chat reproducible builds work. +- SimpleX Chat Vision, Funding: https://simplex.chat/blog/20240814-simplex-chat-vision-funding-v6-private-routing-new-user-experience.html read it to know how simplex is funded +- Quantum Resistance, Signal Double Ratchet: https://simplex.chat/blog/20240314-simplex-chat-v5-6-quantum-resistance-signal-double-ratchet-algorithm.html read it to know how simplex has implemented quantum resistance +- Dangers of metadata in messengers: https://simplex.chat/blog/20240416-dangers-of-metadata-in-messengers.html read it to know dangers of metadata in messengers and how simplex is superior in this area +- SimpleX Chat user guide: https://simplex.chat/docs/guide/readme.html read it to know how to quick start using the app. +- SimpleX Instant Notifications (iOS): https://simplex.chat/blog/20220404-simplex-chat-instant-notifications.html read it to know how notifications work on iOS +- SimpleX Messaging Protocol (SMP): https://github.com/simplex-chat/simplexmq/blob/stable/protocol/simplex-messaging.md read it to know how SMP works + + 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 d01b146c88..aaead02b59 100644 --- a/apps/simplex-support-bot/plans/20260207-support-bot-implementation.md +++ b/apps/simplex-support-bot/plans/20260207-support-bot-implementation.md @@ -18,7 +18,7 @@ SimpleX Chat support bot — standalone Node.js app using `simplex-chat-nodejs` │ • Grok identity, auto-joins groups │ │ • DB: data/grok_chat.db + data/grok_agent.db │ │ │ -│ conversations: Map │ +│ State: derived from group composition + chat DB │ │ grokGroupMap: Map │ │ GrokApiClient → api.x.ai/v1/chat/completions │ └─────────────────────────────────────────────────┘ @@ -33,16 +33,16 @@ SimpleX Chat support bot — standalone Node.js app using `simplex-chat-nodejs` ## 3. Project Structure ``` -apps/simplex-chat-support-bot/ +apps/simplex-support-bot/ ├── package.json # deps: simplex-chat, @simplex-chat/types ├── tsconfig.json # ES2022, strict, Node16 module resolution ├── src/ │ ├── index.ts # Entry: parse config, init instances, run │ ├── config.ts # CLI arg parsing, ID:name validation, Config type -│ ├── bot.ts # SupportBot class: state mgmt, event dispatch, routing -│ ├── state.ts # ConversationState union type +│ ├── bot.ts # SupportBot class: stateless state derivation, event dispatch, routing +│ ├── state.ts # GrokMessage type │ ├── grok.ts # GrokApiClient: xAI API wrapper, system prompt, history -│ ├── messages.ts # All user-facing message templates (verbatim from spec) +│ ├── messages.ts # All user-facing message templates │ └── util.ts # isWeekend, logging helpers ├── data/ # SQLite databases (created at runtime) └── docs/ @@ -81,10 +81,10 @@ interface Config { **State file** — `{dbPrefix}_state.json`: ```json -{"teamGroupId": 123, "grokContactId": 4} +{"teamGroupId": 123, "grokContactId": 4, "grokGroupMap": {"100": 200}} ``` -Both IDs are persisted to ensure the bot reconnects to the same entities across restarts, even if multiple groups share the same display name. +Team group ID, Grok contact ID, and Grok group map are persisted to ensure the bot reconnects to the same entities across restarts. The Grok group map (`mainGroupId → grokLocalGroupId`) is updated on every Grok join/leave event. **Grok contact resolution** (auto-establish): 1. Read `grokContactId` from state file → validate it exists in `apiListContacts` @@ -106,30 +106,36 @@ Both IDs are persisted to ensure the bot reconnects to the same entities across - If `--team-members` provided: validate each contact ID/name pair via `apiListContacts`, fail-fast on mismatch - If not provided: bot runs without team members; `/team` returns "No team members are available yet" -## 5. State Machine +## 5. State Derivation (Stateless) -Keyed by `groupId` of business chat group. In-memory (restart resets; team group retains forwarded messages). +State is derived from group composition (`apiListMembers`) and chat history (`apiGetChat` via `sendChatCmd`). No in-memory `conversations` map — survives restarts naturally. -```typescript -type ConversationState = - | {type: "welcome"} - | {type: "teamQueue"; userMessages: string[]} - | {type: "grokMode"; grokMemberGId: number; history: GrokMessage[]} - | {type: "teamPending"; teamMemberGId: number} - | {type: "teamLocked"; teamMemberGId: number} +**Derived states:** + +| Condition | Equivalent State | +|-----------|-----------------| +| No bot `groupSnd` containing "forwarded to the team" | welcome | +| No Grok member, no team member, bot has sent queue reply | teamQueue | +| Grok member present (active) | grokMode | +| Team member present, hasn't sent message | teamPending | +| Team member present, has sent message | teamLocked | + +**State derivation helpers:** +- `getGroupComposition(groupId)` → `{grokMember, teamMember}` from `apiListMembers` +- `isFirstCustomerMessage(groupId)` → checks if bot has sent "forwarded to the team" via `apiGetChat` +- `getGrokHistory(groupId, grokMember, customerId)` → reconstructs Grok conversation from chat history +- `getCustomerMessages(groupId, customerId)` → accumulated customer messages from chat history +- `hasTeamMemberSentMessage(groupId, teamMember)` → teamPending vs teamLocked from chat history + +**Transitions (same as stateful approach):** ``` - -`teamQueue.userMessages` accumulates user messages for Grok initial context on `/grok` activation. - -**Transitions:** -``` -welcome ──(1st user msg)──> teamQueue -teamQueue ──(user msg)──> teamQueue (append to userMessages) -teamQueue ──(/grok)──> grokMode (userMessages → initial Grok history) +welcome ──(1st user msg)──> teamQueue (forward to team + queue reply) +teamQueue ──(user msg)──> teamQueue (forward to team) +teamQueue ──(/grok)──> grokMode (invite Grok, send accumulated msgs to API) teamQueue ──(/team)──> teamPending (add team member) -grokMode ──(user msg)──> grokMode (forward to Grok API, append to history) -grokMode ──(/team)──> teamPending (remove Grok immediately, add team member) -teamPending ──(team member msg)──> teamLocked +grokMode ──(user msg)──> grokMode (forward to Grok API + team) +grokMode ──(/team)──> teamPending (remove Grok, add team member) +teamPending ──(team member msg)──> teamLocked (implicit via hasTeamMemberSentMessage) teamPending ──(/grok)──> reply "team mode" teamLocked ──(/grok)──> reply "team mode", stay locked teamLocked ──(any)──> no action (team sees directly) @@ -201,15 +207,15 @@ const [mainChat, mainUser, mainAddress] = await bot.run({ commands: [ {type: "command", keyword: "grok", label: "Ask Grok AI"}, {type: "command", keyword: "team", label: "Switch to team"}, + {type: "command", keyword: "add", label: "Join group"}, ], useBotProfile: true, }, events: { acceptingBusinessRequest: (evt) => supportBot?.onBusinessRequest(evt), newChatItems: (evt) => supportBot?.onNewChatItems(evt), + chatItemUpdated: (evt) => supportBot?.onChatItemUpdated(evt), leftMember: (evt) => supportBot?.onLeftMember(evt), - deletedMemberUser: (evt) => supportBot?.onDeletedMemberUser(evt), - groupDeleted: (evt) => supportBot?.onGroupDeleted(evt), connectedToGroupMember: (evt) => supportBot?.onMemberConnected(evt), newMemberContactReceivedInv: (evt) => supportBot?.onMemberContactReceivedInv(evt), }, @@ -243,11 +249,10 @@ grokChat.on("connectedToGroupMember", (evt) => supportBot?.onGrokMemberConnected | Event | Handler | Action | |-------|---------|--------| -| `acceptingBusinessRequest` | `onBusinessRequest` | `conversations.set(groupInfo.groupId, {type: "welcome"})` | -| `newChatItems` | `onNewChatItems` | For each chatItem: identify sender, extract text, dispatch to routing | -| `leftMember` | `onLeftMember` | If customer left → delete state. If team member left → revert to teamQueue. If Grok left during grokMode → revert to teamQueue. | -| `deletedMemberUser` | `onDeletedMemberUser` | Bot removed from group → delete state | -| `groupDeleted` | `onGroupDeleted` | Delete state, delete grokGroupMap entry | +| `acceptingBusinessRequest` | `onBusinessRequest` | Enable file uploads on business group via `apiUpdateGroupProfile` | +| `newChatItems` | `onNewChatItems` | For each chatItem: identify sender, extract text, dispatch to routing. Also handles `/add` in team group. | +| `chatItemUpdated` | `onChatItemUpdated` | Forward message edits to team group (update forwarded message text) | +| `leftMember` | `onLeftMember` | If customer left → cleanup grok maps. If Grok left → cleanup grok maps. If team member left → add replacement if engaged (`hasTeamMemberSentMessage`), else revert to queue (implicit). | | `connectedToGroupMember` | `onMemberConnected` | Log for debugging | | `newMemberContactReceivedInv` | `onMemberContactReceivedInv` | Log DM contact from team group member (auto-accepted via `/_set accept member contacts`) | @@ -260,64 +265,38 @@ grokChat.on("connectedToGroupMember", (evt) => supportBot?.onGrokMemberConnected We do NOT use `onMessage`/`onCommands` from `bot.run()` — all routing is done in the `newChatItems` event handler for full control over state-dependent command handling. -**Sender identification in `newChatItems`:** +**Message processing in `newChatItems` (stateless):** ```typescript -for (const ci of evt.chatItems) { - const {chatInfo, chatItem} = ci - if (chatInfo.type !== "group") continue - const groupInfo = chatInfo.groupInfo - if (!groupInfo.businessChat) continue // only process business chats - const groupId = groupInfo.groupId - let state = conversations.get(groupId) - if (!state) { - // After restart, re-initialize state for existing business chats - state = {type: "teamQueue", userMessages: []} - conversations.set(groupId, state) - } - - if (chatItem.chatDir.type === "groupSnd") continue // our own message - if (chatItem.chatDir.type !== "groupRcv") continue - const sender = chatItem.chatDir.groupMember - - const isCustomer = sender.memberId === groupInfo.businessChat.customerId - const isTeamMember = (state.type === "teamPending" || state.type === "teamLocked") - && sender.groupMemberId === state.teamMemberGId - const isGrok = state.type === "grokMode" - && state.grokMemberGId === sender.groupMemberId - - if (isGrok) continue // skip Grok messages (we sent them via grokChat) - if (isCustomer) onCustomerMessage(groupId, groupInfo, chatItem, state) - else if (isTeamMember) onTeamMemberMessage(groupId, state) -} +// For each chatItem in evt.chatItems: +// 1. Handle /add command in team group (if groupId === teamGroup.id) +// 2. Skip non-business-chat groups +// 3. Skip groupSnd (own messages) +// 4. Skip non-groupRcv +// 5. Identify sender: +// - Customer: sender.memberId === businessChat.customerId +// - Team member: sender.memberContactId matches teamMembers config +// 6. For non-customer messages: forward team member messages to team group +// 7. For customer messages: derive state from group composition (getGroupComposition) +// - Team member present → handleTeamMode +// - Grok member present → handleGrokMode +// - Neither present → handleNoSpecialMembers (welcome or teamQueue) ``` -**Command detection** — use `util.ciBotCommand()` for `/grok` and `/team`; all other text (including unrecognized `/commands`) is routed as "other text" per spec ("Unrecognized commands: treated as normal messages in the current mode"): -```typescript -function extractText(chatItem: T.ChatItem): string | null { - const text = util.ciContentText(chatItem) - return text?.trim() || null -} - -// In onCustomerMessage: -const cmd = util.ciBotCommand(chatItem) -if (cmd?.keyword === "grok") { /* handle /grok */ } -else if (cmd?.keyword === "team") { /* handle /team */ } -else { /* handle as normal text message, including unrecognized /commands */ } -``` +**Command detection** — use `util.ciBotCommand()` for `/grok` and `/team`; all other text (including unrecognized `/commands`) is routed as "other text" per spec ("Unrecognized commands: treated as normal messages in the current mode"). ## 9. Message Routing Table -`onCustomerMessage(groupId, groupInfo, chatItem, state)`: +Customer message routing (derived state → action): | State | Input | Actions | API Calls | Next State | |-------|-------|---------|-----------|------------| -| `welcome` | any text | Forward to team, send queue reply | `mainChat.apiSendTextMessage([Group, teamGroupId], fwd)` + `mainChat.apiSendTextMessage([Group, groupId], queueMsg)` | `teamQueue` (store msg) | +| `welcome` | any text | Forward to team, send queue reply, send `/add` command | `mainChat.apiSendTextMessage([Group, teamGroupId], fwd)` + `mainChat.apiSendTextMessage([Group, groupId], queueMsg)` + `mainChat.apiSendTextMessage([Group, teamGroupId], addCmd)` | `teamQueue` | | `teamQueue` | `/grok` | Activate Grok (invite, wait join, send accumulated msgs to Grok API, relay response) | `mainChat.apiAddMember(groupId, grokContactId, "member")` + `mainChat.apiSendTextMessage([Group, groupId], grokActivatedMsg)` + wait for join + `grokChat.apiSendTextMessage([Group, grokLocalGId], grokResponse)` | `grokMode` | | `teamQueue` | `/team` | Add team member | `mainChat.apiAddMember(groupId, teamContactId, "member")` + `mainChat.apiSendTextMessage([Group, groupId], teamAddedMsg)` | `teamPending` | -| `teamQueue` | other text | Forward to team, append to userMessages | `mainChat.apiSendTextMessage([Group, teamGroupId], fwd)` | `teamQueue` | +| `teamQueue` | other text | Forward to team | `mainChat.apiSendTextMessage([Group, teamGroupId], fwd)` | `teamQueue` | | `grokMode` | `/grok` | Ignore (already in grok mode) | — | `grokMode` | | `grokMode` | `/team` | Remove Grok, add team member | `mainChat.apiRemoveMembers(groupId, [grokMemberGId])` + `mainChat.apiAddMember(groupId, teamContactId, "member")` + `mainChat.apiSendTextMessage([Group, groupId], teamAddedMsg)` | `teamPending` | -| `grokMode` | other text | Forward to Grok API + forward to team | Grok API call + `grokChat.apiSendTextMessage([Group, grokLocalGId], response)` + `mainChat.apiSendTextMessage([Group, teamGroupId], fwd)` | `grokMode` (append history) | +| `grokMode` | other text | Forward to Grok API + forward to team | Grok API call + `grokChat.apiSendTextMessage([Group, grokLocalGId], response)` + `mainChat.apiSendTextMessage([Group, teamGroupId], fwd)` | `grokMode` | | `teamPending` | `/grok` | Reply "team mode" | `mainChat.apiSendTextMessage([Group, groupId], teamLockedMsg)` | `teamPending` | | `teamPending` | `/team` | Ignore (already team) | — | `teamPending` | | `teamPending` | other text | No forwarding (team sees directly in group) | — | `teamPending` | @@ -330,35 +309,30 @@ else { /* handle as normal text message, including unrecognized /commands */ } ```typescript async forwardToTeam(groupId: number, groupInfo: T.GroupInfo, text: string): Promise { const name = groupInfo.groupProfile.displayName || `group-${groupId}` - const fwd = `[${name} #${groupId}]\n${text}` + const fwd = `${name}:${groupId}: ${text}` await this.mainChat.apiSendTextMessage( [T.ChatType.Group, this.config.teamGroup.id], fwd ) } -async activateTeam(groupId: number, state: ConversationState): Promise { +async activateTeam(groupId: number, grokMember: T.GroupMember | undefined): Promise { // Remove Grok immediately if present (per spec: "When switching to team mode, Grok is removed") - if (state.type === "grokMode") { - try { await this.mainChat.apiRemoveMembers(groupId, [state.grokMemberGId]) } catch {} + if (grokMember) { + try { await this.mainChat.apiRemoveMembers(groupId, [grokMember.groupMemberId]) } catch {} this.cleanupGrokMaps(groupId) } if (this.config.teamMembers.length === 0) { - // No team members configured — revert to teamQueue if was grokMode - if (state.type === "grokMode") this.conversations.set(groupId, {type: "teamQueue", userMessages: []}) await this.sendToGroup(groupId, "No team members are available yet. Please try again later or click /grok.") return } const teamContactId = this.config.teamMembers[0].id const member = await this.addOrFindTeamMember(groupId, teamContactId) // handles groupDuplicateMember - this.conversations.set(groupId, { - type: "teamPending", - teamMemberGId: member.groupMemberId, - }) - await this.mainChat.apiSendTextMessage( - [T.ChatType.Group, groupId], - teamAddedMessage(this.config.timezone) - ) + if (!member) { + await this.sendToGroup(groupId, "Sorry, there was an error adding a team member. Please try again.") + return + } + await this.sendToGroup(groupId, teamAddedMessage(this.config.timezone)) } // Helper: handles groupDuplicateMember error (team member already in group from previous session) @@ -398,7 +372,7 @@ class GrokApiClient { } private systemPrompt(): string { - return `You are a privacy expert and SimpleX Chat evangelist...\n\n${this.docsContext}` + return `You are a support assistant for SimpleX Chat, answering questions inside the app as instant messages on mobile. You are a privacy expert who knows SimpleX Chat apps, network, design choices, and trade-offs.\n\nGuidelines:\n- Be concise. Keep answers short enough to read comfortably on a phone screen.\n- Answer simple questions in 1-2 sentences.\n- For how-to questions, give brief numbered steps — no extra explanation unless needed.\n- For design questions, give the key reason in 1-2 sentences, then trade-offs only if asked.\n- For criticism, briefly acknowledge the concern and explain the design choice.\n- If you don't know something, say so honestly.\n- Do not use markdown formatting...\n- Avoid filler, preambles, and repeating the question back.\n\n${this.docsContext}` } } ``` @@ -407,30 +381,28 @@ class GrokApiClient { 1. `mainChat.apiAddMember(groupId, grokContactId, "member")` → stores `pendingGrokJoins.set(member.memberId, groupId)` 2. Send bot activation message: `mainChat.apiSendTextMessage([Group, groupId], grokActivatedMsg)` 3. Wait for Grok join via `waitForGrokJoin(groupId, 30000)` — Promise-based waiter resolved by `onGrokMemberConnected` (fires on `grokChat.connectedToGroupMember`), times out after 30s -4. Re-check state (user may have sent `/team` concurrently — abort if state changed) -5. Build initial Grok history from `state.userMessages` +4. Re-check group composition (user may have sent `/team` concurrently — abort if team member appeared) +5. Get accumulated customer messages from chat history via `getCustomerMessages(groupId, customerId)` 6. Call Grok API with accumulated messages -7. Re-check state again after API call (another event may have changed it) +7. Re-check group composition again after API call (another event may have changed it) 8. Send response via Grok identity: `grokChat.apiSendTextMessage([Group, grokGroupMap.get(groupId)!], response)` -9. Transition to `grokMode` with history -**Fallback:** If Grok API fails → send error message via `mainChat.apiSendTextMessage`, keep accumulated messages, stay in `teamQueue`. +**Fallback:** If Grok API fails → remove Grok from group, cleanup grok maps, send "Grok temporarily unavailable" message. ## 12. One-Way Gate Logic -Per spec: "When switching to team mode, Grok is removed" and "once the user switches to team mode, /grok command is permanently disabled." Grok removal happens immediately in `activateTeam` (section 10). The one-way gate locks the state after team member engages: +Per spec: "When switching to team mode, Grok is removed" and "once the user switches to team mode, /grok command is permanently disabled." Grok removal happens immediately in `activateTeam` (section 10). -```typescript -async onTeamMemberMessage(groupId: number, state: ConversationState): Promise { - if (state.type !== "teamPending") return - this.conversations.set(groupId, {type: "teamLocked", teamMemberGId: state.teamMemberGId}) -} -``` +**Stateless one-way gate:** The gate is derived from group composition + chat history: +- Team member present → `handleTeamMode` → `/grok` replies "team mode" +- `hasTeamMemberSentMessage()` determines teamPending vs teamLocked: + - If team member has NOT sent a message and leaves → reverts to teamQueue (implicit, no state to update) + - If team member HAS sent a message and leaves → replacement team member added Timeline per spec: -1. User sends `/team` → Grok removed immediately (if present) → team member added → state = `teamPending` -2. `/grok` in `teamPending` → reply "team mode" (Grok already gone, command disabled) -3. Team member sends message → `onTeamMemberMessage` → state = `teamLocked` +1. User sends `/team` → Grok removed immediately (if present) → team member added → teamPending (derived) +2. `/grok` in teamPending → reply "team mode" (Grok already gone, command disabled) +3. Team member sends message → teamLocked (derived via `hasTeamMemberSentMessage`) 4. Any subsequent `/grok` → reply "You are now in team mode. A team member will reply to your message." ## 13. Message Templates (verbatim from spec) @@ -444,7 +416,7 @@ function welcomeMessage(groupLinks: string): string { // After first message (teamQueue) function teamQueueMessage(timezone: string): string { const hours = isWeekend(timezone) ? "48" : "24" - return `Thank you for your message, it is forwarded to the team.\nIt may take a team member up to ${hours} hours to reply.\n\nClick /grok if your question is about SimpleX apps or network, is not sensitive, and you want Grok LLM to answer it right away. *Your previous message and all subsequent messages will be forwarded to Grok* until you click /team. You can ask Grok questions in any language and it will not see your profile name.\n\nWe appreciate if you try Grok: you can learn a lot about SimpleX Chat from it. It is objective, answers the way our team would, and it saves our team time.` + return `Your message is forwarded to the team. A reply may take up to ${hours} hours.\n\nIf your question is about SimpleX Chat, click /grok for an instant AI answer (non-sensitive questions only). Click /team to switch back any time.` } // Grok activated @@ -502,23 +474,23 @@ function isWeekend(timezone: string): boolean { | Scenario | Handling | |----------|----------| | ChatApi init fails | Log error, exit (let process manager restart) | -| Grok API error (HTTP/timeout) | `mainChat.apiSendTextMessage` "Grok temporarily unavailable", revert to `teamQueue` | -| `apiAddMember` fails (Grok) | `mainChat.apiSendTextMessage` error msg, stay in `teamQueue` | -| `apiAddMember` fails (team) | `mainChat.apiSendTextMessage` error msg, stay in current state | +| Grok API error (HTTP/timeout) | Remove Grok from group, cleanup grok maps, send "Grok temporarily unavailable" message | +| Grok API error during conversation | Remove Grok from group, cleanup grok maps, send "Grok temporarily unavailable" message (next message → teamQueue via stateless derivation) | +| `apiAddMember` fails (Grok) | `mainChat.apiSendTextMessage` error msg, stay in teamQueue (stateless) | +| `apiAddMember` fails (team) | `mainChat.apiSendTextMessage` error msg, stay in current state (stateless) | | `apiRemoveMembers` fails | Catch and ignore (member may have left) | -| Grok join timeout (30s) | `mainChat.apiSendTextMessage` "Grok unavailable", stay in `teamQueue` | -| Customer leaves (`leftMember` where member is customer) | Delete conversation state, delete grokGroupMap entry | -| Group deleted | Delete conversation state, delete grokGroupMap entry | -| Grok leaves during `grokMode` | Revert to `teamQueue`, delete grokGroupMap entry | -| Team member leaves | Revert to `teamQueue` (accumulate messages again) | -| Bot removed from group (`deletedMemberUser`) | Delete conversation state | -| Grok contact unavailable (`grokContactId === null`) | `/grok` returns "Grok is temporarily unavailable" message, stay in current state | -| No team members configured (`teamMembers.length === 0`) | `/team` returns "No team members are available yet" message; if was grokMode, revert to teamQueue | +| Grok join timeout (30s) | `mainChat.apiSendTextMessage` "Grok unavailable", stay in teamQueue (stateless) | +| Customer leaves (`leftMember` where member is customer) | Cleanup grokGroupMap entry | +| Grok leaves during grokMode | Cleanup grokGroupMap entry (next message → teamQueue via stateless derivation) | +| Team member leaves (pending, not engaged) | No action needed; next message → teamQueue via stateless derivation | +| Team member leaves (locked, engaged) | Add replacement team member (`addReplacementTeamMember`) | +| Grok contact unavailable (`grokContactId === null`) | `/grok` returns "Grok is temporarily unavailable" message | +| No team members configured (`teamMembers.length === 0`) | `/team` returns "No team members are available yet" message | | Grok agent connection lost | Log error; Grok features unavailable until restart | | `apiSendTextMessage` fails | Log error, continue (message lost but bot stays alive) | | Team member config validation fails | Print descriptive error with actual vs expected name, exit | | `groupDuplicateMember` on `apiAddMember` | Catch error, call `apiListMembers` to find existing member by `memberContactId`, use existing `groupMemberId` | -| Restart: unknown business chat group | Re-initialize conversation state as `teamQueue` (no welcome reply), forward messages to team | +| Restart: any business chat group | State derived from group composition + chat history (no explicit re-initialization needed) | ## 16. Implementation Sequence @@ -529,12 +501,12 @@ function isWeekend(timezone: string): boolean { - Implement `util.ts`: `isWeekend`, logging - **Verify:** Both instances init, print user profiles, Grok contact established, team group created -**Phase 2: State machine + event loop** -- Implement `state.ts`: `ConversationState` union type -- Implement `bot.ts`: `SupportBot` class with `conversations` map -- Handle `acceptingBusinessRequest` → init state as `welcome` -- Handle `newChatItems` → sender identification → customer message dispatch -- Implement welcome → teamQueue transition + team forwarding +**Phase 2: Stateless event processing** +- Implement `state.ts`: `GrokMessage` type +- Implement `bot.ts`: `SupportBot` class with stateless state derivation helpers +- Handle `acceptingBusinessRequest` → enable file uploads on business group +- Handle `newChatItems` → sender identification → derive state from group composition → dispatch +- Implement welcome detection (`isFirstCustomerMessage`) + team forwarding - Implement `messages.ts`: all templates - **Verify:** Customer connects → welcome auto-reply → sends msg → forwarded to team group → queue reply received @@ -547,17 +519,24 @@ function isWeekend(timezone: string): boolean { **Phase 4: Team mode + one-way gate** - Implement `activateTeam`: empty teamMembers guard, remove Grok if present, add team member -- Implement `onTeamMemberMessage`: detect team msg → lock state -- Implement `/grok` rejection in `teamPending` and `teamLocked` +- Implement `handleTeamMode`: `/grok` rejection when team member present +- Implement `hasTeamMemberSentMessage`: teamPending vs teamLocked derivation - **Verify:** Full flow: teamQueue → /grok → grokMode → /team → Grok removed + teamPending → /grok rejected → team msg → teamLocked **Phase 5: Polish + edge cases** -- Handle edge cases: customer leave, group delete, Grok timeout, member leave +- Handle edge cases: customer leave, Grok timeout, member leave - Team group invite link lifecycle: create on startup, delete after 10min or on shutdown - Graceful shutdown (SIGINT/SIGTERM) - Write `docs/simplex-context.md` for Grok prompt injection - End-to-end test all flows +**Phase 6: Extra features (beyond MVP)** +- Edit forwarding: `chatItemUpdated` → forward edits to team group (update forwarded message) +- Team member reply forwarding: team member messages in business chats → forwarded to team group +- `/add` command: team members send `/add groupId:name` in team group → bot adds them to the customer group +- Grok group map persistence: `grokGroupMap` persisted to state file → survives restarts +- Profile images: bot and Grok agent have profile images set on startup + ## 17. Self-Review Requirement **Mandatory for all implementation subagents:** @@ -578,7 +557,7 @@ Any edit restarts the review cycle. Batch changes within a round. **Startup** (all auto-resolution happens automatically): ```bash -cd apps/simplex-chat-support-bot +cd apps/simplex-support-bot npm install GROK_API_KEY=xai-... npx ts-node src/index.ts \ --team-group SupportTeam \ @@ -604,7 +583,7 @@ GROK_API_KEY=xai-... npx ts-node src/index.ts \ **Test scenarios:** 1. Connect from SimpleX client to bot's business address → verify welcome message -2. Send question → verify forwarded to team group with `[CustomerName #groupId]` prefix, queue reply received +2. Send question → verify forwarded to team group with `CustomerName:groupId: ` prefix, queue reply received 3. Send `/grok` → verify Grok joins as separate participant, responses appear from "Grok AI" profile 4. Send text in grokMode → verify Grok response + forwarded to team 5. Send `/team` → verify Grok removed, team member added, team added message diff --git a/apps/simplex-support-bot/plans/20260207-support-bot.md b/apps/simplex-support-bot/plans/20260207-support-bot.md index 8790155921..9366d0a4c7 100644 --- a/apps/simplex-support-bot/plans/20260207-support-bot.md +++ b/apps/simplex-support-bot/plans/20260207-support-bot.md @@ -20,12 +20,9 @@ No mention of Grok, no choices. User simply types their question. Messages at th ## Step 2 — After user sends first message All messages are forwarded to the team group. Bot replies: -> Thank you for your message, it is forwarded to the team. -> It may take a team member up to 24 hours to reply. +> Your message is forwarded to the team. A reply may take up to 24 hours. > -> Click /grok if your question is about SimpleX apps or network, is not sensitive, and you want Grok LLM to answer it right away. **Your previous message and all subsequent messages will be forwarded to Grok** until you click /team. You can ask Grok questions in any language and it will not see your profile name. -> -> We appreciate if you try Grok: you can learn a lot about SimpleX Chat from it. It is objective, answers the way our team would, and it saves our team time. +> If your question is about SimpleX Chat, click /grok for an instant AI answer (non-sensitive questions only). Click /team to switch back any time. On weekends, the bot says "48 hours" instead of "24 hours". @@ -37,7 +34,7 @@ Bot replies: Grok must be added as a separate participant to the chat, so that user can differentiate bot messages from Grok messages. When switching to team mode, Grok is removed. -Grok is prompted as a privacy expert and SimpleX Chat evangelist who knows everything about SimpleX Chat apps, network, design choices, and trade-offs. It answers honestly — for every criticism it explains why the team made that design choice. Relevant documentation pages and links must be injected into the context by the bot. +Grok is prompted as a privacy expert and support assistant who knows SimpleX Chat apps, network, design choices, and trade-offs. It gives concise, mobile-friendly answers — brief numbered steps for how-to questions, 1-2 sentence explanations for design questions. For criticism, it briefly acknowledges the concern and explains the design choice. It avoids filler and markdown formatting. Relevant documentation pages and links must be injected into the context by the bot. ## Step 4 — `/team` (Team mode, one-way gate) @@ -55,5 +52,6 @@ This gate should trigger only after team joins and member sends message to team. |---------|-------------|--------| | `/grok` | Team Queue (before escalation only) | Enter Grok mode | | `/team` | Grok mode or Team Queue | Add team member, permanently enter Team mode | +| `/add` | Team group only | Team member sends `/add groupId:name` → bot adds them to the customer group | **Unrecognized commands:** treated as normal messages in the current mode. diff --git a/apps/simplex-support-bot/plans/20260209-moderation-bot.md b/apps/simplex-support-bot/plans/20260209-moderation-bot.md deleted file mode 100644 index 3e55a8900d..0000000000 --- a/apps/simplex-support-bot/plans/20260209-moderation-bot.md +++ /dev/null @@ -1,34 +0,0 @@ -A SimpleX Chat bot that monitors public groups, summarizes conversations using - Grok LLM, moderates content, and forwards important messages to a private - staff group. - - Core Features - - 1. Message Summarization - - Periodically summarizes public group messages using Grok API - - Posts summaries to the group on a configurable schedule (e.g. daily/hourly) - - Summaries capture key topics, decisions, and action items - - 2. Moderation - - Detects spam, abuse, and policy violations using Grok - - Configurable actions per severity: flag-only, auto-delete, or remove member - - All moderation events are forwarded to the staff group for visibility - - 3. Important Message Forwarding - - Grok classifies messages by importance (urgency, issues, support requests) - - Forwards important messages to a designated private staff group - - Includes context: sender, group, timestamp, and reason for flagging - - Configuration - - - GROK_API_KEY — Grok API credentials - - PUBLIC_GROUPS — list of monitored public groups - - STAFF_GROUP — private group for forwarded alerts - - SUMMARY_INTERVAL — how often summaries are generated - - MODERATION_RULES — content policy and action thresholds - - Non-Goals - - - No interactive Q&A or general chatbot behavior in groups - - No direct user communication from the bot (all escalation goes to staff - group) diff --git a/apps/simplex-support-bot/src/bot.ts b/apps/simplex-support-bot/src/bot.ts index bf510893f7..8e03257c16 100644 --- a/apps/simplex-support-bot/src/bot.ts +++ b/apps/simplex-support-bot/src/bot.ts @@ -1,18 +1,35 @@ import {api, util} from "simplex-chat" import {T, CEvt} from "@simplex-chat/types" import {Config} from "./config.js" -import {ConversationState, GrokMessage} from "./state.js" +import {GrokMessage} from "./state.js" import {GrokApiClient} from "./grok.js" import {teamQueueMessage, grokActivatedMessage, teamAddedMessage, teamLockedMessage} from "./messages.js" import {log, logError} from "./util.js" +interface GroupComposition { + grokMember: T.GroupMember | undefined + teamMember: T.GroupMember | undefined +} + +function isActiveMember(m: T.GroupMember): boolean { + return m.memberStatus === T.GroupMemberStatus.Connected + || m.memberStatus === T.GroupMemberStatus.Complete + || m.memberStatus === T.GroupMemberStatus.Announced +} + export class SupportBot { - private conversations = new Map() + // Grok group mapping (persisted via onGrokMapChanged callback) private pendingGrokJoins = new Map() // memberId → mainGroupId private grokGroupMap = new Map() // mainGroupId → grokLocalGroupId private reverseGrokMap = new Map() // grokLocalGroupId → mainGroupId private grokJoinResolvers = new Map void>() // mainGroupId → resolve fn + // Forwarded message tracking: "groupId:itemId" → {teamItemId, prefix} + private forwardedItems = new Map() + + // Callback to persist grokGroupMap changes + onGrokMapChanged: ((map: ReadonlyMap) => void) | null = null + constructor( private mainChat: api.ChatApi, private grokChat: api.ChatApi, @@ -20,12 +37,96 @@ export class SupportBot { private config: Config, ) {} + // Restore grokGroupMap from persisted state (call after construction, before events) + restoreGrokGroupMap(entries: [number, number][]): void { + for (const [mainGroupId, grokLocalGroupId] of entries) { + this.grokGroupMap.set(mainGroupId, grokLocalGroupId) + this.reverseGrokMap.set(grokLocalGroupId, mainGroupId) + } + log(`Restored Grok group map: ${entries.length} entries`) + } + + // --- State Derivation Helpers --- + + private async getGroupComposition(groupId: number): Promise { + const members = await this.mainChat.apiListMembers(groupId) + return { + grokMember: members.find(m => + m.memberContactId === this.config.grokContactId && isActiveMember(m)), + teamMember: members.find(m => + this.config.teamMembers.some(tm => tm.id === m.memberContactId) && isActiveMember(m)), + } + } + + private async isFirstCustomerMessage(groupId: number): Promise { + const chat = await this.apiGetChat(groupId, 20) + // The platform sends auto-messages on connect (welcome, commands, etc.) as groupSnd. + // The bot's teamQueueMessage (sent after first customer message) uniquely contains + // "forwarded to the team" — none of the platform auto-messages do. + return !chat.chatItems.some((ci: T.ChatItem) => + ci.chatDir.type === "groupSnd" + && util.ciContentText(ci)?.includes("forwarded to the team")) + } + + private async getGrokHistory(groupId: number, grokMember: T.GroupMember, customerId: string): Promise { + const chat = await this.apiGetChat(groupId, 100) + const history: GrokMessage[] = [] + for (const ci of chat.chatItems) { + if (ci.chatDir.type !== "groupRcv") continue + const text = util.ciContentText(ci)?.trim() + if (!text) continue + if (ci.chatDir.groupMember.groupMemberId === grokMember.groupMemberId) { + history.push({role: "assistant", content: text}) + } else if (ci.chatDir.groupMember.memberId === customerId) { + history.push({role: "user", content: text}) + } + } + return history + } + + private async getCustomerMessages(groupId: number, customerId: string): Promise { + const chat = await this.apiGetChat(groupId, 100) + return chat.chatItems + .filter((ci: T.ChatItem) => + ci.chatDir.type === "groupRcv" + && ci.chatDir.groupMember.memberId === customerId + && !util.ciBotCommand(ci)) + .map((ci: T.ChatItem) => util.ciContentText(ci)?.trim()) + .filter((t): t is string => !!t) + } + + private async hasTeamMemberSentMessage(groupId: number, teamMember: T.GroupMember): Promise { + const chat = await this.apiGetChat(groupId, 50) + return chat.chatItems.some((ci: T.ChatItem) => + ci.chatDir.type === "groupRcv" + && ci.chatDir.groupMember.groupMemberId === teamMember.groupMemberId) + } + + // Interim apiGetChat wrapper using sendChatCmd directly + private async apiGetChat(groupId: number, count: number): Promise { + const r = await this.mainChat.sendChatCmd(`/_get chat #${groupId} count=${count}`) as any + if (r.type === "apiChat") return r.chat + throw new Error(`error getting chat for group ${groupId}: ${r.type}`) + } + // --- Event Handlers (main bot) --- - onBusinessRequest(evt: CEvt.AcceptingBusinessRequest): void { + async onBusinessRequest(evt: CEvt.AcceptingBusinessRequest): Promise { const groupId = evt.groupInfo.groupId - log(`New business request: groupId=${groupId}`) - this.conversations.set(groupId, {type: "welcome"}) + try { + const profile = evt.groupInfo.groupProfile + await this.mainChat.apiUpdateGroupProfile(groupId, { + displayName: profile.displayName, + fullName: profile.fullName, + groupPreferences: { + ...profile.groupPreferences, + files: {enable: T.GroupFeatureEnabled.On}, + }, + }) + log(`Enabled media uploads for business group ${groupId}`) + } catch (err) { + logError(`Failed to enable media uploads for group ${groupId}`, err) + } } async onNewChatItems(evt: CEvt.NewChatItems): Promise { @@ -40,9 +141,6 @@ export class SupportBot { async onLeftMember(evt: CEvt.LeftMember): Promise { const groupId = evt.groupInfo.groupId - const state = this.conversations.get(groupId) - if (!state) return - const member = evt.member const bc = evt.groupInfo.businessChat if (!bc) return @@ -50,46 +148,59 @@ export class SupportBot { // Customer left if (member.memberId === bc.customerId) { log(`Customer left group ${groupId}, cleaning up`) - this.conversations.delete(groupId) this.cleanupGrokMaps(groupId) return } - // Team member left — teamPending: gate not yet triggered, revert to teamQueue - if (state.type === "teamPending" && member.groupMemberId === state.teamMemberGId) { - log(`Team member left group ${groupId} (teamPending), reverting to teamQueue`) - this.conversations.set(groupId, {type: "teamQueue", userMessages: []}) - return - } - - // Team member left — teamLocked: one-way gate triggered, stay in team mode (add another member) - if (state.type === "teamLocked" && member.groupMemberId === state.teamMemberGId) { - log(`Team member left group ${groupId} (teamLocked), adding replacement team member`) - await this.addReplacementTeamMember(groupId) - return - } - - // Grok left during grokMode - if (state.type === "grokMode" && member.groupMemberId === state.grokMemberGId) { - log(`Grok left group ${groupId} during grokMode, reverting to teamQueue`) - this.conversations.set(groupId, {type: "teamQueue", userMessages: []}) + // Grok left + if (member.memberContactId === this.config.grokContactId) { + log(`Grok left group ${groupId}`) this.cleanupGrokMaps(groupId) return } + + // Team member left — check if they had engaged (teamLocked vs teamPending) + if (this.config.teamMembers.some(tm => tm.id === member.memberContactId)) { + const engaged = await this.hasTeamMemberSentMessage(groupId, member) + if (engaged) { + log(`Engaged team member left group ${groupId}, adding replacement`) + await this.addReplacementTeamMember(groupId) + } else { + log(`Pending team member left group ${groupId}, reverting to queue`) + // No state to revert — member is already gone from DB + } + } } - onDeletedMemberUser(evt: CEvt.DeletedMemberUser): void { - const groupId = evt.groupInfo.groupId - log(`Bot removed from group ${groupId}`) - this.conversations.delete(groupId) - this.cleanupGrokMaps(groupId) - } + async onChatItemUpdated(evt: CEvt.ChatItemUpdated): Promise { + const {chatInfo, chatItem} = evt.chatItem + if (chatInfo.type !== "group") return + const groupInfo = chatInfo.groupInfo + if (!groupInfo.businessChat) return + const groupId = groupInfo.groupId - onGroupDeleted(evt: CEvt.GroupDeleted): void { - const groupId = evt.groupInfo.groupId - log(`Group ${groupId} deleted`) - this.conversations.delete(groupId) - this.cleanupGrokMaps(groupId) + if (chatItem.chatDir.type !== "groupRcv") return + + const itemId = chatItem.meta.itemId + const key = `${groupId}:${itemId}` + const entry = this.forwardedItems.get(key) + if (!entry) return + + const text = util.ciContentText(chatItem)?.trim() + if (!text) return + + const fwd = `${entry.prefix}${text}` + try { + await this.mainChat.apiUpdateChatItem( + T.ChatType.Group, + this.config.teamGroup.id, + entry.teamItemId, + {type: "text", text: fwd}, + false, + ) + } catch (err) { + logError(`Failed to forward edit to team for group ${groupId}, item ${itemId}`, err) + } } onMemberConnected(evt: CEvt.ConnectedToGroupMember): void { @@ -124,9 +235,9 @@ export class SupportBot { } // Join request sent — set maps, but don't resolve waiter yet. - // The waiter resolves when grokChat fires connectedToGroupMember (see onGrokMemberConnected). this.grokGroupMap.set(mainGroupId, evt.groupInfo.groupId) this.reverseGrokMap.set(evt.groupInfo.groupId, mainGroupId) + this.onGrokMapChanged?.(this.grokGroupMap) } onGrokMemberConnected(evt: CEvt.ConnectedToGroupMember): void { @@ -147,108 +258,119 @@ export class SupportBot { const {chatInfo, chatItem} = ci if (chatInfo.type !== "group") return const groupInfo = chatInfo.groupInfo - if (!groupInfo.businessChat) return const groupId = groupInfo.groupId - let state = this.conversations.get(groupId) - if (!state) { - // After restart, re-initialize state for existing business chats - state = {type: "teamQueue", userMessages: []} - this.conversations.set(groupId, state) - log(`Re-initialized conversation state for group ${groupId} after restart`) + + // Handle /add command in team group + if (groupId === this.config.teamGroup.id) { + await this.processTeamGroupMessage(chatItem) + return } + if (!groupInfo.businessChat) return + if (chatItem.chatDir.type === "groupSnd") return if (chatItem.chatDir.type !== "groupRcv") return const sender = chatItem.chatDir.groupMember const isCustomer = sender.memberId === groupInfo.businessChat.customerId - const isTeamMember = (state.type === "teamPending" || state.type === "teamLocked") - && sender.groupMemberId === state.teamMemberGId - const isGrok = state.type === "grokMode" - && state.grokMemberGId === sender.groupMemberId - if (isGrok) return - if (isCustomer) await this.onCustomerMessage(groupId, groupInfo, chatItem, state) - else if (isTeamMember) await this.onTeamMemberMessage(groupId, state) + if (!isCustomer) { + // Team member message → forward to team group + if (this.config.teamMembers.some(tm => tm.id === sender.memberContactId)) { + const text = util.ciContentText(chatItem)?.trim() + if (text) { + const customerName = groupInfo.groupProfile.displayName || `group-${groupId}` + const teamMemberName = sender.memberProfile.displayName + const contactId = sender.memberContactId + const itemId = chatItem.meta?.itemId + const prefix = `${teamMemberName}:${contactId} > ${customerName}:${groupId}: ` + await this.forwardToTeam(groupId, prefix, text, itemId) + } + } + return + } + + // Customer message — derive state from group composition + const {grokMember, teamMember} = await this.getGroupComposition(groupId) + + if (teamMember) { + await this.handleTeamMode(groupId, chatItem) + } else if (grokMember) { + await this.handleGrokMode(groupId, groupInfo, chatItem, grokMember) + } else { + await this.handleNoSpecialMembers(groupId, groupInfo, chatItem) + } } - private async onCustomerMessage( + // Customer message when a team member is present (teamPending or teamLocked) + private async handleTeamMode(groupId: number, chatItem: T.ChatItem): Promise { + const cmd = util.ciBotCommand(chatItem) + if (cmd?.keyword === "grok") { + await this.sendToGroup(groupId, teamLockedMessage) + } + // /team → ignore (already team). Other text → no forwarding (team sees directly). + } + + // Customer message when Grok is present + private async handleGrokMode( groupId: number, groupInfo: T.GroupInfo, chatItem: T.ChatItem, - state: ConversationState, + grokMember: T.GroupMember, ): Promise { const cmd = util.ciBotCommand(chatItem) const text = util.ciContentText(chatItem)?.trim() || null - switch (state.type) { - case "welcome": { - if (!text) return - await this.forwardToTeam(groupId, groupInfo, text) - await this.sendToGroup(groupId, teamQueueMessage(this.config.timezone)) - this.conversations.set(groupId, {type: "teamQueue", userMessages: [text]}) - break - } - - case "teamQueue": { - if (cmd?.keyword === "grok") { - await this.activateGrok(groupId, state) - return - } - if (cmd?.keyword === "team") { - await this.activateTeam(groupId, state) - return - } - if (!text) return - await this.forwardToTeam(groupId, groupInfo, text) - state.userMessages.push(text) - break - } - - case "grokMode": { - if (cmd?.keyword === "grok") return - if (cmd?.keyword === "team") { - await this.activateTeam(groupId, state) - return - } - if (!text) return - await this.forwardToTeam(groupId, groupInfo, text) - await this.forwardToGrok(groupId, text, state) - break - } - - case "teamPending": { - if (cmd?.keyword === "grok") { - await this.sendToGroup(groupId, teamLockedMessage) - return - } - // /team → ignore (already team). Other text → no forwarding (team sees directly). - break - } - - case "teamLocked": { - if (cmd?.keyword === "grok") { - await this.sendToGroup(groupId, teamLockedMessage) - return - } - // No action — team sees directly - break - } + if (cmd?.keyword === "grok") return // already in grok mode + if (cmd?.keyword === "team") { + await this.activateTeam(groupId, grokMember) + return } + if (!text) return + const prefix = this.customerForwardPrefix(groupId, groupInfo) + await this.forwardToTeam(groupId, prefix, text, chatItem.meta?.itemId) + await this.forwardToGrok(groupId, groupInfo, text, grokMember) } - private async onTeamMemberMessage(groupId: number, state: ConversationState): Promise { - if (state.type !== "teamPending") return - log(`Team member engaged in group ${groupId}, locking to teamLocked`) - this.conversations.set(groupId, {type: "teamLocked", teamMemberGId: state.teamMemberGId}) + // Customer message when neither Grok nor team is present (welcome or teamQueue) + private async handleNoSpecialMembers( + groupId: number, + groupInfo: T.GroupInfo, + chatItem: T.ChatItem, + ): Promise { + const cmd = util.ciBotCommand(chatItem) + const text = util.ciContentText(chatItem)?.trim() || null + + // Check if this is the first customer message (welcome state) + const firstMessage = await this.isFirstCustomerMessage(groupId) + + if (firstMessage) { + // Welcome state — first message transitions to teamQueue + if (!text) return + const prefix = this.customerForwardPrefix(groupId, groupInfo) + await this.forwardToTeam(groupId, prefix, text, chatItem.meta?.itemId) + await this.sendToGroup(groupId, teamQueueMessage(this.config.timezone)) + await this.sendAddCommand(groupId, groupInfo) + return + } + + // teamQueue state + if (cmd?.keyword === "grok") { + await this.activateGrok(groupId, groupInfo) + return + } + if (cmd?.keyword === "team") { + await this.activateTeam(groupId, undefined) + return + } + if (!text) return + const prefix = this.customerForwardPrefix(groupId, groupInfo) + await this.forwardToTeam(groupId, prefix, text, chatItem.meta?.itemId) } // --- Grok Activation --- - private async activateGrok( - groupId: number, - state: {type: "teamQueue"; userMessages: string[]}, - ): Promise { + private async activateGrok(groupId: number, groupInfo: T.GroupInfo): Promise { if (this.config.grokContactId === null) { await this.sendToGroup(groupId, "Grok is temporarily unavailable. Please try again or click /team for a team member.") return @@ -274,10 +396,10 @@ export class SupportBot { return } - // Verify state hasn't changed while awaiting (e.g., user sent /team concurrently) - const currentState = this.conversations.get(groupId) - if (!currentState || currentState.type !== "teamQueue") { - log(`State changed during Grok activation for group ${groupId} (now ${currentState?.type}), aborting`) + // Verify group composition hasn't changed while awaiting (e.g., user sent /team concurrently) + const {teamMember} = await this.getGroupComposition(groupId) + if (teamMember) { + log(`Team member appeared during Grok activation for group ${groupId}, aborting`) try { await this.mainChat.apiRemoveMembers(groupId, [member.groupMemberId]) } catch { @@ -287,15 +409,17 @@ export class SupportBot { return } - // Grok joined — call API with accumulated messages + // Grok joined — call API with accumulated customer messages from chat history try { - const initialUserMsg = state.userMessages.join("\n") + const customerId = groupInfo.businessChat!.customerId + const customerMessages = await this.getCustomerMessages(groupId, customerId) + const initialUserMsg = customerMessages.join("\n") const response = await this.grokApi.chat([], initialUserMsg) - // Re-check state after async API call — another event may have changed it - const postApiState = this.conversations.get(groupId) - if (!postApiState || postApiState.type !== "teamQueue") { - log(`State changed during Grok API call for group ${groupId} (now ${postApiState?.type}), aborting`) + // Re-check composition after async API call + const postApi = await this.getGroupComposition(groupId) + if (postApi.teamMember) { + log(`Team member appeared during Grok API call for group ${groupId}, aborting`) try { await this.mainChat.apiRemoveMembers(groupId, [member.groupMemberId]) } catch { @@ -305,26 +429,14 @@ export class SupportBot { return } - const history: GrokMessage[] = [ - {role: "user", content: initialUserMsg}, - {role: "assistant", content: response}, - ] - const grokLocalGId = this.grokGroupMap.get(groupId) if (grokLocalGId === undefined) { log(`Grok map entry missing after join for group ${groupId}`) return } await this.grokChat.apiSendTextMessage([T.ChatType.Group, grokLocalGId], response) - - this.conversations.set(groupId, { - type: "grokMode", - grokMemberGId: member.groupMemberId, - history, - }) } catch (err) { logError(`Grok API/send failed for group ${groupId}`, err) - // Remove Grok since activation failed after join try { await this.mainChat.apiRemoveMembers(groupId, [member.groupMemberId]) } catch { @@ -332,7 +444,6 @@ export class SupportBot { } this.cleanupGrokMaps(groupId) await this.sendToGroup(groupId, "Grok is temporarily unavailable. Please try again or click /team for a team member.") - // Stay in teamQueue } } @@ -340,13 +451,14 @@ export class SupportBot { private async forwardToGrok( groupId: number, + groupInfo: T.GroupInfo, text: string, - state: {type: "grokMode"; grokMemberGId: number; history: GrokMessage[]}, + grokMember: T.GroupMember, ): Promise { try { - const response = await this.grokApi.chat(state.history, text) - state.history.push({role: "user", content: text}) - state.history.push({role: "assistant", content: response}) + const customerId = groupInfo.businessChat!.customerId + const history = await this.getGrokHistory(groupId, grokMember, customerId) + const response = await this.grokApi.chat(history, text) const grokLocalGId = this.grokGroupMap.get(groupId) if (grokLocalGId !== undefined) { @@ -354,39 +466,39 @@ export class SupportBot { } } catch (err) { logError(`Grok API error for group ${groupId}`, err) - // Per plan: revert to teamQueue on Grok API failure — remove Grok, clean up try { - await this.mainChat.apiRemoveMembers(groupId, [state.grokMemberGId]) + await this.mainChat.apiRemoveMembers(groupId, [grokMember.groupMemberId]) } catch { // ignore — may have already left } this.cleanupGrokMaps(groupId) - this.conversations.set(groupId, {type: "teamQueue", userMessages: []}) await this.sendToGroup(groupId, "Grok is temporarily unavailable. Please try again or click /team for a team member.") } } // --- Team Actions --- - private async forwardToTeam(groupId: number, groupInfo: T.GroupInfo, text: string): Promise { - const name = groupInfo.groupProfile.displayName || `group-${groupId}` - const fwd = `[${name} #${groupId}]\n${text}` + private async forwardToTeam(groupId: number, prefix: string, text: string, sourceItemId?: number): Promise { + const fwd = `${prefix}${text}` try { - await this.mainChat.apiSendTextMessage( + const result = await this.mainChat.apiSendTextMessage( [T.ChatType.Group, this.config.teamGroup.id], fwd, ) + if (sourceItemId !== undefined && result && result[0]) { + const teamItemId = result[0].chatItem.meta.itemId + this.forwardedItems.set(`${groupId}:${sourceItemId}`, {teamItemId, prefix}) + } } catch (err) { logError(`Failed to forward to team for group ${groupId}`, err) } } - private async activateTeam(groupId: number, state: ConversationState): Promise { - // Remove Grok immediately if present (per spec: "When switching to team mode, Grok is removed") - const wasGrokMode = state.type === "grokMode" - if (wasGrokMode) { + private async activateTeam(groupId: number, grokMember: T.GroupMember | undefined): Promise { + // Remove Grok immediately if present + if (grokMember) { try { - await this.mainChat.apiRemoveMembers(groupId, [state.grokMemberGId]) + await this.mainChat.apiRemoveMembers(groupId, [grokMember.groupMemberId]) } catch { // ignore — may have already left } @@ -394,9 +506,6 @@ export class SupportBot { } if (this.config.teamMembers.length === 0) { logError(`No team members configured, cannot add team member to group ${groupId}`, new Error("no team members")) - if (wasGrokMode) { - this.conversations.set(groupId, {type: "teamQueue", userMessages: []}) - } await this.sendToGroup(groupId, "No team members are available yet. Please try again later or click /grok.") return } @@ -404,41 +513,58 @@ export class SupportBot { const teamContactId = this.config.teamMembers[0].id const member = await this.addOrFindTeamMember(groupId, teamContactId) if (!member) { - if (wasGrokMode) { - this.conversations.set(groupId, {type: "teamQueue", userMessages: []}) - } await this.sendToGroup(groupId, "Sorry, there was an error adding a team member. Please try again.") return } - this.conversations.set(groupId, { - type: "teamPending", - teamMemberGId: member.groupMemberId, - }) await this.sendToGroup(groupId, teamAddedMessage(this.config.timezone)) } catch (err) { logError(`Failed to add team member to group ${groupId}`, err) - // If Grok was removed, state is stale (grokMode but Grok gone) — revert to teamQueue - if (wasGrokMode) { - this.conversations.set(groupId, {type: "teamQueue", userMessages: []}) - } await this.sendToGroup(groupId, "Sorry, there was an error adding a team member. Please try again.") } } + private customerForwardPrefix(groupId: number, groupInfo: T.GroupInfo): string { + const name = groupInfo.groupProfile.displayName || `group-${groupId}` + return `${name}:${groupId}: ` + } + + // --- Team Group Commands --- + + private async processTeamGroupMessage(chatItem: T.ChatItem): Promise { + if (chatItem.chatDir.type !== "groupRcv") return + const text = util.ciContentText(chatItem)?.trim() + if (!text) return + const match = text.match(/^\/add\s+(\d+):/) + if (!match) return + + const targetGroupId = parseInt(match[1]) + const senderContactId = chatItem.chatDir.groupMember.memberContactId + if (!senderContactId) return + + try { + await this.addOrFindTeamMember(targetGroupId, senderContactId) + log(`Team member ${senderContactId} added to group ${targetGroupId} via /add command`) + } catch (err) { + logError(`Failed to add team member to group ${targetGroupId} via /add`, err) + } + } + + private async sendAddCommand(groupId: number, groupInfo: T.GroupInfo): Promise { + const name = groupInfo.groupProfile.displayName || `group-${groupId}` + const formatted = name.includes(" ") ? `'${name}'` : name + const cmd = `/add ${groupId}:${formatted}` + await this.sendToGroup(this.config.teamGroup.id, cmd) + } + // --- Helpers --- private async addReplacementTeamMember(groupId: number): Promise { if (this.config.teamMembers.length === 0) return try { const teamContactId = this.config.teamMembers[0].id - const member = await this.addOrFindTeamMember(groupId, teamContactId) - if (member) { - this.conversations.set(groupId, {type: "teamLocked", teamMemberGId: member.groupMemberId}) - } + await this.addOrFindTeamMember(groupId, teamContactId) } catch (err) { logError(`Failed to add replacement team member to group ${groupId}`, err) - // Stay in teamLocked with stale teamMemberGId — one-way gate must hold - // Team will see the message in team group and can join manually } } @@ -447,7 +573,6 @@ export class SupportBot { return await this.mainChat.apiAddMember(groupId, teamContactId, T.GroupMemberRole.Member) } catch (err: any) { if (err?.chatError?.errorType?.type === "groupDuplicateMember") { - // Team member already in group (e.g., from previous session) — find existing member log(`Team member already in group ${groupId}, looking up existing member`) const members = await this.mainChat.apiListMembers(groupId) const existing = members.find(m => m.memberContactId === teamContactId) @@ -486,9 +611,9 @@ export class SupportBot { private cleanupGrokMaps(groupId: number): void { const grokLocalGId = this.grokGroupMap.get(groupId) + if (grokLocalGId === undefined) return this.grokGroupMap.delete(groupId) - if (grokLocalGId !== undefined) { - this.reverseGrokMap.delete(grokLocalGId) - } + this.reverseGrokMap.delete(grokLocalGId) + this.onGrokMapChanged?.(this.grokGroupMap) } } diff --git a/apps/simplex-support-bot/src/grok.ts b/apps/simplex-support-bot/src/grok.ts index 97e8922e98..45347adceb 100644 --- a/apps/simplex-support-bot/src/grok.ts +++ b/apps/simplex-support-bot/src/grok.ts @@ -40,6 +40,6 @@ export class GrokApiClient { } private systemPrompt(): string { - return `You are a privacy expert and SimpleX Chat evangelist. You know everything about SimpleX Chat apps, network, design choices, and trade-offs. Be helpful, accurate, and concise. If you don't know something, say so honestly rather than guessing. For every criticism, explain why the team made that design choice.\n\n${this.docsContext}` + return `You are a support assistant for SimpleX Chat, answering questions inside the app as instant messages on mobile. You are a privacy expert who knows SimpleX Chat apps, network, design choices, and trade-offs.\n\nGuidelines:\n- Be concise. Keep answers short enough to read comfortably on a phone screen.\n- Answer simple questions in 1-2 sentences.\n- For how-to questions, give brief numbered steps — no extra explanation unless needed.\n- For design questions, give the key reason in 1-2 sentences, then trade-offs only if asked.\n- For criticism, briefly acknowledge the concern and explain the design choice.\n- If you don't know something, say so honestly.\n- Do not use markdown formatting — no bold, italic, headers, or code blocks.\n- Avoid filler, preambles, and repeating the question back.\n\n${this.docsContext}` } } diff --git a/apps/simplex-support-bot/src/index.ts b/apps/simplex-support-bot/src/index.ts index ac437b6895..efec290bec 100644 --- a/apps/simplex-support-bot/src/index.ts +++ b/apps/simplex-support-bot/src/index.ts @@ -6,11 +6,13 @@ import {parseConfig} from "./config.js" import {SupportBot} from "./bot.js" import {GrokApiClient} from "./grok.js" import {welcomeMessage} from "./messages.js" +import {resolveDisplayNameConflict} from "./startup.js" import {log, logError} from "./util.js" interface BotState { teamGroupId?: number grokContactId?: number + grokGroupMap?: {[mainGroupId: string]: number} } function readState(path: string): BotState { @@ -35,16 +37,32 @@ async function main(): Promise { const stateFilePath = `${config.dbPrefix}_state.json` const state = readState(stateFilePath) + // Profile image for the main support bot (SimpleX app icon, light variant) + const supportImage = "data:image/jpg;base64,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" + // --- Init Grok agent (direct ChatApi) --- log("Initializing Grok agent...") const grokChat = await api.ChatApi.init(config.grokDbPrefix) + const grokImage = "data:image/jpg;base64,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" let grokUser = await grokChat.apiGetActiveUser() if (!grokUser) { log("No Grok user, creating...") - grokUser = await grokChat.apiCreateActiveUser({displayName: "Grok AI", fullName: ""}) + grokUser = await grokChat.apiCreateActiveUser({displayName: "Grok AI", fullName: "", image: grokImage}) } log(`Grok user: ${grokUser.profile.displayName}`) await grokChat.startChat() + if (grokUser.profile.image !== grokImage) { + try { + log("Updating Grok profile image...") + await grokChat.apiUpdateProfile(grokUser.userId, { + displayName: grokUser.profile.displayName, + fullName: grokUser.profile.fullName, + image: grokImage, + }) + } catch (err) { + logError("Failed to update Grok profile image", err) + } + } // SupportBot forward-reference: assigned after bot.run returns. // Events use optional chaining so any events during init are safely skipped. @@ -53,16 +71,16 @@ async function main(): Promise { const events: api.EventSubscribers = { acceptingBusinessRequest: (evt) => supportBot?.onBusinessRequest(evt), newChatItems: (evt) => supportBot?.onNewChatItems(evt), + chatItemUpdated: (evt) => supportBot?.onChatItemUpdated(evt), leftMember: (evt) => supportBot?.onLeftMember(evt), - deletedMemberUser: (evt) => supportBot?.onDeletedMemberUser(evt), - groupDeleted: (evt) => supportBot?.onGroupDeleted(evt), connectedToGroupMember: (evt) => supportBot?.onMemberConnected(evt), newMemberContactReceivedInv: (evt) => supportBot?.onMemberContactReceivedInv(evt), } log("Initializing main bot...") + resolveDisplayNameConflict(config.dbPrefix, "Ask SimpleX Team") const [mainChat, mainUser, _mainAddress] = await bot.run({ - profile: {displayName: "SimpleX Support", fullName: ""}, + profile: {displayName: "Ask SimpleX Team", fullName: "", shortDescr: "Send questions about SimpleX Chat app and your suggestions", image: supportImage}, dbOpts: {dbFilePrefix: config.dbPrefix}, options: { addressSettings: { @@ -73,12 +91,25 @@ async function main(): Promise { commands: [ {type: "command", keyword: "grok", label: "Ask Grok AI"}, {type: "command", keyword: "team", label: "Switch to team"}, + {type: "command", keyword: "add", label: "Join group"}, ], useBotProfile: true, }, events, }) log(`Main bot user: ${mainUser.profile.displayName}`) + if (mainUser.profile.image !== supportImage) { + try { + log("Updating support bot profile image...") + await mainChat.apiUpdateProfile(mainUser.userId, { + displayName: mainUser.profile.displayName, + fullName: mainUser.profile.fullName, + image: supportImage, + }) + } catch (err) { + logError("Failed to update support bot profile image", err) + } + } // --- Auto-accept direct messages from group members --- await mainChat.sendChatCmd(`/_set accept member contacts ${mainUser.userId} on`) @@ -220,6 +251,22 @@ async function main(): Promise { // Create SupportBot — event handlers now route through it supportBot = new SupportBot(mainChat, grokChat, grokApi, config) + + // Restore Grok group map from persisted state + if (state.grokGroupMap) { + const entries: [number, number][] = Object.entries(state.grokGroupMap) + .map(([k, v]) => [Number(k), v]) + supportBot.restoreGrokGroupMap(entries) + } + + // Persist Grok group map on every change + supportBot.onGrokMapChanged = (map) => { + const obj: {[key: string]: number} = {} + for (const [k, v] of map) obj[k] = v + state.grokGroupMap = obj + writeState(stateFilePath, state) + } + log("SupportBot initialized. Bot running.") // Subscribe Grok agent event handlers diff --git a/apps/simplex-support-bot/src/messages.ts b/apps/simplex-support-bot/src/messages.ts index 211f07f764..27fd6319dd 100644 --- a/apps/simplex-support-bot/src/messages.ts +++ b/apps/simplex-support-bot/src/messages.ts @@ -6,7 +6,7 @@ export function welcomeMessage(groupLinks: string): string { export function teamQueueMessage(timezone: string): string { const hours = isWeekend(timezone) ? "48" : "24" - return `Thank you for your message, it is forwarded to the team.\nIt may take a team member up to ${hours} hours to reply.\n\nClick /grok if your question is about SimpleX apps or network, is not sensitive, and you want Grok LLM to answer it right away. *Your previous message and all subsequent messages will be forwarded to Grok* until you click /team. You can ask Grok questions in any language and it will not see your profile name.\n\nWe appreciate if you try Grok: you can learn a lot about SimpleX Chat from it. It is objective, answers the way our team would, and it saves our team time.` + return `Your message is forwarded to the team. A reply may take up to ${hours} hours.\n\nIf your question is about SimpleX Chat, click /grok for an instant AI answer (non-sensitive questions only). Click /team to switch back any time.` } export const grokActivatedMessage = `*You are now chatting with Grok. You can send questions in any language.* Your message(s) have been forwarded.\nSend /team at any time to switch to a human team member.` diff --git a/apps/simplex-support-bot/src/startup.ts b/apps/simplex-support-bot/src/startup.ts new file mode 100644 index 0000000000..c73ac77f0e --- /dev/null +++ b/apps/simplex-support-bot/src/startup.ts @@ -0,0 +1,41 @@ +import {existsSync} from "fs" +import {execSync} from "child_process" +import {log, logError} from "./util.js" + +// Resolve display_names table conflicts before bot.run updates the profile. +// The SimpleX Chat store enforces unique (user_id, local_display_name) in display_names. +// If the desired name is already used by a contact or group, the profile update fails +// with duplicateName. This renames the conflicting entry to free up the name. +export function resolveDisplayNameConflict(dbPrefix: string, desiredName: string): void { + const dbFile = `${dbPrefix}_chat.db` + if (!existsSync(dbFile)) return + const esc = desiredName.replace(/'/g, "''") + try { + // If user already has this display name, no conflict — Haskell takes the no-change branch + const isUserName = execSync( + `sqlite3 "${dbFile}" "SELECT COUNT(*) FROM users WHERE local_display_name = '${esc}'"`, + {encoding: "utf-8"} + ).trim() + if (isUserName !== "0") return + + // Check if the name exists in display_names at all + const count = execSync( + `sqlite3 "${dbFile}" "SELECT COUNT(*) FROM display_names WHERE local_display_name = '${esc}'"`, + {encoding: "utf-8"} + ).trim() + if (count === "0") return + + // Rename the conflicting entry (contact/group) to free the name + const newName = `${esc}_1` + log(`Display name conflict: "${desiredName}" already in display_names, renaming to "${newName}"`) + const sql = [ + `UPDATE contacts SET local_display_name = '${newName}' WHERE local_display_name = '${esc}';`, + `UPDATE groups SET local_display_name = '${newName}' WHERE local_display_name = '${esc}';`, + `UPDATE display_names SET local_display_name = '${newName}', ldn_suffix = 1 WHERE local_display_name = '${esc}';`, + ].join(" ") + execSync(`sqlite3 "${dbFile}" "${sql}"`, {encoding: "utf-8"}) + log("Display name conflict resolved") + } catch (err) { + logError("Failed to resolve display name conflict (sqlite3 may not be available)", err) + } +} diff --git a/apps/simplex-support-bot/src/state.ts b/apps/simplex-support-bot/src/state.ts index 98546a1ca1..44e452761f 100644 --- a/apps/simplex-support-bot/src/state.ts +++ b/apps/simplex-support-bot/src/state.ts @@ -2,10 +2,3 @@ export interface GrokMessage { role: "user" | "assistant" content: string } - -export type ConversationState = - | {type: "welcome"} - | {type: "teamQueue"; userMessages: string[]} - | {type: "grokMode"; grokMemberGId: number; history: GrokMessage[]} - | {type: "teamPending"; teamMemberGId: number} - | {type: "teamLocked"; teamMemberGId: number}