// ═══════════════════════════════════════════════════════════════════ // SimpleX Support Bot — Acceptance Tests (Stateless) // ═══════════════════════════════════════════════════════════════════ // // 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, afterEach, vi} from "vitest" // ─── Module Mocks (hoisted by vitest) ──────────────────────────── vi.mock("simplex-chat", () => ({ api: {}, util: { ciBotCommand: (chatItem: any) => chatItem._botCommand ? {keyword: chatItem._botCommand} : null, ciContentText: (chatItem: any) => chatItem._text ?? null, }, })) vi.mock("@simplex-chat/types", () => ({ T: { ChatType: {Group: "group", Direct: "direct"}, GroupMemberRole: {Member: "member"}, GroupMemberStatus: { Connected: "connected", Complete: "complete", Announced: "announced", }, GroupFeatureEnabled: { On: "on", Off: "off", }, }, CEvt: {}, })) vi.mock("./src/util", () => ({ isWeekend: vi.fn(() => false), log: vi.fn(), 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 {parseConfig, parseIdName} from "./src/config" 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 ────────────────────────────────────────────── class MockGrokApi { private responses: Array = [] calls: {history: GrokMessage[]; message: string}[] = [] willRespond(text: string) { this.responses.push(text) } willFail() { this.responses.push(new Error("Grok API error")) } async chat(history: GrokMessage[], message: string): Promise { this.calls.push({history: [...history], message}) const resp = this.responses.shift() if (!resp) throw new Error("MockGrokApi: no response configured") if (resp instanceof Error) throw resp return resp } lastCall() { return this.calls[this.calls.length - 1] } callCount() { return this.calls.length } reset() { this.responses = []; this.calls = [] } } // ─── Mock Chat API ────────────────────────────────────────────── interface SentMessage { chat: [string, number]; text: string; inReplyTo?: number } interface AddedMember { groupId: number; contactId: number; role: string } interface RemovedMembers { groupId: number; memberIds: number[] } class MockChatApi { sent: SentMessage[] = [] added: AddedMember[] = [] 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}[] = [] roleChanges: {groupId: number; memberIds: number[]; role: string}[] = [] 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, inReplyTo?: number) { this.sent.push({chat, text, inReplyTo}) // 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) { if (this.addMemberFail) { this.addMemberFail = false; throw new Error("apiAddMember failed") } if (this.addMemberDuplicate) { this.addMemberDuplicate = false const err: any = new Error("groupDuplicateMember") err.chatError = {type: "error", errorType: {type: "groupDuplicateMember", contactName: "TeamGuy"}} throw err } const gid = this.nextMemberGId++ this.added.push({groupId, contactId, role}) 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 apiSetMembersRole(groupId: number, memberIds: number[], role: string) { this.roleChanges.push({groupId, memberIds, role}) } async apiJoinGroup(groupId: number) { this.joined.push(groupId) } async apiListMembers(groupId: number) { return this.members.get(groupId) || [] } sentCmds: string[] = [] private nextContactId = 100 // sendChatCmd is used by apiGetChat, /_create member contact, /_invite member contact async sendChatCmd(cmd: string) { this.sentCmds.push(cmd) // 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: {}, }, } } // Parse "/_create member contact # " const createMatch = cmd.match(/\/_create member contact #(\d+) (\d+)/) if (createMatch) { const contactId = this.nextContactId++ return {type: "newMemberContact", contact: {contactId}} } // Parse "/_invite member contact @" if (cmd.startsWith("/_invite member contact @")) { return {type: "newMemberContactSentInv"} } return {type: "cmdOk"} } sentTo(groupId: number): string[] { return this.sent.filter(m => m.chat[1] === groupId).map(m => m.text) } lastSentTo(groupId: number): string | undefined { const msgs = this.sentTo(groupId) return msgs[msgs.length - 1] } reset() { this.sent = []; this.added = []; this.removed = []; this.joined = []; this.sentCmds = [] this.members.clear(); this.chatItems.clear() this.updatedProfiles = []; this.updatedChatItems = [] this.addMemberFail = false; this.addMemberDuplicate = false; this.nextMemberGId = 50; this.nextItemId = 1000; this.nextContactId = 100 } } // ─── Event Factories ──────────────────────────────────────────── const GROUP_ID = 100 const TEAM_GRP_ID = 1 const GROK_LOCAL = 200 const CUSTOMER_ID = "cust-1" function businessGroupInfo(groupId = GROUP_ID, displayName = "Alice") { return { groupId, groupProfile: {displayName}, businessChat: {customerId: CUSTOMER_ID}, membership: {memberId: "bot-member"}, } as any } let nextChatItemId = 500 function customerChatItem(text: string | null, command: string | null = null) { const itemId = nextChatItemId++ return { chatInfo: {type: "group", groupInfo: businessGroupInfo()}, chatItem: { chatDir: { type: "groupRcv", groupMember: {memberId: CUSTOMER_ID, groupMemberId: 10}, }, meta: {itemId}, content: {type: "text", text: text ?? ""}, _botCommand: command, _text: text, }, } as any } 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, memberContactId: 2, memberProfile: {displayName: "Bob"}}, }, meta: {itemId}, content: {type: "text", text}, _text: text, }, } as any } function grokMemberChatItem(grokMemberGId: number, text: string) { return { chatInfo: {type: "group", groupInfo: businessGroupInfo()}, chatItem: { chatDir: { type: "groupRcv", groupMember: {memberId: "grok-1", groupMemberId: grokMemberGId, memberContactId: 4}, }, content: {type: "text", text}, _text: text, }, } as any } function botOwnChatItem(text: string) { return { chatInfo: {type: "group", groupInfo: businessGroupInfo()}, chatItem: {chatDir: {type: "groupSnd"}, content: {type: "text", text}}, } as any } // ─── Test DSL ─────────────────────────────────────────────────── let bot: SupportBot let mainChat: MockChatApi let grokChat: MockChatApi let grokApi: MockGrokApi let lastTeamMemberGId: number let lastGrokMemberGId: number const customer = { 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) }, async sendsReplyTo(text: string, quotedItemId: number, groupId = GROUP_ID) { const ci = customerChatItem(text, null) ci.chatInfo.groupInfo = businessGroupInfo(groupId) ci.chatItem.quotedItem = {itemId: quotedItemId} if (!mainChat.chatItems.has(groupId)) mainChat.chatItems.set(groupId, []) mainChat.chatItems.get(groupId)!.push({ chatDir: { type: "groupRcv", groupMember: {memberId: CUSTOMER_ID, groupMemberId: 10}, }, _text: text, }) await bot.onNewChatItems({chatItems: [ci]} as any) }, async sendsNonText(groupId = GROUP_ID) { const ci = customerChatItem(null, null) ci.chatInfo.groupInfo = businessGroupInfo(groupId) await bot.onNewChatItems({chatItems: [ci]} as any) }, async leaves(groupId = GROUP_ID) { await bot.onLeftMember({ groupInfo: businessGroupInfo(groupId), member: {memberId: CUSTOMER_ID, groupMemberId: 10}, } as any) }, received(expected: string, groupId = GROUP_ID) { const msgs = mainChat.sentTo(groupId) expect(msgs).toContain(expected) }, receivedFromGrok(expected: string) { const msgs = grokChat.sentTo(GROK_LOCAL) expect(msgs).toContain(expected) }, receivedNothing(groupId = GROUP_ID) { expect(mainChat.sentTo(groupId)).toEqual([]) }, } // Format helpers for expected forwarded messages (new A1-A6 format) // Note: in tests, duration is always <60s so it's omitted from the header function fmtCustomer(text: string, state = "QUEUE", msgNum = 2, name = "Alice", groupId = GROUP_ID) { return `*${groupId}:${name} · ${state} · #${msgNum}*\n${text}` } function fmtTeamMember(tmContactId: number, text: string, state = "TEAM", msgNum: number, tmName = "Bob", customerName = "Alice", groupId = GROUP_ID) { return `!2 >>! *${tmContactId}:${tmName} > ${groupId}:${customerName} · ${state} · #${msgNum}*\n${text}` } function fmtGrok(text: string, state = "GROK", msgNum: number, name = "Alice", groupId = GROUP_ID) { return `!5 AI! *Grok > ${groupId}:${name} · ${state} · #${msgNum}*\n_${text}_` } function fmtNewCustomer(text: string, state = "QUEUE", msgNum = 1, name = "Alice", groupId = GROUP_ID) { return `!1 NEW! *${groupId}:${name} · ${state} · #${msgNum}*\n${text}` } const teamGroup = { received(expected: string) { const msgs = mainChat.sentTo(TEAM_GRP_ID) expect(msgs).toContain(expected) }, receivedNothing() { expect(mainChat.sentTo(TEAM_GRP_ID)).toEqual([]) }, } const teamMember = { wasInvited(groupId = GROUP_ID) { const found = mainChat.added.some(a => a.groupId === groupId && a.contactId === 2) expect(found).toBe(true) }, 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 sendsReplyTo(text: string, quotedItemId: number, groupId = GROUP_ID) { const ci = teamMemberChatItem(lastTeamMemberGId, text) ci.chatInfo.groupInfo = businessGroupInfo(groupId) ci.chatItem.quotedItem = {itemId: quotedItemId} 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, memberContactId: 2}, } as any) }, } const grokAgent = { wasInvited(groupId = GROUP_ID) { const found = mainChat.added.some(a => a.groupId === groupId && a.contactId === 4) expect(found).toBe(true) }, async joins() { await new Promise(r => setTimeout(r, 0)) const memberId = `member-${lastGrokMemberGId}` await bot.onGrokGroupInvitation({ groupInfo: { groupId: GROK_LOCAL, membership: {memberId}, }, } as any) bot.onGrokMemberConnected({ groupInfo: {groupId: GROK_LOCAL}, member: {memberProfile: {displayName: "Bot"}}, } as any) }, async timesOut() { await vi.advanceTimersByTimeAsync(30_001) }, wasRemoved(groupId = GROUP_ID) { const found = mainChat.removed.some( r => r.groupId === groupId && r.memberIds.includes(lastGrokMemberGId) ) expect(found).toBe(true) }, wasNotRemoved(groupId = GROUP_ID) { const found = mainChat.removed.some( r => r.groupId === groupId && r.memberIds.includes(lastGrokMemberGId) ) expect(found).toBe(false) }, 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, memberContactId: 4}, } as any) }, } // ─── Constants ────────────────────────────────────────────────── const TEAM_QUEUE_24H = `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") const GROK_ACTIVATED = `*You are now chatting with Grok. You can send questions in any language.* ` + `Your message(s) have been forwarded.\n` + `Send /team at any time to switch to a human team member.` const TEAM_ADDED_24H = `A team member has been added and will reply within 24 hours. ` + `You can keep describing your issue — they will see the full conversation.` const TEAM_ADDED_48H = TEAM_ADDED_24H.replace("24 hours", "48 hours") const TEAM_LOCKED_MSG = `You are now in team mode. A team member will reply to your message.` const GROK_UNAVAILABLE = `Grok is temporarily unavailable. Please try again or click /team for a team member.` const TEAM_ADD_ERROR = `Sorry, there was an error adding a team member. Please try again.` const TEAM_ALREADY_ADDED = `A team member has already been invited to this conversation and will reply when available.` // ─── Setup ────────────────────────────────────────────────────── const config = { teamGroup: {id: 1, name: "SupportTeam"}, teamMembers: [{id: 2, name: "Bob"}], grokContactId: 4, timezone: "America/New_York", groupLinks: "https://simplex.chat/contact#...", grokApiKey: "test-key", dbPrefix: "./test-data/bot", grokDbPrefix: "./test-data/grok", } beforeEach(() => { mainChat = new MockChatApi() grokChat = new MockChatApi() grokApi = new MockGrokApi() 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) vi.mocked(isWeekend).mockReturnValue(false) }) // ─── State Setup Helpers ──────────────────────────────────────── // Reach teamQueue: customer sends first message → bot sends queue reply (groupSnd in DB) async function reachTeamQueue(...messages: string[]) { 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) 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") } // ═══════════════════════════════════════════════════════════════ // TESTS // ═══════════════════════════════════════════════════════════════ // ─── 1. Connection & Welcome ──────────────────────────────────── describe("Connection & Welcome", () => { test("first message → forwarded to team with NEW, queue reply sent", async () => { // No prior bot messages → isFirstCustomerMessage returns true → welcome flow await customer.sends("How do I create a group?") teamGroup.received(fmtNewCustomer("How do I create a group?", "QUEUE", 1)) customer.received(TEAM_QUEUE_24H) }) test("non-text message when no bot messages → ignored", async () => { await customer.sendsNonText() expect(mainChat.sent.length).toBe(0) }) }) // ─── 2. Team Queue ────────────────────────────────────────────── describe("Team Queue", () => { test("additional messages forwarded to team, no second queue reply", async () => { await reachTeamQueue("First question") mainChat.sent = [] await customer.sends("More details about my issue") teamGroup.received(fmtCustomer("More details about my issue", "QUEUE", 2)) // No queue message sent again — bot already sent a message (groupSnd in DB) expect(mainChat.sentTo(GROUP_ID).length).toBe(0) }) test("non-text message in teamQueue → ignored", async () => { await reachTeamQueue("Hello") mainChat.sent = [] await customer.sendsNonText() expect(mainChat.sent.length).toBe(0) }) test("unrecognized /command treated as normal text message", async () => { await reachTeamQueue("Hello") mainChat.sent = [] await customer.sends("/unknown") teamGroup.received(fmtCustomer("/unknown", "QUEUE", 2)) }) }) // ─── 3. Grok Activation ──────────────────────────────────────── describe("Grok Activation", () => { test("/grok → Grok invited, activated, API called, response sent", async () => { mainChat.setNextGroupMemberId(60) lastGrokMemberGId = 60 await reachTeamQueue("How do I create a group?") grokApi.willRespond("To create a group, go to Settings > New Group.") 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 grokAgent.wasInvited() customer.received(GROK_ACTIVATED) // Grok API called with empty history + accumulated message expect(grokApi.lastCall().history).toEqual([]) expect(grokApi.lastCall().message).toBe("How do I create a group?") // Grok response sent via Grok identity customer.receivedFromGrok("To create a group, go to Settings > New Group.") }) test("/grok with multiple accumulated messages → joined with newline", async () => { mainChat.setNextGroupMemberId(60) lastGrokMemberGId = 60 await reachTeamQueue("Question about groups", "Also, how do I add members?") 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 expect(grokApi.lastCall().message).toBe( "Question about groups\nAlso, how do I add members?" ) customer.receivedFromGrok("Here's how to do both...") }) }) // ─── 4. Grok Mode Conversation ───────────────────────────────── 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?") // msgNum=3: #1=Hello, #2=Grok initial answer, #3=customer follow-up teamGroup.received(fmtCustomer("What about encryption?", "GROK", 3)) // 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") }) test("/grok in grokMode → silently ignored", async () => { await reachGrokMode() mainChat.sent = [] grokApi.reset() await customer.sends("/grok") expect(mainChat.sent.length).toBe(0) expect(grokApi.callCount()).toBe(0) }) test("non-text message in grokMode → ignored", async () => { await reachGrokMode() mainChat.sent = [] grokApi.reset() await customer.sendsNonText() expect(mainChat.sent.length).toBe(0) expect(grokApi.callCount()).toBe(0) }) }) // ─── 5. Team Activation ──────────────────────────────────────── describe("Team Activation", () => { test("/team from teamQueue → team member invited, team added message", async () => { mainChat.setNextGroupMemberId(50) lastTeamMemberGId = 50 await reachTeamQueue("Hello") mainChat.sent = [] await customer.sends("/team") teamMember.wasInvited() customer.received(TEAM_ADDED_24H) }) test("/team from grokMode → team member added, Grok stays until team member connects", async () => { await reachGrokMode() mainChat.setNextGroupMemberId(70) lastTeamMemberGId = 70 mainChat.sent = [] await customer.sends("/team") // Grok NOT removed yet — stays functional during transition grokAgent.wasNotRemoved() teamMember.wasInvited() customer.received(TEAM_ADDED_24H) // Team member sends first message → Grok removed mainChat.setGroupMembers(GROUP_ID, [ {groupMemberId: lastGrokMemberGId, memberContactId: 4, memberStatus: "connected"}, {groupMemberId: 70, memberContactId: 2, memberStatus: "connected"}, ]) await teamMember.sends("Hi, I'll help you") grokAgent.wasRemoved() }) }) // ─── 6. One-Way Gate ──────────────────────────────────────────── describe("One-Way Gate", () => { test("/grok in teamPending → 'team mode' reply", async () => { await reachTeamPending() mainChat.sent = [] await customer.sends("/grok") customer.received(TEAM_LOCKED_MSG) }) test("/grok in teamLocked → 'team mode' reply", async () => { await reachTeamLocked() mainChat.sent = [] await customer.sends("/grok") customer.received(TEAM_LOCKED_MSG) }) test("/team in teamPending → silently ignored", async () => { await reachTeamPending() mainChat.sent = [] await customer.sends("/team") expect(mainChat.sentTo(GROUP_ID).length).toBe(0) }) test("/team in teamLocked → silently ignored", async () => { await reachTeamLocked() mainChat.sent = [] await customer.sends("/team") expect(mainChat.sentTo(GROUP_ID).length).toBe(0) }) test("customer text in teamPending → forwarded to team group", async () => { await reachTeamPending() mainChat.sent = [] await customer.sends("Here's more info about my issue") // msgNum=2: #1=Hello, #2=this message; TEAM state (team member present) teamGroup.received(fmtCustomer("Here's more info about my issue", "TEAM", 2)) // No reply sent to customer group expect(mainChat.sentTo(GROUP_ID).length).toBe(0) }) test("customer text in teamLocked → forwarded to team group", async () => { await reachTeamLocked() mainChat.sent = [] await customer.sends("Thank you!") // msgNum=3: #1=Hello, #2=team "I'll help you", #3=customer "Thank you!" teamGroup.received(fmtCustomer("Thank you!", "TEAM", 3)) // No reply sent to customer group expect(mainChat.sentTo(GROUP_ID).length).toBe(0) }) }) // ─── 7. Gate Reversal vs Irreversibility ──────────────────────── describe("Gate Reversal vs Irreversibility", () => { 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() // 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 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") }) test("team member leaves in teamLocked → no replacement added", async () => { await reachTeamLocked() mainChat.added = [] await teamMember.leaves() // No replacement — team member is not auto-invited back expect(mainChat.added.length).toBe(0) }) }) // ─── 7b. Team Re-addition Prevention ───────────────────────────── describe("Team Re-addition Prevention", () => { test("/team after team member left teamPending → not re-added, already-added message", async () => { await reachTeamPending() // Team member leaves (teamPending revert) mainChat.setGroupMembers(GROUP_ID, []) await teamMember.leaves() mainChat.added = [] mainChat.sent = [] // Customer sends /team again await customer.sends("/team") // Team member NOT re-added expect(mainChat.added.length).toBe(0) // Customer gets the already-added message customer.received(TEAM_ALREADY_ADDED) }) test("/team after team member left teamLocked → not re-added", async () => { await reachTeamLocked() // Team member leaves mainChat.setGroupMembers(GROUP_ID, []) await teamMember.leaves() mainChat.added = [] mainChat.sent = [] // Customer sends /team again await customer.sends("/team") // Team member NOT re-added — hasTeamBeenActivatedBefore returns true expect(mainChat.added.length).toBe(0) customer.received(TEAM_ALREADY_ADDED) }) test("/team from grokMode after prior team activation → Grok NOT removed, not re-added", async () => { // First: activate team, then team member leaves, then customer activates Grok await reachTeamPending() mainChat.setGroupMembers(GROUP_ID, []) await teamMember.leaves() // Now in teamQueue equivalent — activate Grok mainChat.setNextGroupMemberId(61) lastGrokMemberGId = 61 grokApi.willRespond("Grok answer") const p = customer.sends("/grok") mainChat.setGroupMembers(GROUP_ID, [ {groupMemberId: 61, memberContactId: 4, memberStatus: "connected"}, ]) await grokAgent.joins() await p mainChat.added = [] mainChat.removed = [] mainChat.sent = [] // Customer sends /team while in grokMode — but team was already activated before await customer.sends("/team") // Grok NOT removed (activateTeam returned early) expect(mainChat.removed.length).toBe(0) // Team member NOT re-added expect(mainChat.added.length).toBe(0) customer.received(TEAM_ALREADY_ADDED) }) test("first /team still works normally", async () => { await reachTeamQueue("Hello") mainChat.setGroupMembers(GROUP_ID, []) mainChat.setNextGroupMemberId(50) lastTeamMemberGId = 50 mainChat.added = [] mainChat.sent = [] await customer.sends("/team") teamMember.wasInvited() customer.received(TEAM_ADDED_24H) }) test("restart after team activation → /team still blocked", async () => { await reachTeamPending() mainChat.setGroupMembers(GROUP_ID, []) await teamMember.leaves() // Simulate restart: create new bot instance, but chat history persists const freshBot = new SupportBot(mainChat as any, grokChat as any, grokApi as any, config as any) mainChat.added = [] mainChat.sent = [] // Customer sends /team via the restarted bot const ci = customerChatItem("/team", "team") mainChat.chatItems.get(GROUP_ID)!.push({ chatDir: {type: "groupRcv", groupMember: {memberId: CUSTOMER_ID, groupMemberId: 10}}, _text: "/team", _botCommand: "team", }) await freshBot.onNewChatItems({chatItems: [ci]} as any) // Team member NOT re-added expect(mainChat.added.length).toBe(0) customer.received(TEAM_ALREADY_ADDED) }) test("/add command still works after team activation (team-initiated)", async () => { await reachTeamPending() mainChat.setGroupMembers(GROUP_ID, []) await teamMember.leaves() mainChat.added = [] // Team member uses /add in team group — should bypass the check const addCi = { chatInfo: {type: "group", groupInfo: {groupId: TEAM_GRP_ID, businessChat: null}}, chatItem: { chatDir: { type: "groupRcv", groupMember: {memberId: "tm-1", groupMemberId: 30, memberContactId: 2, memberProfile: {displayName: "Bob"}}, }, meta: {itemId: 900}, _text: `/add ${GROUP_ID}:Alice`, }, } as any await bot.onNewChatItems({chatItems: [addCi]} as any) // /add bypasses activateTeam — team member added directly expect(mainChat.added.length).toBe(1) expect(mainChat.added[0].groupId).toBe(GROUP_ID) expect(mainChat.added[0].contactId).toBe(2) }) }) // ─── 8. Member Leave & Cleanup ────────────────────────────────── describe("Member Leave & Cleanup", () => { test("customer leaves → grok maps cleaned up", async () => { await reachTeamQueue("Hello") await customer.leaves() // No crash, grok maps cleaned expect((bot as any).grokGroupMap.has(GROUP_ID)).toBe(false) }) test("customer leaves in grokMode → grok maps cleaned", async () => { await reachGrokMode() await customer.leaves() expect((bot as any).grokGroupMap.has(GROUP_ID)).toBe(false) }) test("Grok leaves during grokMode → next customer message goes to teamQueue", async () => { await reachGrokMode() await grokAgent.leaves() mainChat.sent = [] grokApi.reset() // 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") // msgNum=3: #1=Hello, #2=Grok answer in reachGrokMode, #3=this teamGroup.received(fmtCustomer("Another question", "QUEUE", 3)) expect((bot as any).grokGroupMap.has(GROUP_ID)).toBe(false) }) 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("customer leaves in welcome → no crash", async () => { // No prior messages sent — just leave await customer.leaves() // No crash expected }) }) // ─── 9. Error Handling ────────────────────────────────────────── describe("Error Handling", () => { test("Grok invitation (apiAddMember) fails → error msg, stays in queue", async () => { await reachTeamQueue("Hello") mainChat.apiAddMemberWillFail() mainChat.sent = [] await customer.sends("/grok") customer.received(GROK_UNAVAILABLE) expect(grokApi.callCount()).toBe(0) }) test("Grok join timeout → error msg, Grok member removed", async () => { vi.useFakeTimers() mainChat.setNextGroupMemberId(60) lastGrokMemberGId = 60 await reachTeamQueue("Hello") mainChat.sent = [] const sendPromise = customer.sends("/grok") await grokAgent.timesOut() await sendPromise customer.received(GROK_UNAVAILABLE) expect(grokApi.callCount()).toBe(0) // Grok member should be removed on timeout to prevent ghost grokMode grokAgent.wasRemoved() vi.useRealTimers() }) test("Grok API error during activation → remove Grok, error msg", async () => { mainChat.setNextGroupMemberId(60) lastGrokMemberGId = 60 await reachTeamQueue("Hello") grokApi.willFail() 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) }) test("Grok API error during conversation → remove Grok, error msg", async () => { await reachGrokMode() grokApi.willFail() mainChat.sent = [] await customer.sends("Another question") grokAgent.wasRemoved() customer.received(GROK_UNAVAILABLE) }) test("after Grok API failure revert, /team still works", async () => { await reachGrokMode() grokApi.willFail() await customer.sends("Failing question") // After Grok removal, members list should be empty mainChat.setGroupMembers(GROUP_ID, []) mainChat.setNextGroupMemberId(51) lastTeamMemberGId = 51 mainChat.sent = [] await customer.sends("/team") teamMember.wasInvited() customer.received(TEAM_ADDED_24H) }) test("team member add fails from teamQueue → error, stays in queue", async () => { await reachTeamQueue("Hello") mainChat.apiAddMemberWillFail() mainChat.sent = [] await customer.sends("/team") customer.received(TEAM_ADD_ERROR) }) test("team member add fails in grokMode → error msg, Grok stays", async () => { await reachGrokMode() mainChat.apiAddMemberWillFail() mainChat.sent = [] await customer.sends("/team") grokAgent.wasNotRemoved() customer.received(TEAM_ADD_ERROR) }) test("Grok failure then retry succeeds", async () => { mainChat.setNextGroupMemberId(60) lastGrokMemberGId = 60 await reachTeamQueue("Hello") // 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 // 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?") }) }) // ─── 10. Race Conditions ──────────────────────────────────────── describe("Race Conditions", () => { test("/team sent while waiting for Grok to join → Grok continues, team member added", async () => { mainChat.setNextGroupMemberId(60) lastGrokMemberGId = 60 await reachTeamQueue("Hello") // 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 (no special members yet) mainChat.setNextGroupMemberId(70) lastTeamMemberGId = 70 mainChat.setGroupMembers(GROUP_ID, []) await customer.sends("/team") customer.received(TEAM_ADDED_24H) // Grok join completes — Grok keeps working (team member not yet connected) mainChat.setGroupMembers(GROUP_ID, [ {groupMemberId: 60, memberContactId: 4, memberStatus: "connected"}, ]) await grokAgent.joins() await grokPromise // Grok NOT removed — still functional grokAgent.wasNotRemoved() // Grok API was called (activation succeeded) expect(grokApi.callCount()).toBe(1) }) test("team member connects during Grok session → Grok removed", async () => { mainChat.setNextGroupMemberId(60) lastGrokMemberGId = 60 await reachTeamQueue("Hello") // Make grokApi.chat return a controllable promise let resolveGrokCall!: (v: string) => void 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)) // While API call is pending, /team adds team member mainChat.setNextGroupMemberId(70) lastTeamMemberGId = 70 mainChat.setGroupMembers(GROUP_ID, [ {groupMemberId: 60, memberContactId: 4, memberStatus: "connected"}, ]) await customer.sends("/team") // API call completes — Grok answer is sent (no abort) resolveGrokCall("Grok answer") await grokPromise grokAgent.wasNotRemoved() // Team member sends message → Grok removed mainChat.setGroupMembers(GROUP_ID, [ {groupMemberId: 60, memberContactId: 4, memberStatus: "connected"}, {groupMemberId: 70, memberContactId: 2, memberStatus: "connected"}, ]) await teamMember.sends("I'll take over") grokAgent.wasRemoved() }) test("team member non-text event (join notification) does NOT remove Grok", async () => { await reachGrokMode() mainChat.sent = [] // Simulate a non-text system event from a team member (e.g., join notification) const ci = { chatInfo: {type: "group", groupInfo: businessGroupInfo()}, chatItem: { chatDir: { type: "groupRcv", groupMember: {memberId: "team-member-1", groupMemberId: 70, memberContactId: 2, memberProfile: {displayName: "Bob"}}, }, meta: {itemId: nextChatItemId++}, content: {type: "rcvGroupEvent", rcvGroupEvent: {type: "memberConnected"}}, _text: null, }, } as any ci.chatInfo.groupInfo = businessGroupInfo() await bot.onNewChatItems({chatItems: [ci]} as any) // Grok should NOT be removed — only a real text message should trigger removal grokAgent.wasNotRemoved() expect((bot as any).grokGroupMap.has(GROUP_ID)).toBe(true) }) }) // ─── 11. Weekend Hours ────────────────────────────────────────── describe("Weekend Hours", () => { test("weekend: 48 hours in queue message", async () => { vi.mocked(isWeekend).mockReturnValue(true) await customer.sends("Hello") customer.received(TEAM_QUEUE_48H) }) test("weekend: 48 hours in team added message", async () => { vi.mocked(isWeekend).mockReturnValue(true) await reachTeamQueue("Hello") await customer.sends("/team") customer.received(TEAM_ADDED_48H) }) }) // ─── 12. Team Forwarding Format ───────────────────────────────── describe("Team Forwarding", () => { test("format: first message has !1 NEW! color-coded prefix", async () => { await customer.sends("My app crashes on startup") teamGroup.received(fmtNewCustomer("My app crashes on startup", "QUEUE", 1)) }) test("grokMode messages also forwarded to team", async () => { await reachGrokMode() mainChat.sent = [] grokApi.willRespond("Try clearing app data") await customer.sends("App keeps crashing") // msgNum=3: #1=Hello, #2=Grok answer, #3=customer follow-up teamGroup.received(fmtCustomer("App keeps crashing", "GROK", 3)) customer.receivedFromGrok("Try clearing app data") }) test("fallback displayName when empty → group-{id}", async () => { const emptyNameGroup = {...businessGroupInfo(101), groupProfile: {displayName: ""}} mainChat.sent = [] 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(fmtNewCustomer("Hello", "QUEUE", 1, "group-101", 101)) }) }) // ─── 13. Edge Cases ───────────────────────────────────────────── describe("Edge Cases", () => { test("bot's own messages (groupSnd) → ignored", async () => { await reachTeamQueue("Hello") mainChat.sent = [] await bot.onNewChatItems({chatItems: [botOwnChatItem("queue reply")]} as any) expect(mainChat.sent.length).toBe(0) }) test("non-business-chat group → ignored", async () => { const nonBizGroup = { groupId: 999, groupProfile: {displayName: "Random"}, businessChat: undefined, } const ci = { chatInfo: {type: "group", groupInfo: nonBizGroup}, chatItem: {chatDir: {type: "groupRcv", groupMember: {memberId: "x"}}, _text: "hi"}, } as any await bot.onNewChatItems({chatItems: [ci]} as any) expect(mainChat.sent.length).toBe(0) }) 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) // Handled as teamQueue (not welcome, since bot already has groupSnd), forwarded to team // First message for group 888 in this bot instance → msgNum=1 teamGroup.received(fmtCustomer("I had a question earlier", "QUEUE", 1, "Alice", 888)) }) test("Grok's own messages in grokMode → ignored by bot (non-customer)", async () => { await reachGrokMode() mainChat.sent = [] grokApi.reset() const ci = grokMemberChatItem(lastGrokMemberGId, "Grok's response text") await bot.onNewChatItems({chatItems: [ci]} as any) expect(grokApi.callCount()).toBe(0) expect(mainChat.sent.length).toBe(0) }) test("unexpected Grok group invitation → ignored", async () => { await bot.onGrokGroupInvitation({ groupInfo: { groupId: 999, membership: {memberId: "unknown-member"}, }, } as any) expect(grokChat.joined.length).toBe(0) }) test("multiple concurrent conversations are independent", async () => { const GROUP_A = 100 const GROUP_B = 300 // 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) // 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() // 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!") }) test("/grok as first message → activates grok directly", async () => { mainChat.setNextGroupMemberId(60) lastGrokMemberGId = 60 grokApi.willRespond("Hello! How can I help?") const p = customer.sends("/grok") mainChat.setGroupMembers(GROUP_ID, [ {groupMemberId: 60, memberContactId: 4, memberStatus: "connected"}, ]) await grokAgent.joins() await p // Grok activated, no teamQueue message customer.received(GROK_ACTIVATED) const teamMsgs = mainChat.sentTo(TEAM_GRP_ID) expect(teamMsgs.some(m => m.includes("/grok"))).toBe(false) // Commands not forwarded // /add not sent — only sent on first forwarded text message expect(teamMsgs.some(m => m.startsWith("/add"))).toBe(false) }) test("/team as first message → activates team directly", async () => { mainChat.setGroupMembers(GROUP_ID, []) await customer.sends("/team") // Team member added, no teamQueue message customer.received(TEAM_ADDED_24H) const teamMsgs = mainChat.sentTo(TEAM_GRP_ID) expect(teamMsgs.some(m => m.includes("/team"))).toBe(false) // Commands not forwarded // /add not sent — only sent on first forwarded text message expect(teamMsgs.some(m => m.startsWith("/add"))).toBe(false) }) test("non-text message in teamPending → ignored", async () => { await reachTeamPending() mainChat.sent = [] await customer.sendsNonText() expect(mainChat.sent.length).toBe(0) }) test("non-text message in teamLocked → ignored", async () => { await reachTeamLocked() mainChat.sent = [] await customer.sendsNonText() expect(mainChat.sent.length).toBe(0) }) test("unknown member message → silently ignored", async () => { await reachTeamQueue("Hello") mainChat.sent = [] grokApi.reset() const ci = { chatInfo: {type: "group", groupInfo: businessGroupInfo()}, chatItem: { chatDir: { type: "groupRcv", groupMember: {memberId: "unknown-1", groupMemberId: 999}, }, content: {type: "text", text: "Who am I?"}, _text: "Who am I?", }, } as any await bot.onNewChatItems({chatItems: [ci]} as any) expect(mainChat.sent.length).toBe(0) expect(grokApi.callCount()).toBe(0) }) test("Grok apiJoinGroup failure → maps not set", async () => { mainChat.setNextGroupMemberId(60) lastGrokMemberGId = 60 await reachTeamQueue("Hello") // Make apiJoinGroup fail grokChat.apiJoinGroup = async () => { throw new Error("join failed") } grokApi.willRespond("answer") const p = customer.sends("/grok") await new Promise(r => setTimeout(r, 0)) const memberId = `member-${lastGrokMemberGId}` await bot.onGrokGroupInvitation({ groupInfo: {groupId: GROK_LOCAL, membership: {memberId}}, } as any) expect((bot as any).grokGroupMap.has(GROUP_ID)).toBe(false) expect((bot as any).reverseGrokMap.has(GROK_LOCAL)).toBe(false) }) test("team member leaves teamLocked → no auto-replacement attempted", async () => { await reachTeamLocked() mainChat.added = [] await teamMember.leaves() // No replacement attempted expect(mainChat.added.length).toBe(0) }) 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) // 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_UNAVAILABLE) }) test("null grokContactId → members with null memberContactId not matched as Grok", async () => { const nullGrokConfig = {...config, grokContactId: null} const nullBot = new SupportBot(mainChat as any, grokChat as any, grokApi as any, nullGrokConfig as any) // A member with null memberContactId is in the group (should NOT be treated as Grok) mainChat.setGroupMembers(GROUP_ID, [ {groupMemberId: 99, memberContactId: null, memberStatus: "connected"}, ]) // Send first message to move past welcome 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) // Should route to handleNoSpecialMembers (welcome→teamQueue), NOT handleGrokMode customer.received(TEAM_QUEUE_24H) }) test("null grokContactId → leftMember with null memberContactId not treated as Grok leave", async () => { const nullGrokConfig = {...config, grokContactId: null} const nullBot = new SupportBot(mainChat as any, grokChat as any, grokApi as any, nullGrokConfig as any) // Simulate a member with null memberContactId leaving — should not crash or misidentify await nullBot.onLeftMember({ groupInfo: businessGroupInfo(), member: {memberId: "unknown-member", groupMemberId: 99, memberContactId: null}, } as any) // No crash, and grok maps unchanged (was never set) expect((nullBot as any).grokGroupMap.size).toBe(0) }) 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) // 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.") }) }) // ─── 14. Full End-to-End Flows ────────────────────────────────── describe("End-to-End Flows", () => { test("full flow: welcome → grokMode → /team → teamLocked", async () => { // Step 1: first message → teamQueue (#1) await customer.sends("How do I enable disappearing messages?") teamGroup.received(fmtNewCustomer("How do I enable disappearing messages?", "QUEUE", 1)) customer.received(TEAM_QUEUE_24H) // 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'.") // 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?") // msgNum=3: #1=customer msg, #2=Grok initial, #3=customer follow-up teamGroup.received(fmtCustomer("Can I set different timers?", "GROK", 3)) customer.receivedFromGrok("Yes, you can set different timers per conversation.") // Step 4: /team → team added, Grok stays during transition mainChat.setNextGroupMemberId(70) lastTeamMemberGId = 70 await customer.sends("/team") grokAgent.wasNotRemoved() teamMember.wasInvited() customer.received(TEAM_ADDED_24H) // Step 4b: team member sends first message → Grok removed mainChat.setGroupMembers(GROUP_ID, [ {groupMemberId: lastGrokMemberGId, memberContactId: 4, memberStatus: "connected"}, {groupMemberId: 70, memberContactId: 2, memberStatus: "connected"}, ]) await teamMember.sends("Hi! Let me help you.") grokAgent.wasRemoved() // Update members: Grok gone, team member present mainChat.setGroupMembers(GROUP_ID, [ {groupMemberId: 70, memberContactId: 2, memberStatus: "connected"}, ]) // Step 7: /grok still rejected await customer.sends("/grok") customer.received(TEAM_LOCKED_MSG) // Step 8: customer continues — forwarded to team group, no reply to customer mainChat.sent = [] await customer.sends("Thanks for helping!") // msgNum=6: #1=customer, #2=grok, #3=customer, #4=grok, #5=team, #6=customer teamGroup.received(fmtCustomer("Thanks for helping!", "TEAM", 6)) expect(mainChat.sentTo(GROUP_ID).length).toBe(0) }) test("full flow: welcome → teamQueue → /team directly (skip Grok)", async () => { await customer.sends("I have a billing question") customer.received(TEAM_QUEUE_24H) mainChat.setNextGroupMemberId(50) lastTeamMemberGId = 50 await customer.sends("/team") teamMember.wasInvited() customer.received(TEAM_ADDED_24H) // Team member is now present mainChat.setGroupMembers(GROUP_ID, [ {groupMemberId: 50, memberContactId: 2, memberStatus: "connected"}, ]) await teamMember.sends("Hi, I can help with billing") // Team member sent a message, now in "teamLocked" equivalent // /grok should be rejected await customer.sends("/grok") customer.received(TEAM_LOCKED_MSG) }) }) // ─── 15. Restart Recovery ─────────────────────────────────────── describe("Restart Recovery", () => { 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) // Treated as teamQueue (not welcome), message forwarded to team teamGroup.received(fmtCustomer("I had a question earlier", "QUEUE", 1, "Alice", 777)) }) 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."}, ]) // 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: GROK_LOCAL, membership: {memberId}}, } as any) bot.onGrokMemberConnected({ groupInfo: {groupId: GROK_LOCAL}, member: {memberProfile: {displayName: "Bot"}}, } as any) await p customer.receivedFromGrok("Grok answer") }) }) // ─── 16. Grok connectedToGroupMember ─────────────────────────── describe("Grok connectedToGroupMember", () => { test("waiter not resolved by onGrokGroupInvitation alone", async () => { mainChat.setNextGroupMemberId(60) lastGrokMemberGId = 60 await reachTeamQueue("Hello") grokApi.willRespond("answer") const p = customer.sends("/grok") // Only fire invitation (no connectedToGroupMember) — waiter should NOT resolve await new Promise(r => setTimeout(r, 0)) const memberId = `member-${lastGrokMemberGId}` await bot.onGrokGroupInvitation({ groupInfo: {groupId: GROK_LOCAL, membership: {memberId}}, } as any) // Maps set but waiter not resolved expect((bot as any).grokGroupMap.has(GROUP_ID)).toBe(true) // 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 // Grok activated successfully customer.receivedFromGrok("answer") }) test("onGrokMemberConnected for unknown group → ignored", () => { bot.onGrokMemberConnected({ groupInfo: {groupId: 9999}, member: {memberProfile: {displayName: "Someone"}}, } as any) }) test("grokGroupMap set does NOT satisfy waitForGrokJoin (only grokFullyConnected does)", async () => { // Verify the fast-path checks grokFullyConnected, not grokGroupMap // grokGroupMap can be set (by onGrokGroupInvitation) before connectedToGroupMember fires expect((bot as any).grokGroupMap.has(GROUP_ID)).toBe(false) expect((bot as any).grokFullyConnected.has(GROUP_ID)).toBe(false) // Manually set grokGroupMap but NOT grokFullyConnected (simulates invitation processed) ;(bot as any).grokGroupMap.set(GROUP_ID, GROK_LOCAL) ;(bot as any).reverseGrokMap.set(GROK_LOCAL, GROUP_ID) // waitForGrokJoin should NOT resolve immediately (grokGroupMap is set but grokFullyConnected isn't) vi.useFakeTimers() const result = (bot as any).waitForGrokJoin(GROUP_ID, 100) await vi.advanceTimersByTimeAsync(101) expect(await result).toBe(false) vi.useRealTimers() // Cleanup ;(bot as any).grokGroupMap.delete(GROUP_ID) ;(bot as any).reverseGrokMap.delete(GROK_LOCAL) }) }) // ─── 17. groupDuplicateMember Handling ───────────────────────── describe("groupDuplicateMember Handling", () => { test("/team with duplicate member already present → team mode (no message needed)", async () => { await reachTeamQueue("Hello") // Team member is already in the group (from previous session) mainChat.setGroupMembers(GROUP_ID, [ {groupMemberId: 42, memberContactId: 2, memberStatus: "connected"}, ]) mainChat.sent = [] await customer.sends("/team") // 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 () => { await reachTeamQueue("Hello") mainChat.apiAddMemberWillDuplicate() mainChat.setGroupMembers(GROUP_ID, []) // empty — member not found mainChat.sent = [] await customer.sends("/team") customer.received(TEAM_ADD_ERROR) }) test("team member leaves → no replacement, no duplicate handling needed", async () => { await reachTeamLocked() mainChat.added = [] await teamMember.leaves() expect(mainChat.added.length).toBe(0) }) }) // ─── 18. DM Contact — Proactive Member Contact Creation ──────── describe("DM Contact — Proactive Member Contact Creation", () => { test("member with existing contact (auto-accept) → DM sent directly", async () => { mainChat.sent = [] mainChat.sentCmds = [] await bot.onMemberConnected({ groupInfo: {groupId: TEAM_GRP_ID}, member: {groupMemberId: 30, memberContactId: 5, memberProfile: {displayName: "TeamGuy"}}, } as any) // No /_create command — contact already exists expect(mainChat.sentCmds.some(c => c.includes("/_create member contact"))).toBe(false) // DM sent directly via existing contact const dm = mainChat.sent.find(m => m.chat[0] === "direct" && m.chat[1] === 5) expect(dm).toBeDefined() expect(dm!.text).toContain("keep this contact") expect(dm!.text).toContain("5:TeamGuy") }) test("member with memberContact on event → DM sent directly via memberContact", async () => { mainChat.sent = [] mainChat.sentCmds = [] await bot.onMemberConnected({ groupInfo: {groupId: TEAM_GRP_ID}, member: {groupMemberId: 30, memberContactId: null, memberProfile: {displayName: "TeamGuy"}}, memberContact: {contactId: 42}, } as any) // No /_create command — memberContact provided expect(mainChat.sentCmds.some(c => c.includes("/_create member contact"))).toBe(false) // DM sent directly via memberContact const dm = mainChat.sent.find(m => m.chat[0] === "direct" && m.chat[1] === 42) expect(dm).toBeDefined() expect(dm!.text).toContain("keep this contact") expect(dm!.text).toContain("42:TeamGuy") }) test("member with no contact → create contact, invite, DM on contactConnected", async () => { mainChat.sent = [] mainChat.sentCmds = [] await bot.onMemberConnected({ groupInfo: {groupId: TEAM_GRP_ID}, member: {groupMemberId: 30, memberContactId: null, memberProfile: {displayName: "TeamGuy"}}, } as any) // /_create member contact and /_invite member contact sent expect(mainChat.sentCmds.some(c => c.includes("/_create member contact #1 30"))).toBe(true) expect(mainChat.sentCmds.some(c => c.includes("/_invite member contact @"))).toBe(true) // DM not sent yet — contact not connected expect(mainChat.sent.find(m => m.chat[0] === "direct")).toBeUndefined() // contactConnected fires → DM sent await bot.onContactConnected({contact: {contactId: 100}} as any) const dm = mainChat.sent.find(m => m.chat[0] === "direct" && m.chat[1] === 100) expect(dm).toBeDefined() expect(dm!.text).toContain("keep this contact") expect(dm!.text).toContain("100:TeamGuy") }) test("member with spaces in name → name quoted in DM", async () => { mainChat.sent = [] await bot.onMemberConnected({ groupInfo: {groupId: TEAM_GRP_ID}, member: {groupMemberId: 31, memberContactId: 7, memberProfile: {displayName: "Team Guy"}}, } as any) const dm = mainChat.sent.find(m => m.chat[0] === "direct" && m.chat[1] === 7) expect(dm).toBeDefined() expect(dm!.text).toContain("7:'Team Guy'") }) test("non-team group member connects → no create, no DM", async () => { mainChat.sent = [] mainChat.sentCmds = [] await bot.onMemberConnected({ groupInfo: {groupId: 999}, member: {groupMemberId: 30, memberContactId: null, memberProfile: {displayName: "Someone"}}, } as any) expect(mainChat.sentCmds.some(c => c.includes("/_create member contact"))).toBe(false) expect(mainChat.sent.find(m => m.chat[0] === "direct")).toBeUndefined() }) test("contactConnected for unknown contact → ignored", async () => { mainChat.sent = [] await bot.onContactConnected({contact: {contactId: 999}} as any) expect(mainChat.sent.find(m => m.chat[0] === "direct")).toBeUndefined() }) test("receivedInv fallback → DM queued and sent on contactConnected", async () => { mainChat.sent = [] await bot.onMemberContactReceivedInv({ contact: {contactId: 10}, groupInfo: {groupId: TEAM_GRP_ID}, member: {memberProfile: {displayName: "TeamGuy"}}, } as any) // DM not sent yet expect(mainChat.sent.find(m => m.chat[0] === "direct")).toBeUndefined() // contactConnected fires → DM sent await bot.onContactConnected({contact: {contactId: 10}} as any) const dm = mainChat.sent.find(m => m.chat[0] === "direct" && m.chat[1] === 10) expect(dm).toBeDefined() expect(dm!.text).toContain("keep this contact") }) test("non-team group receivedInv → no DM", async () => { mainChat.sent = [] await bot.onMemberContactReceivedInv({ contact: {contactId: 11}, groupInfo: {groupId: 999}, member: {memberProfile: {displayName: "Stranger"}}, } as any) await bot.onContactConnected({contact: {contactId: 11}} as any) expect(mainChat.sent.find(m => m.chat[0] === "direct")).toBeUndefined() }) }) // ─── 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 (with *NEW:* if still new)", 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 — first message still has *NEW:* marker 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) // Edit uses stored header from original forward. Original was first msg with QUEUE state, #1 expect(mainChat.updatedChatItems[0].msgContent).toEqual({type: "text", text: fmtNewCustomer("Edited question", "QUEUE", 1)}) }) test("team member edits forwarded message → team group message updated", async () => { await reachTeamPending() // After reachTeamPending: nextChatItemId=502, nextItemId=1004 (no command fwd) // 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) // Team member msg was #2 in TEAM state expect(mainChat.updatedChatItems[0].msgContent).toEqual({type: "text", text: fmtTeamMember(2, "Actually, let me rephrase", "TEAM", 2)}) }) 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=1005 (no command fwd) 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(1005) // Edit uses stored header from original forward: GROK state, #3 expect(mainChat.updatedChatItems[0].msgContent).toEqual({type: "text", text: fmtCustomer("Edited encryption question", "GROK", 3)}) }) 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") // Team member msg #2 in TEAM state teamGroup.received(fmtTeamMember(2, "I'll help you with this", "TEAM", 2)) }) test("team member message in teamLocked → forwarded to team group", async () => { await reachTeamLocked() mainChat.sent = [] await teamMember.sends("Here is the solution") // Team member msg #3 in TEAM state (after #1=Hello, #2=team "I'll help you") teamGroup.received(fmtTeamMember(2, "Here is the solution", "TEAM", 3)) }) 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 (mock has no chat history after reset, so isFirstCustomerMessage → true → NEW) // State is GROK (grok member present), #1 (first tracked msg) teamGroup.received(fmtNewCustomer("How does encryption work?", "GROK", 1)) }) 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 (with !1 NEW!) + /add command teamGroup.received(fmtNewCustomer("Hello, I need help", "QUEUE", 1)) 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", "QUEUE", 2)]) }) 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") }) }) // ─── 25b. Forwarded Message Reply-To ───────────────────────────── describe("Forwarded Message Reply-To", () => { test("customer reply-to is forwarded with inReplyTo to team group", async () => { // "Hello" gets chatItemId 500, forwarded → teamItemId 1000 await reachTeamQueue("Hello") // Send a reply to "Hello" (quotedItemId 500) await customer.sendsReplyTo("Following up on that", 500) const fwdMsg = mainChat.sent.find(m => m.chat[1] === TEAM_GRP_ID && m.text.includes("Following up on that")) expect(fwdMsg).toBeDefined() expect(fwdMsg!.inReplyTo).toBe(1000) }) test("customer reply-to unknown item → A1 threading falls back to lastTeamItemByGroup", async () => { await reachTeamQueue("Hello") // "Hello" teamItemId=1000. Reply-to unknown (999) → resolveTeamReplyTo returns undefined // But A1 threading: effectiveReplyTo = lastTeamItemByGroup = 1000 await customer.sendsReplyTo("Reply to unknown", 999) const fwdMsg = mainChat.sent.find(m => m.chat[1] === TEAM_GRP_ID && m.text.includes("Reply to unknown")) expect(fwdMsg).toBeDefined() expect(fwdMsg!.inReplyTo).toBe(1000) // A1: auto-thread to last team item }) test("customer message without reply-to → A1 auto-threads to last team item", async () => { await reachTeamQueue("Hello") // "Hello" teamItemId=1000 mainChat.sent = [] await customer.sends("Another question") const fwdMsg = mainChat.sent.find(m => m.chat[1] === TEAM_GRP_ID && m.text.includes("Another question")) expect(fwdMsg).toBeDefined() expect(fwdMsg!.inReplyTo).toBe(1000) // A1: auto-thread to last team item }) test("team member reply-to is forwarded with inReplyTo", async () => { // Customer "Hello" (chatItemId 500) → teamItemId 1000 await reachTeamPending() await teamMember.sendsReplyTo("I'll help with that", 500) const fwdMsg = mainChat.sent.find(m => m.chat[1] === TEAM_GRP_ID && m.text.includes("I'll help with that")) expect(fwdMsg).toBeDefined() expect(fwdMsg!.inReplyTo).toBe(1000) }) test("customer reply-to in grok mode forwarded with inReplyTo", async () => { await reachGrokMode("Initial answer") mainChat.chatItems.get(GROUP_ID)!.push({ chatDir: {type: "groupRcv", groupMember: {memberId: "grok-1", groupMemberId: 60, memberContactId: 4}}, _text: "Initial answer", }) grokChat.setChatItems(GROK_LOCAL, [ {chatDir: {type: "groupRcv"}, meta: {itemId: 5001}, _text: "Follow-up on my hello"}, ]) grokApi.willRespond("Follow-up answer") mainChat.sent = [] // Customer replies to their own "Hello" (itemId 500) which was forwarded (teamItemId 1000) await customer.sendsReplyTo("Follow-up on my hello", 500) // After reachGrokMode: #1=Hello, #2=Grok initial. Customer follow-up is #3 in GROK state const custFwd = mainChat.sent.find(m => m.chat[1] === TEAM_GRP_ID && m.text === fmtCustomer("Follow-up on my hello", "GROK", 3)) expect(custFwd).toBeDefined() expect(custFwd!.inReplyTo).toBe(1000) }) }) // ─── 25c. Grok Response Forwarded to Team ─────────────────────── describe("Grok Response Forwarded to Team", () => { test("activateGrok forwards grok response to team with reply-to", async () => { mainChat.setNextGroupMemberId(60) lastGrokMemberGId = 60 await reachTeamQueue("Hello") // "Hello" (chatItemId 500) → teamItemId 1000 grokChat.setChatItems(GROK_LOCAL, [ {chatDir: {type: "groupRcv"}, meta: {itemId: 6001}, _text: "Hello"}, ]) grokApi.willRespond("Hi there!") const p = customer.sends("/grok") mainChat.setGroupMembers(GROUP_ID, [ {groupMemberId: 60, memberContactId: 4, memberStatus: "connected"}, ]) await grokAgent.joins() await p // activateGrok: #1=Hello, Grok response=#2 in GROK state const grokFwd = mainChat.sent.find(m => m.chat[1] === TEAM_GRP_ID && m.text === fmtGrok("Hi there!", "GROK", 2)) expect(grokFwd).toBeDefined() expect(grokFwd!.inReplyTo).toBe(1000) }) test("forwardToGrok forwards grok response to team with reply-to", async () => { await reachGrokMode("Initial answer") // "Hello" (chatItemId 500) → teamItemId 1000 mainChat.chatItems.get(GROUP_ID)!.push({ chatDir: {type: "groupRcv", groupMember: {memberId: "grok-1", groupMemberId: 60, memberContactId: 4}}, _text: "Initial answer", }) grokChat.setChatItems(GROK_LOCAL, [ {chatDir: {type: "groupRcv"}, meta: {itemId: 5001}, _text: "What about encryption?"}, ]) grokApi.willRespond("Encryption answer") mainChat.sent = [] await customer.sends("What about encryption?") // Customer msg forwarded: #3 in GROK state (#1=Hello, #2=Grok initial) const custFwd = mainChat.sent.find(m => m.chat[1] === TEAM_GRP_ID && m.text === fmtCustomer("What about encryption?", "GROK", 3)) expect(custFwd).toBeDefined() // Grok response forwarded: #4 in GROK state const grokFwd = mainChat.sent.find(m => m.chat[1] === TEAM_GRP_ID && m.text === fmtGrok("Encryption answer", "GROK", 4)) expect(grokFwd).toBeDefined() // After reachGrokMode, mainChat.nextItemId = 1005 (no cmd fwd). Customer fwd gets 1005. expect(grokFwd!.inReplyTo).toBe(1005) }) test("grok response format includes customer prefix", async () => { await reachGrokMode("Test response") // activateGrok: #2 in GROK state const grokFwd = mainChat.sent.find(m => m.chat[1] === TEAM_GRP_ID && m.text === fmtGrok("Test response", "GROK", 2)) expect(grokFwd).toBeDefined() }) test("grok API failure does not forward to team", async () => { await reachGrokMode("Initial answer") mainChat.chatItems.get(GROUP_ID)!.push({ chatDir: {type: "groupRcv", groupMember: {memberId: "grok-1", groupMemberId: 60, memberContactId: 4}}, _text: "Initial answer", }) grokChat.setChatItems(GROK_LOCAL, [ {chatDir: {type: "groupRcv"}, meta: {itemId: 5001}, _text: "Fail me"}, ]) grokApi.willFail() mainChat.sent = [] await customer.sends("Fail me") // No Grok response forwarded to team (look for AI prefix) const grokFwd = mainChat.sent.find(m => m.chat[1] === TEAM_GRP_ID && m.text.startsWith("!5 AI!")) expect(grokFwd).toBeUndefined() }) }) // ─── 25d. Grok Reply-To ───────────────────────────────────────── describe("Grok Reply-To", () => { test("forwardToGrok replies to the last received message in grok chat", async () => { await reachGrokMode("Initial answer") // Simulate Grok agent's view: it has the previous customer message in its local chat mainChat.chatItems.get(GROUP_ID)!.push({ chatDir: {type: "groupRcv", groupMember: {memberId: "grok-1", groupMemberId: 60, memberContactId: 4}}, _text: "Initial answer", }) // Set up Grok agent's local chat with the new customer message (as Grok would see it) grokChat.setChatItems(GROK_LOCAL, [ {chatDir: {type: "groupRcv"}, meta: {itemId: 5001}, _text: "What about encryption?"}, ]) grokApi.willRespond("Encryption answer") grokChat.sent = [] await customer.sends("What about encryption?") // Grok response sent with inReplyTo matching the customer message item ID in Grok's view const grokSent = grokChat.sent.find(m => m.chat[1] === GROK_LOCAL && m.text === "Encryption answer") expect(grokSent).toBeDefined() expect(grokSent!.inReplyTo).toBe(5001) }) test("activateGrok replies to the last customer message", async () => { mainChat.setNextGroupMemberId(60) lastGrokMemberGId = 60 await reachTeamQueue("Hello") // Set up Grok agent's local chat — simulates Grok seeing the customer's message after join grokChat.setChatItems(GROK_LOCAL, [ {chatDir: {type: "groupRcv"}, meta: {itemId: 6001}, _text: "Hello"}, ]) grokApi.willRespond("Hi there!") const p = customer.sends("/grok") mainChat.setGroupMembers(GROUP_ID, [ {groupMemberId: 60, memberContactId: 4, memberStatus: "connected"}, ]) await grokAgent.joins() await p const grokSent = grokChat.sent.find(m => m.chat[1] === GROK_LOCAL && m.text === "Hi there!") expect(grokSent).toBeDefined() expect(grokSent!.inReplyTo).toBe(6001) }) test("activateGrok with multiple customer messages replies to the last one", async () => { mainChat.setNextGroupMemberId(60) lastGrokMemberGId = 60 await reachTeamQueue("First question", "Second question") // Grok agent sees both customer messages — reply should target the last one grokChat.setChatItems(GROK_LOCAL, [ {chatDir: {type: "groupRcv"}, meta: {itemId: 7001}, _text: "First question"}, {chatDir: {type: "groupRcv"}, meta: {itemId: 7002}, _text: "Second question"}, ]) grokApi.willRespond("Answer to both") const p = customer.sends("/grok") mainChat.setGroupMembers(GROUP_ID, [ {groupMemberId: 60, memberContactId: 4, memberStatus: "connected"}, ]) await grokAgent.joins() await p const grokSent = grokChat.sent.find(m => m.chat[1] === GROK_LOCAL && m.text === "Answer to both") expect(grokSent).toBeDefined() expect(grokSent!.inReplyTo).toBe(7002) }) test("graceful fallback when grok chat has no matching item", async () => { await reachGrokMode("Initial answer") mainChat.chatItems.get(GROUP_ID)!.push({ chatDir: {type: "groupRcv", groupMember: {memberId: "grok-1", groupMemberId: 60, memberContactId: 4}}, _text: "Initial answer", }) // Grok agent's chat is empty — no item to reply to grokChat.setChatItems(GROK_LOCAL, []) grokApi.willRespond("Some answer") grokChat.sent = [] await customer.sends("New question") // Response sent without inReplyTo (graceful fallback) const grokSent = grokChat.sent.find(m => m.chat[1] === GROK_LOCAL && m.text === "Some answer") expect(grokSent).toBeDefined() expect(grokSent!.inReplyTo).toBeUndefined() }) test("skips grok's own messages (groupSnd) when searching for reply target", async () => { await reachGrokMode("Initial answer") mainChat.chatItems.get(GROUP_ID)!.push({ chatDir: {type: "groupRcv", groupMember: {memberId: "grok-1", groupMemberId: 60, memberContactId: 4}}, _text: "Initial answer", }) // Grok's chat: has Grok's own previous response (groupSnd) then the customer message grokChat.setChatItems(GROK_LOCAL, [ {chatDir: {type: "groupSnd"}, meta: {itemId: 8001}, _text: "Follow-up question"}, {chatDir: {type: "groupRcv"}, meta: {itemId: 8002}, _text: "Follow-up question"}, ]) grokApi.willRespond("Follow-up answer") grokChat.sent = [] await customer.sends("Follow-up question") // Should reply to 8002 (groupRcv), not 8001 (groupSnd) const grokSent = grokChat.sent.find(m => m.chat[1] === GROK_LOCAL && m.text === "Follow-up answer") expect(grokSent).toBeDefined() expect(grokSent!.inReplyTo).toBe(8002) }) test("replies to last received even if text differs", async () => { await reachGrokMode("Initial answer") mainChat.chatItems.get(GROUP_ID)!.push({ chatDir: {type: "groupRcv", groupMember: {memberId: "grok-1", groupMemberId: 60, memberContactId: 4}}, _text: "Initial answer", }) // Grok's chat has a message with different text (e.g., previous message arrived but current hasn't yet) grokChat.setChatItems(GROK_LOCAL, [ {chatDir: {type: "groupRcv"}, meta: {itemId: 9001}, _text: "How does encryption work exactly?"}, ]) grokApi.willRespond("Partial answer") grokChat.sent = [] await customer.sends("How does encryption work?") // Replies to last received item regardless of text match const grokSent = grokChat.sent.find(m => m.chat[1] === GROK_LOCAL && m.text === "Partial answer") expect(grokSent).toBeDefined() expect(grokSent!.inReplyTo).toBe(9001) }) }) // ─── 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) ) }) }) // ─── 26. parseConfig & parseIdName ─────────────────────────────── describe("parseIdName", () => { test("parses valid id:name", () => { expect(parseIdName("2:Bob")).toEqual({id: 2, name: "Bob"}) }) test("parses name with colons", () => { expect(parseIdName("5:Alice:Admin")).toEqual({id: 5, name: "Alice:Admin"}) }) test("throws on missing colon", () => { expect(() => parseIdName("Bob")).toThrow('Invalid ID:name format: "Bob"') }) test("throws on non-numeric id", () => { expect(() => parseIdName("abc:Bob")).toThrow('Invalid ID:name format (non-numeric ID): "abc:Bob"') }) test("throws on colon at start", () => { expect(() => parseIdName(":Bob")).toThrow('Invalid ID:name format: ":Bob"') }) }) describe("parseConfig --team-members / --team-member aliases", () => { const baseArgs = ["--team-group", "Support Team"] beforeEach(() => { vi.stubEnv("GROK_API_KEY", "test-key") }) afterEach(() => { vi.unstubAllEnvs() }) test("--team-members with single member", () => { const config = parseConfig([...baseArgs, "--team-members", "2:Bob"]) expect(config.teamMembers).toEqual([{id: 2, name: "Bob"}]) }) test("--team-members with multiple comma-separated members", () => { const config = parseConfig([...baseArgs, "--team-members", "2:Bob,5:Alice"]) expect(config.teamMembers).toEqual([ {id: 2, name: "Bob"}, {id: 5, name: "Alice"}, ]) }) test("--team-member with single member", () => { const config = parseConfig([...baseArgs, "--team-member", "2:Bob"]) expect(config.teamMembers).toEqual([{id: 2, name: "Bob"}]) }) test("--team-member with multiple comma-separated members", () => { const config = parseConfig([...baseArgs, "--team-member", "2:Bob,5:Alice"]) expect(config.teamMembers).toEqual([ {id: 2, name: "Bob"}, {id: 5, name: "Alice"}, ]) }) test("both flags provided → members merged", () => { const config = parseConfig([...baseArgs, "--team-members", "2:Bob", "--team-member", "5:Alice"]) expect(config.teamMembers).toEqual([ {id: 2, name: "Bob"}, {id: 5, name: "Alice"}, ]) }) test("both flags with comma-separated values → all merged", () => { const config = parseConfig([...baseArgs, "--team-members", "2:Bob,3:Carol", "--team-member", "5:Alice"]) expect(config.teamMembers).toEqual([ {id: 2, name: "Bob"}, {id: 3, name: "Carol"}, {id: 5, name: "Alice"}, ]) }) test("neither flag → empty array", () => { const config = parseConfig(baseArgs) expect(config.teamMembers).toEqual([]) }) test("other config fields still parsed correctly", () => { const config = parseConfig([...baseArgs, "--team-member", "2:Bob", "--timezone", "US/Eastern"]) expect(config.teamGroup).toEqual({id: 0, name: "Support Team"}) expect(config.timezone).toBe("US/Eastern") expect(config.teamMembers).toEqual([{id: 2, name: "Bob"}]) }) }) // ─── 27. Message Truncation ────────────────────────────────── describe("Message Truncation", () => { test("short message forwarded unchanged (with !1 NEW! on first)", async () => { await customer.sends("Short question") teamGroup.received(fmtNewCustomer("Short question", "QUEUE", 1)) }) test("message exceeding limit is truncated with suffix", async () => { // Create a message that exceeds 15000 bytes when combined with prefix const longText = "A".repeat(15000) await customer.sends(longText) const teamMsgs = mainChat.sentTo(TEAM_GRP_ID) const fwdMsg = teamMsgs.find(m => m.startsWith("!1 NEW!")) expect(fwdMsg).toBeDefined() expect(fwdMsg!.endsWith("… [truncated]")).toBe(true) expect(new TextEncoder().encode(fwdMsg!).length).toBeLessThanOrEqual(15000) }) test("prefix is preserved in truncated message", async () => { const longText = "B".repeat(15000) await customer.sends(longText) const teamMsgs = mainChat.sentTo(TEAM_GRP_ID) const fwdMsg = teamMsgs.find(m => m.startsWith("!1 NEW!")) expect(fwdMsg).toBeDefined() // Header is intact at the start (with !1 NEW!) expect(fwdMsg!.startsWith(`!1 NEW! *${GROUP_ID}:Alice`)).toBe(true) expect(fwdMsg!.endsWith("… [truncated]")).toBe(true) }) test("edit of a long message is also truncated", async () => { // Send first message → forwarded to team (stores mapping) await customer.sends("Original question") // customerChatItem itemId=500, forwarded teamItemId=1000 mainChat.updatedChatItems = [] // Simulate edit with very long text — first message still has !1 NEW! marker const longEditText = "C".repeat(15000) 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: longEditText}, _text: longEditText, }, }, } as any) expect(mainChat.updatedChatItems.length).toBe(1) const updatedText = mainChat.updatedChatItems[0].msgContent.text expect(updatedText.endsWith("… [truncated]")).toBe(true) expect(updatedText.startsWith(`!1 NEW! *${GROUP_ID}:Alice`)).toBe(true) expect(new TextEncoder().encode(updatedText).length).toBeLessThanOrEqual(15000) }) test("Grok response to customer group is truncated when too long", async () => { const longGrokResponse = "D".repeat(16000) mainChat.setNextGroupMemberId(60) lastGrokMemberGId = 60 await reachTeamQueue("Hello") grokApi.willRespond(longGrokResponse) const p = customer.sends("/grok") mainChat.setGroupMembers(GROUP_ID, [ {groupMemberId: 60, memberContactId: 4, memberStatus: "connected"}, ]) await grokAgent.joins() await p // Grok response sent to customer group (via grokChat) should be truncated const grokMsgs = grokChat.sentTo(GROK_LOCAL) const grokMsg = grokMsgs.find(m => m.endsWith("… [truncated]")) expect(grokMsg).toBeDefined() expect(new TextEncoder().encode(grokMsg!).length).toBeLessThanOrEqual(15000) }) test("multi-byte characters are not broken by truncation", async () => { // Create a message with multi-byte chars that would be split mid-character const emoji = "\u{1F600}" // 4-byte emoji const longText = emoji.repeat(4000) // 16000 bytes await customer.sends(longText) const teamMsgs = mainChat.sentTo(TEAM_GRP_ID) const fwdMsg = teamMsgs.find(m => m.startsWith("!1 NEW!")) expect(fwdMsg).toBeDefined() expect(fwdMsg!.endsWith("… [truncated]")).toBe(true) // Verify no replacement character (U+FFFD) from broken multi-byte sequences expect(fwdMsg!).not.toContain("\uFFFD") expect(new TextEncoder().encode(fwdMsg!).length).toBeLessThanOrEqual(15000) }) }) // ─── 28. NEW: Prefix ──────────────────────────────────────────── describe("NEW: Prefix", () => { test("first customer text gets !1 NEW! prefix in team group", async () => { await customer.sends("How do I create a group?") teamGroup.received(fmtNewCustomer("How do I create a group?", "QUEUE", 1)) }) test("second customer message does NOT get !1 NEW!", async () => { await reachTeamQueue("First question") mainChat.sent = [] await customer.sends("More details") // Should be forwarded without !1 NEW! const teamMsgs = mainChat.sentTo(TEAM_GRP_ID) expect(teamMsgs).toContain(fmtCustomer("More details", "QUEUE", 2)) expect(teamMsgs.some(m => m.includes("!1 NEW!"))).toBe(false) }) test("/grok removes !1 NEW! (team message edited)", async () => { await customer.sends("Hello") // First message: chatItemId=500, teamItemId=1000 mainChat.updatedChatItems = [] mainChat.setNextGroupMemberId(60) lastGrokMemberGId = 60 grokApi.willRespond("Grok answer") const p = customer.sends("/grok") mainChat.setGroupMembers(GROUP_ID, [ {groupMemberId: 60, memberContactId: 4, memberStatus: "connected"}, ]) await grokAgent.joins() await p // Team message should have been edited to remove !1 NEW! → originalText (clean version) const update = mainChat.updatedChatItems.find(u => u.chatItemId === 1000) expect(update).toBeDefined() expect(update!.msgContent.text).toBe(fmtCustomer("Hello", "QUEUE", 1)) }) test("/team removes !1 NEW! (team message edited)", async () => { await customer.sends("Hello") // First message: chatItemId=500, teamItemId=1000 mainChat.updatedChatItems = [] mainChat.setGroupMembers(GROUP_ID, []) await customer.sends("/team") // Team message should have been edited to remove !1 NEW! → originalText const update = mainChat.updatedChatItems.find(u => u.chatItemId === 1000) expect(update).toBeDefined() expect(update!.msgContent.text).toBe(fmtCustomer("Hello", "QUEUE", 1)) }) test("/add command removes *NEW:* (team message edited)", async () => { await customer.sends("Hello") // First message: chatItemId=500, teamItemId=1000 mainChat.updatedChatItems = [] // Team member sends /add command in team 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 message should have been edited to remove !1 NEW! → originalText const update = mainChat.updatedChatItems.find(u => u.chatItemId === 1000) expect(update).toBeDefined() expect(update!.msgContent.text).toBe(fmtCustomer("Hello", "QUEUE", 1)) }) test("customer edit of first message preserves !1 NEW! prefix and updates originalText", async () => { await customer.sends("Original question") // First message: chatItemId=500, teamItemId=1000 mainChat.updatedChatItems = [] // 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].chatItemId).toBe(1000) // Edit should preserve !1 NEW! prefix (stored header is for #1 QUEUE) expect(mainChat.updatedChatItems[0].msgContent.text).toBe(fmtNewCustomer("Edited question", "QUEUE", 1)) // originalText should be updated to the clean version const newEntry = (bot as any).newItems.get(GROUP_ID) expect(newEntry).toBeDefined() expect(newEntry.originalText).toBe(fmtCustomer("Edited question", "QUEUE", 1)) }) test("/grok as first message — no *NEW:* created", async () => { mainChat.setNextGroupMemberId(60) lastGrokMemberGId = 60 grokApi.willRespond("Hello!") const p = customer.sends("/grok") mainChat.setGroupMembers(GROUP_ID, [ {groupMemberId: 60, memberContactId: 4, memberStatus: "connected"}, ]) await grokAgent.joins() await p // No *NEW:* entry created expect((bot as any).newItems.has(GROUP_ID)).toBe(false) }) test("/team as first message — no *NEW:* created", async () => { mainChat.setGroupMembers(GROUP_ID, []) await customer.sends("/team") // No *NEW:* entry created expect((bot as any).newItems.has(GROUP_ID)).toBe(false) }) test("24h expiry — removeNewPrefix skips edit for old entries", async () => { await customer.sends("Hello") // First message: chatItemId=500, teamItemId=1000 mainChat.updatedChatItems = [] // Manually age the entry to > 24h const entry = (bot as any).newItems.get(GROUP_ID) entry.timestamp = Date.now() - 25 * 60 * 60 * 1000 // Trigger removeNewPrefix via /team mainChat.setGroupMembers(GROUP_ID, []) await customer.sends("/team") // newItems should be cleared expect((bot as any).newItems.has(GROUP_ID)).toBe(false) // But no edit should have been made (expired) const update = mainChat.updatedChatItems.find(u => u.chatItemId === 1000) expect(update).toBeUndefined() }) test("customer leaves — newItems cleaned up", async () => { await customer.sends("Hello") expect((bot as any).newItems.has(GROUP_ID)).toBe(true) await customer.leaves() expect((bot as any).newItems.has(GROUP_ID)).toBe(false) }) test("persistence — restoreNewItems prunes expired entries", () => { const now = Date.now() const fresh = {teamItemId: 100, timestamp: now - 1000, originalText: "fresh"} const expired = {teamItemId: 200, timestamp: now - 25 * 60 * 60 * 1000, originalText: "old"} bot.restoreNewItems([ [GROUP_ID, fresh], [300, expired], ]) expect((bot as any).newItems.has(GROUP_ID)).toBe(true) expect((bot as any).newItems.has(300)).toBe(false) expect((bot as any).newItems.size).toBe(1) }) test("multiple groups — independent tracking", async () => { const GROUP_A = 100 const GROUP_B = 300 // Group A: first customer message 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) // Group B: first customer message 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) // Both groups should have newItems entries expect((bot as any).newItems.has(GROUP_A)).toBe(true) expect((bot as any).newItems.has(GROUP_B)).toBe(true) // Claim Group A via /team — only removes A's *NEW:* mainChat.setGroupMembers(GROUP_A, []) mainChat.updatedChatItems = [] const teamCi = customerChatItem("/team", "team") teamCi.chatInfo.groupInfo = businessGroupInfo(GROUP_A, "Alice") mainChat.chatItems.get(GROUP_A)!.push({ chatDir: {type: "groupRcv", groupMember: {memberId: CUSTOMER_ID, groupMemberId: 10}}, _text: "/team", _botCommand: "team", }) await bot.onNewChatItems({chatItems: [teamCi]} as any) expect((bot as any).newItems.has(GROUP_A)).toBe(false) expect((bot as any).newItems.has(GROUP_B)).toBe(true) }) test("onNewItemsChanged fires on first message", async () => { const callback = vi.fn() bot.onNewItemsChanged = callback await customer.sends("Hello") expect(callback).toHaveBeenCalled() const lastCallArg = callback.mock.calls[callback.mock.calls.length - 1][0] expect(lastCallArg.has(GROUP_ID)).toBe(true) }) test("onNewItemsChanged fires on removal", async () => { await customer.sends("Hello") const callback = vi.fn() bot.onNewItemsChanged = callback mainChat.setGroupMembers(GROUP_ID, []) await customer.sends("/team") expect(callback).toHaveBeenCalled() const lastCallArg = callback.mock.calls[callback.mock.calls.length - 1][0] expect(lastCallArg.has(GROUP_ID)).toBe(false) }) }) // ─── 29. Direct Message Reply ────────────────────────────────── describe("Direct Message Reply", () => { test("direct message → replies with business address redirect", async () => { bot.businessAddress = "https://simplex.chat/contact#abc123" const ci = { chatInfo: {type: "direct", contact: {contactId: 99}}, chatItem: { chatDir: {type: "directRcv"}, meta: {itemId: 900}, content: {type: "text", text: "Hello, I have a question"}, _text: "Hello, I have a question", }, } as any await bot.onNewChatItems({chatItems: [ci]} as any) const reply = mainChat.sent.find(m => m.chat[0] === "direct" && m.chat[1] === 99) expect(reply).toBeDefined() expect(reply!.text).toBe( "I can't answer your questions on non-business address, please add me through my business address: https://simplex.chat/contact#abc123" ) }) test("direct message without business address → no reply", async () => { bot.businessAddress = null const ci = { chatInfo: {type: "direct", contact: {contactId: 99}}, chatItem: { chatDir: {type: "directRcv"}, meta: {itemId: 901}, content: {type: "text", text: "Hello"}, _text: "Hello", }, } as any await bot.onNewChatItems({chatItems: [ci]} as any) const reply = mainChat.sent.find(m => m.chat[0] === "direct" && m.chat[1] === 99) expect(reply).toBeUndefined() }) test("direct message does not get forwarded to team group", async () => { bot.businessAddress = "https://simplex.chat/contact#abc123" const ci = { chatInfo: {type: "direct", contact: {contactId: 99}}, chatItem: { chatDir: {type: "directRcv"}, meta: {itemId: 902}, content: {type: "text", text: "Some question"}, _text: "Some question", }, } as any await bot.onNewChatItems({chatItems: [ci]} as any) teamGroup.receivedNothing() }) }) // ─── 30. /inviteall & /invitenew Commands ──────────────────────── function teamGroupCommand(text: string, senderContactId = 2) { return { chatInfo: {type: "group", groupInfo: {groupId: TEAM_GRP_ID, groupProfile: {displayName: "SupportTeam"}}}, chatItem: { chatDir: { type: "groupRcv", groupMember: {memberId: "tm-1", groupMemberId: 30, memberContactId: senderContactId, memberProfile: {displayName: "Bob"}}, }, meta: {itemId: nextChatItemId++}, content: {type: "text", text}, _text: text, }, } as any } describe("/inviteall & /invitenew Commands", () => { const GROUP_A = 300 const GROUP_B = 301 const GROUP_C = 302 function setGroupLastActive(groups: [number, number][]) { bot.restoreGroupLastActive(groups) } test("/inviteall invites sender to groups active within 24h", async () => { const now = Date.now() // Group A active 1h ago, Group B active 2h ago — both within 24h setGroupLastActive([[GROUP_A, now - 1 * 60 * 60 * 1000], [GROUP_B, now - 2 * 60 * 60 * 1000]]) mainChat.setGroupMembers(GROUP_A, []) mainChat.setGroupMembers(GROUP_B, []) await bot.onNewChatItems({chatItems: [teamGroupCommand("/inviteall")]} as any) const addedA = mainChat.added.find(a => a.groupId === GROUP_A && a.contactId === 2) const addedB = mainChat.added.find(a => a.groupId === GROUP_B && a.contactId === 2) expect(addedA).toBeDefined() expect(addedB).toBeDefined() }) test("/inviteall skips groups with last activity older than 24h", async () => { const now = Date.now() // Group A active 25h ago — outside 24h window setGroupLastActive([[GROUP_A, now - 25 * 60 * 60 * 1000]]) mainChat.setGroupMembers(GROUP_A, []) await bot.onNewChatItems({chatItems: [teamGroupCommand("/inviteall")]} as any) const addedA = mainChat.added.find(a => a.groupId === GROUP_A) expect(addedA).toBeUndefined() }) test("/inviteall skips groups where sender is already a member", async () => { const now = Date.now() setGroupLastActive([[GROUP_A, now - 1 * 60 * 60 * 1000]]) // Sender (contactId=2) already in group A mainChat.setGroupMembers(GROUP_A, [ {groupMemberId: 70, memberContactId: 2, memberStatus: "connected"}, ]) await bot.onNewChatItems({chatItems: [teamGroupCommand("/inviteall")]} as any) const addedA = mainChat.added.find(a => a.groupId === GROUP_A && a.contactId === 2) expect(addedA).toBeUndefined() }) test("/inviteall sends summary to team group", async () => { const now = Date.now() setGroupLastActive([[GROUP_A, now - 1 * 60 * 60 * 1000]]) mainChat.setGroupMembers(GROUP_A, []) await bot.onNewChatItems({chatItems: [teamGroupCommand("/inviteall")]} as any) const teamMsgs = mainChat.sentTo(TEAM_GRP_ID) const summary = teamMsgs.find(m => m.includes("Invited to") && m.includes("active in 24h")) expect(summary).toBeDefined() }) test("/invitenew invites sender only to groups with no grok and no team", async () => { const now = Date.now() // Group A: no special members, Group B: has team, Group C: has grok setGroupLastActive([ [GROUP_A, now - 1 * 60 * 60 * 1000], [GROUP_B, now - 1 * 60 * 60 * 1000], [GROUP_C, now - 1 * 60 * 60 * 1000], ]) mainChat.setGroupMembers(GROUP_A, []) mainChat.setGroupMembers(GROUP_B, [ {groupMemberId: 71, memberContactId: 2, memberStatus: "connected"}, ]) mainChat.setGroupMembers(GROUP_C, [ {groupMemberId: 72, memberContactId: 4, memberStatus: "connected"}, ]) await bot.onNewChatItems({chatItems: [teamGroupCommand("/invitenew")]} as any) const addedA = mainChat.added.find(a => a.groupId === GROUP_A && a.contactId === 2) expect(addedA).toBeDefined() // B and C should NOT be invited (filtered by composition, not by already-member check) const addedB = mainChat.added.find(a => a.groupId === GROUP_B && a.contactId === 2) const addedC = mainChat.added.find(a => a.groupId === GROUP_C && a.contactId === 2) expect(addedB).toBeUndefined() expect(addedC).toBeUndefined() }) test("/invitenew skips groups with grok member", async () => { const now = Date.now() setGroupLastActive([[GROUP_A, now - 1 * 60 * 60 * 1000]]) mainChat.setGroupMembers(GROUP_A, [ {groupMemberId: 72, memberContactId: 4, memberStatus: "connected"}, ]) await bot.onNewChatItems({chatItems: [teamGroupCommand("/invitenew")]} as any) const addedA = mainChat.added.find(a => a.groupId === GROUP_A) expect(addedA).toBeUndefined() }) test("/invitenew skips groups with team member", async () => { const now = Date.now() setGroupLastActive([[GROUP_A, now - 1 * 60 * 60 * 1000]]) // Team member contactId=2 already in group as a member (not the sender checking membership — // this is the composition check) mainChat.setGroupMembers(GROUP_A, [ {groupMemberId: 71, memberContactId: 2, memberStatus: "connected"}, ]) await bot.onNewChatItems({chatItems: [teamGroupCommand("/invitenew")]} as any) const addedA = mainChat.added.find(a => a.groupId === GROUP_A) expect(addedA).toBeUndefined() }) test("/invitenew skips groups with last activity older than 48h", async () => { const now = Date.now() setGroupLastActive([[GROUP_A, now - 49 * 60 * 60 * 1000]]) mainChat.setGroupMembers(GROUP_A, []) await bot.onNewChatItems({chatItems: [teamGroupCommand("/invitenew")]} as any) const addedA = mainChat.added.find(a => a.groupId === GROUP_A) expect(addedA).toBeUndefined() }) test("/inviteall removes !1 NEW! prefix on invited groups", async () => { const now = Date.now() setGroupLastActive([[GROUP_A, now - 1 * 60 * 60 * 1000]]) mainChat.setGroupMembers(GROUP_A, []) // First, create a NEW item for GROUP_A by simulating first customer message mainChat.setChatItems(GROUP_A, [{chatDir: {type: "groupSnd"}, _text: "Welcome!"}]) const ci = customerChatItem("Help me", null) ci.chatInfo.groupInfo = businessGroupInfo(GROUP_A, "TestUser") mainChat.chatItems.get(GROUP_A)!.push({ chatDir: {type: "groupRcv", groupMember: {memberId: CUSTOMER_ID, groupMemberId: 10}}, _text: "Help me", }) await bot.onNewChatItems({chatItems: [ci]} as any) // Verify !1 NEW! prefix was set const newMsgs = mainChat.sentTo(TEAM_GRP_ID).filter(m => m.startsWith("!1 NEW!")) expect(newMsgs.length).toBe(1) mainChat.updatedChatItems = [] await bot.onNewChatItems({chatItems: [teamGroupCommand("/inviteall")]} as any) // NEW prefix should have been removed (apiUpdateChatItem called) expect(mainChat.updatedChatItems.length).toBeGreaterThan(0) const update = mainChat.updatedChatItems.find(u => u.chatId === TEAM_GRP_ID) expect(update).toBeDefined() expect(update!.msgContent.text).not.toContain("!1 NEW!") }) test("groupLastActive updated on every customer text message", async () => { const callback = vi.fn() bot.onGroupLastActiveChanged = callback await customer.sends("Hello") expect(callback).toHaveBeenCalledTimes(1) await customer.sends("Follow up") expect(callback).toHaveBeenCalledTimes(2) }) test("groupLastActive NOT updated on non-text events", async () => { const callback = vi.fn() bot.onGroupLastActiveChanged = callback await customer.sendsNonText() expect(callback).not.toHaveBeenCalled() }) test("groupLastActive NOT updated on command-only messages (/team)", async () => { // Reach teamQueue first so /team doesn't trigger welcome flow await reachTeamQueue("Hello") const callback = vi.fn() bot.onGroupLastActiveChanged = callback // /team command should not count as customer text activity mainChat.setGroupMembers(GROUP_ID, []) await customer.sends("/team") expect(callback).not.toHaveBeenCalled() }) test("groupLastActive cleaned up on customer leave", async () => { const callback = vi.fn() bot.onGroupLastActiveChanged = callback await customer.sends("Hello") expect(callback).toHaveBeenCalledTimes(1) await customer.leaves() // Called again on leave (deletion) expect(callback).toHaveBeenCalledTimes(2) }) test("restoreGroupLastActive prunes entries older than 48h", async () => { const now = Date.now() const entries: [number, number][] = [ [GROUP_A, now - 1 * 60 * 60 * 1000], // 1h ago — kept [GROUP_B, now - 49 * 60 * 60 * 1000], // 49h ago — pruned [GROUP_C, now - 47 * 60 * 60 * 1000], // 47h ago — kept ] const freshBot = new SupportBot(mainChat as any, grokChat as any, grokApi as any, config as any) freshBot.restoreGroupLastActive(entries) // Verify via /inviteall (24h window): only GROUP_A qualifies mainChat.setGroupMembers(GROUP_A, []) mainChat.setGroupMembers(GROUP_B, []) mainChat.setGroupMembers(GROUP_C, []) mainChat.added = [] await freshBot.onNewChatItems({chatItems: [teamGroupCommand("/inviteall")]} as any) // GROUP_A (1h ago) → within 24h → invited const addedA = mainChat.added.find(a => a.groupId === GROUP_A) expect(addedA).toBeDefined() // GROUP_B (49h ago) → pruned at restore → not invited const addedB = mainChat.added.find(a => a.groupId === GROUP_B) expect(addedB).toBeUndefined() // GROUP_C (47h ago) → restored but outside 24h → not invited by inviteall const addedC = mainChat.added.find(a => a.groupId === GROUP_C) expect(addedC).toBeUndefined() }) }) // ─── 31. Welcome Flow Deduplication ──────────────────────────── describe("Welcome Flow Deduplication", () => { test("teamQueueMessage not re-sent when chat history overflows past 20 items", async () => { // First message → welcome flow: teamQueueMessage sent await customer.sends("Hello") customer.received(TEAM_QUEUE_24H) // Simulate long Grok conversation: clear chat items so "forwarded to the team" // is no longer in history (as if it scrolled past the 20-item window) mainChat.chatItems.set(GROUP_ID, []) mainChat.sent = [] // Next customer message should NOT trigger teamQueueMessage again await customer.sends("Follow-up question") // Message forwarded to team (normal), but NO teamQueueMessage re-sent teamGroup.received(fmtCustomer("Follow-up question", "QUEUE", 2)) const teamQueueMsgs = mainChat.sentTo(GROUP_ID).filter(m => m.includes("forwarded to the team")) expect(teamQueueMsgs.length).toBe(0) }) test("welcomeCompleted cache cleared on customer leave — new customer gets welcome", async () => { // First customer triggers welcome await customer.sends("Hello") customer.received(TEAM_QUEUE_24H) // Customer leaves → cache cleared await customer.leaves() // Clear sent history for clean assertions mainChat.sent = [] mainChat.chatItems.set(GROUP_ID, []) // New customer in same group → welcome flow should trigger again await customer.sends("New question") customer.received(TEAM_QUEUE_24H) }) test("second message in same session never re-sends teamQueueMessage", async () => { await customer.sends("First question") mainChat.sent = [] await customer.sends("Second question") // Only the forwarded message, no teamQueueMessage const customerMsgs = mainChat.sentTo(GROUP_ID) expect(customerMsgs.filter(m => m.includes("forwarded to the team")).length).toBe(0) }) }) // ─── 32. A1: Reply-to-last Threading ────────────────────────────── describe("A1: Reply-to-last Threading", () => { test("first customer message in new group has no inReplyTo (no prior team item)", async () => { await customer.sends("Hello") const fwdMsg = mainChat.sent.find(m => m.chat[1] === TEAM_GRP_ID && m.text.includes("Hello")) expect(fwdMsg).toBeDefined() expect(fwdMsg!.inReplyTo).toBeUndefined() }) test("second customer message auto-threads to last team item", async () => { await reachTeamQueue("Hello") // Hello's teamItemId = 1000 mainChat.sent = [] await customer.sends("Follow-up") const fwdMsg = mainChat.sent.find(m => m.chat[1] === TEAM_GRP_ID && m.text.includes("Follow-up")) expect(fwdMsg).toBeDefined() // A1: threads to 1000 (last team item for this group) expect(fwdMsg!.inReplyTo).toBe(1000) }) test("third message threads to the second message's team item, not the first", async () => { await reachTeamQueue("Hello") // Hello teamItemId=1000. After reachTeamQueue: nextItemId=1003 (1001=queue msg, 1002=/add) await customer.sends("Second msg") // Second msg teamItemId = 1003 mainChat.sent = [] await customer.sends("Third msg") const fwdMsg = mainChat.sent.find(m => m.chat[1] === TEAM_GRP_ID && m.text.includes("Third msg")) expect(fwdMsg).toBeDefined() // A1: threads to 1003 (last team item after second message) expect(fwdMsg!.inReplyTo).toBe(1003) }) test("explicit reply-to takes precedence over auto-threading", async () => { await reachTeamQueue("Hello") // Hello chatItemId=500 → teamItemId=1000. nextItemId=1003. await customer.sends("Second msg") // Second chatItemId=501 → teamItemId=1003 (lastTeamItemByGroup=1003) mainChat.sent = [] // Reply to the original "Hello" (chatItemId=500 → teamItemId=1000) await customer.sendsReplyTo("Reply to hello", 500) const fwdMsg = mainChat.sent.find(m => m.chat[1] === TEAM_GRP_ID && m.text.includes("Reply to hello")) expect(fwdMsg).toBeDefined() // Explicit reply-to (1000) takes precedence over auto-thread (1003) expect(fwdMsg!.inReplyTo).toBe(1000) }) test("team member message also updates lastTeamItemByGroup", async () => { await reachTeamPending() // Hello teamItemId=1000. /team didn't forward. await teamMember.sends("I'll help") // Team member's teamItemId = 1004 mainChat.sent = [] await customer.sends("Thanks!") const fwdMsg = mainChat.sent.find(m => m.chat[1] === TEAM_GRP_ID && m.text.includes("Thanks!")) expect(fwdMsg).toBeDefined() // A1: threads to 1004 (team member's forwarded item) expect(fwdMsg!.inReplyTo).toBe(1004) }) test("grok response also updates lastTeamItemByGroup", async () => { await reachGrokMode("Grok answer") // Hello teamItemId=1000. After reachTeamQueue: nextItemId=1003. Grok activated msg=1003. // activateGrok: Grok response forwarded → teamItemId=1004 mainChat.chatItems.get(GROUP_ID)!.push({ chatDir: {type: "groupRcv", groupMember: {memberId: "grok-1", groupMemberId: 60, memberContactId: 4}}, _text: "Grok answer", }) grokApi.willRespond("More answer") mainChat.sent = [] await customer.sends("Follow-up") const custFwd = mainChat.sent.find(m => m.chat[1] === TEAM_GRP_ID && m.text.includes("Follow-up")) expect(custFwd).toBeDefined() // Customer follow-up should thread to grok response's team item (1004) expect(custFwd!.inReplyTo).toBe(1004) }) test("customer leave clears lastTeamItemByGroup for that group", async () => { await reachTeamQueue("Hello") expect((bot as any).lastTeamItemByGroup.has(GROUP_ID)).toBe(true) await customer.leaves() expect((bot as any).lastTeamItemByGroup.has(GROUP_ID)).toBe(false) }) test("customer leave clears forwardedItems for that group", async () => { await reachTeamQueue("Hello") // After reachTeamQueue, forwardedItems has entry for "100:500" (Hello chatItemId=500) expect((bot as any).forwardedItems.size).toBeGreaterThan(0) const hasGroupEntry = [...(bot as any).forwardedItems.keys()].some((k: string) => k.startsWith(`${GROUP_ID}:`)) expect(hasGroupEntry).toBe(true) await customer.leaves() const hasGroupEntryAfter = [...(bot as any).forwardedItems.keys()].some((k: string) => k.startsWith(`${GROUP_ID}:`)) expect(hasGroupEntryAfter).toBe(false) }) }) // ─── 33. A6: Non-Text Content Indicators ────────────────────────── describe("A6: Non-Text Content Indicators", () => { test("image message → _[image]_ indicator in team forward", async () => { // First message to get past welcome await reachTeamQueue("Hello") mainChat.sent = [] // Send image with caption const ci = { chatInfo: {type: "group", groupInfo: businessGroupInfo()}, chatItem: { chatDir: {type: "groupRcv", groupMember: {memberId: CUSTOMER_ID, groupMemberId: 10}}, meta: {itemId: nextChatItemId++}, content: {type: "rcvMsgContent", msgContent: {type: "image", text: "check this"}}, _text: "check this", }, } as any mainChat.chatItems.get(GROUP_ID)!.push({ chatDir: {type: "groupRcv", groupMember: {memberId: CUSTOMER_ID, groupMemberId: 10}}, _text: "check this", }) await bot.onNewChatItems({chatItems: [ci]} as any) const teamMsgs = mainChat.sentTo(TEAM_GRP_ID) const fwd = teamMsgs.find(m => m.includes("_[image]_") && m.includes("check this")) expect(fwd).toBeDefined() }) test("file message without caption → _[file]_ only", async () => { await reachTeamQueue("Hello") mainChat.sent = [] const ci = { chatInfo: {type: "group", groupInfo: businessGroupInfo()}, chatItem: { chatDir: {type: "groupRcv", groupMember: {memberId: CUSTOMER_ID, groupMemberId: 10}}, meta: {itemId: nextChatItemId++}, content: {type: "rcvMsgContent", msgContent: {type: "file", text: ""}}, _text: null, }, } as any mainChat.chatItems.get(GROUP_ID)!.push({ chatDir: {type: "groupRcv", groupMember: {memberId: CUSTOMER_ID, groupMemberId: 10}}, _text: null, }) await bot.onNewChatItems({chatItems: [ci]} as any) const teamMsgs = mainChat.sentTo(TEAM_GRP_ID) const fwd = teamMsgs.find(m => m.includes("_[file]_")) expect(fwd).toBeDefined() }) test("voice message → _[voice]_ indicator", async () => { await reachTeamQueue("Hello") mainChat.sent = [] const ci = { chatInfo: {type: "group", groupInfo: businessGroupInfo()}, chatItem: { chatDir: {type: "groupRcv", groupMember: {memberId: CUSTOMER_ID, groupMemberId: 10}}, meta: {itemId: nextChatItemId++}, content: {type: "rcvMsgContent", msgContent: {type: "voice", text: "", duration: 5}}, _text: null, }, } as any mainChat.chatItems.get(GROUP_ID)!.push({ chatDir: {type: "groupRcv", groupMember: {memberId: CUSTOMER_ID, groupMemberId: 10}}, _text: null, }) await bot.onNewChatItems({chatItems: [ci]} as any) const teamMsgs = mainChat.sentTo(TEAM_GRP_ID) const fwd = teamMsgs.find(m => m.includes("_[voice]_")) expect(fwd).toBeDefined() }) test("video message with caption → _[video]_ caption", async () => { await reachTeamQueue("Hello") mainChat.sent = [] const ci = { chatInfo: {type: "group", groupInfo: businessGroupInfo()}, chatItem: { chatDir: {type: "groupRcv", groupMember: {memberId: CUSTOMER_ID, groupMemberId: 10}}, meta: {itemId: nextChatItemId++}, content: {type: "rcvMsgContent", msgContent: {type: "video", text: "my screen recording"}}, _text: "my screen recording", }, } as any mainChat.chatItems.get(GROUP_ID)!.push({ chatDir: {type: "groupRcv", groupMember: {memberId: CUSTOMER_ID, groupMemberId: 10}}, _text: "my screen recording", }) await bot.onNewChatItems({chatItems: [ci]} as any) const teamMsgs = mainChat.sentTo(TEAM_GRP_ID) const fwd = teamMsgs.find(m => m.includes("_[video]_") && m.includes("my screen recording")) expect(fwd).toBeDefined() }) test("regular text message has no content type indicator", async () => { await reachTeamQueue("Hello") mainChat.sent = [] await customer.sends("Just text") const teamMsgs = mainChat.sentTo(TEAM_GRP_ID) const fwd = teamMsgs.find(m => m.includes("Just text")) expect(fwd).toBeDefined() expect(fwd).not.toContain("_[") }) }) // ─── 34. D1: /pending Command ───────────────────────────────────── describe("D1: /pending Command", () => { test("/pending with no active groups → 'No pending conversations.'", async () => { mainChat.sent = [] await bot.onNewChatItems({chatItems: [teamGroupCommand("/pending")]} as any) const teamMsgs = mainChat.sentTo(TEAM_GRP_ID) expect(teamMsgs).toContain("No pending conversations.") }) test("/pending with customer message (no grok/team reply) → listed as pending", async () => { await customer.sends("Help me") mainChat.sent = [] await bot.onNewChatItems({chatItems: [teamGroupCommand("/pending")]} as any) const teamMsgs = mainChat.sentTo(TEAM_GRP_ID) const pendingMsg = teamMsgs.find(m => m.includes("*Pending")) expect(pendingMsg).toBeDefined() expect(pendingMsg).toContain(`${GROUP_ID}:Alice`) expect(pendingMsg).toContain("QUEUE") }) test("/pending: grok response makes group not pending", async () => { await reachGrokMode("Grok answer") // After Grok answer, last event is from grok → not pending mainChat.sent = [] await bot.onNewChatItems({chatItems: [teamGroupCommand("/pending")]} as any) const teamMsgs = mainChat.sentTo(TEAM_GRP_ID) expect(teamMsgs).toContain("No pending conversations.") }) test("/pending: team member response makes group not pending", async () => { await reachTeamLocked() // After team member msg, last event is from team → not pending mainChat.sent = [] await bot.onNewChatItems({chatItems: [teamGroupCommand("/pending")]} as any) const teamMsgs = mainChat.sentTo(TEAM_GRP_ID) expect(teamMsgs).toContain("No pending conversations.") }) test("/pending: customer message after grok → pending again", async () => { await reachGrokMode("Grok answer") // Grok answered → not pending // Customer sends follow-up in grok mode mainChat.chatItems.get(GROUP_ID)!.push({ chatDir: {type: "groupRcv", groupMember: {memberId: "grok-1", groupMemberId: 60, memberContactId: 4}}, _text: "Grok answer", }) grokApi.willRespond("Follow-up answer") await customer.sends("More questions") // Customer message updates pending to "customer" → but then Grok responds, updating to "grok" // So after this, last event is from grok (the follow-up answer) mainChat.sent = [] await bot.onNewChatItems({chatItems: [teamGroupCommand("/pending")]} as any) const teamMsgs = mainChat.sentTo(TEAM_GRP_ID) // Grok responded last, so not pending expect(teamMsgs).toContain("No pending conversations.") }) test("/pending: customer reaction while last message is from team → not pending", async () => { await reachTeamLocked() // Team member sent last message → not pending // Now customer reacts await bot.onChatItemReaction({ added: true, reaction: { chatInfo: {type: "group", groupInfo: businessGroupInfo()}, chatReaction: { chatDir: {type: "groupRcv", groupMember: {memberId: CUSTOMER_ID, groupMemberId: 10}}, chatItem: {meta: {itemId: 500}}, sentAt: new Date().toISOString(), reaction: {type: "emoji", emoji: "👍"}, }, }, } as any) mainChat.sent = [] await bot.onNewChatItems({chatItems: [teamGroupCommand("/pending")]} as any) const teamMsgs = mainChat.sentTo(TEAM_GRP_ID) // Customer reaction, but last message was from team → not pending expect(teamMsgs).toContain("No pending conversations.") }) test("/pending: team reaction makes group not pending", async () => { await customer.sends("Need help") // Customer msg → pending // Team member reacts await bot.onChatItemReaction({ added: true, reaction: { chatInfo: {type: "group", groupInfo: businessGroupInfo()}, chatReaction: { chatDir: {type: "groupRcv", groupMember: {memberId: "team-member-1", groupMemberId: 50, memberContactId: 2}}, chatItem: {meta: {itemId: 500}}, sentAt: new Date().toISOString(), reaction: {type: "emoji", emoji: "👍"}, }, }, } as any) mainChat.sent = [] await bot.onNewChatItems({chatItems: [teamGroupCommand("/pending")]} as any) const teamMsgs = mainChat.sentTo(TEAM_GRP_ID) // Team reacted → not pending expect(teamMsgs).toContain("No pending conversations.") }) test("/pending: customer reaction while last message is from customer → still pending", async () => { await customer.sends("Help me") // Customer msg → pending (last event: customer message) // Customer reacts (last event: customer reaction, last message: customer) await bot.onChatItemReaction({ added: true, reaction: { chatInfo: {type: "group", groupInfo: businessGroupInfo()}, chatReaction: { chatDir: {type: "groupRcv", groupMember: {memberId: CUSTOMER_ID, groupMemberId: 10}}, chatItem: {meta: {itemId: 500}}, sentAt: new Date().toISOString(), reaction: {type: "emoji", emoji: "👍"}, }, }, } as any) mainChat.sent = [] await bot.onNewChatItems({chatItems: [teamGroupCommand("/pending")]} as any) const teamMsgs = mainChat.sentTo(TEAM_GRP_ID) // Customer reaction AND last message was from customer → still pending const pendingMsg = teamMsgs.find(m => m.includes("*Pending")) expect(pendingMsg).toBeDefined() expect(pendingMsg).toContain(`${GROUP_ID}:Alice`) }) test("/pending: non-business-chat group reaction → ignored", async () => { // Reaction in non-business group should not crash await bot.onChatItemReaction({ added: true, reaction: { chatInfo: {type: "group", groupInfo: {groupId: 999, groupProfile: {displayName: "X"}}}, chatReaction: { chatDir: {type: "groupRcv", groupMember: {memberId: "someone"}}, chatItem: {meta: {itemId: 1}}, sentAt: new Date().toISOString(), reaction: {type: "emoji", emoji: "👍"}, }, }, } as any) // No crash = success }) test("/pending: removed reaction (added=false) → ignored", async () => { await customer.sends("Help me") // Customer msg → pending mainChat.sent = [] // Team removes reaction await bot.onChatItemReaction({ added: false, reaction: { chatInfo: {type: "group", groupInfo: businessGroupInfo()}, chatReaction: { chatDir: {type: "groupRcv", groupMember: {memberId: "team-member-1", groupMemberId: 50, memberContactId: 2}}, chatItem: {meta: {itemId: 500}}, sentAt: new Date().toISOString(), reaction: {type: "emoji", emoji: "👍"}, }, }, } as any) await bot.onNewChatItems({chatItems: [teamGroupCommand("/pending")]} as any) const teamMsgs = mainChat.sentTo(TEAM_GRP_ID) // Removed reaction should be ignored → still pending (customer msg was last real event) const pendingMsg = teamMsgs.find(m => m.includes("*Pending")) expect(pendingMsg).toBeDefined() }) test("/pending: group with no pending info but with lastActive → listed as pending", async () => { // Simulate a group that has lastActive but no pendingInfo (e.g., after restart) bot.restoreGroupLastActive([[GROUP_ID, Date.now()]]) mainChat.sent = [] await bot.onNewChatItems({chatItems: [teamGroupCommand("/pending")]} as any) const teamMsgs = mainChat.sentTo(TEAM_GRP_ID) const pendingMsg = teamMsgs.find(m => m.includes("*Pending")) expect(pendingMsg).toBeDefined() }) test("groupPendingInfo cleaned up on customer leave", async () => { await customer.sends("Hello") expect((bot as any).groupPendingInfo.has(GROUP_ID)).toBe(true) await customer.leaves() expect((bot as any).groupPendingInfo.has(GROUP_ID)).toBe(false) }) test("groupMetadata cleaned up on customer leave", async () => { await customer.sends("Hello") expect((bot as any).groupMetadata.has(GROUP_ID)).toBe(true) await customer.leaves() expect((bot as any).groupMetadata.has(GROUP_ID)).toBe(false) }) test("restoreGroupMetadata works", () => { const meta = {firstContact: 1000000, msgCount: 5, customerName: "Test"} bot.restoreGroupMetadata([[GROUP_ID, meta]]) expect((bot as any).groupMetadata.get(GROUP_ID)).toEqual(meta) }) test("restoreGroupPendingInfo works", () => { const info = {lastEventType: "message" as const, lastEventFrom: "customer" as const, lastEventTimestamp: Date.now(), lastMessageFrom: "customer" as const} bot.restoreGroupPendingInfo([[GROUP_ID, info]]) expect((bot as any).groupPendingInfo.get(GROUP_ID)).toEqual(info) }) test("onGroupMetadataChanged fires on customer message", async () => { const callback = vi.fn() bot.onGroupMetadataChanged = callback await customer.sends("Hello") expect(callback).toHaveBeenCalled() }) test("onGroupPendingInfoChanged fires on customer message", async () => { const callback = vi.fn() bot.onGroupPendingInfoChanged = callback await customer.sends("Hello") expect(callback).toHaveBeenCalled() }) }) // ─── 35. Welcome Flow After Command-First Interaction ────────── describe("Welcome Flow After Command-First Interaction", () => { afterEach(() => vi.useRealTimers()) test("/grok as first command then text → no duplicate welcome", async () => { mainChat.setNextGroupMemberId(60) lastGrokMemberGId = 60 grokApi.willRespond("AI answer") const p = customer.sends("/grok") mainChat.setGroupMembers(GROUP_ID, [ {groupMemberId: 60, memberContactId: 4, memberStatus: "connected"}, ]) await grokAgent.joins() await p // Now customer sends text — should NOT trigger teamQueueMessage grokApi.willRespond("Follow-up answer") await customer.sends("Help me with something") const teamMsgs = mainChat.sentTo(TEAM_GRP_ID) const hasQueueMsg = teamMsgs.some(m => m.includes("forwarded to the team")) expect(hasQueueMsg).toBe(false) const hasNewMarker = teamMsgs.some(m => m.includes("!1 NEW!")) expect(hasNewMarker).toBe(false) }) test("/grok timeout as first command then text → no duplicate welcome", async () => { vi.useFakeTimers() mainChat.setNextGroupMemberId(60) lastGrokMemberGId = 60 const p = customer.sends("/grok") await grokAgent.timesOut() await p vi.useRealTimers() // Customer sends text — welcomeCompleted stays set, no duplicate welcome await customer.sends("Hello") const teamMsgs = mainChat.sentTo(TEAM_GRP_ID) const hasQueueMsg = teamMsgs.some(m => m.includes("forwarded to the team")) expect(hasQueueMsg).toBe(false) const hasNewMarker = teamMsgs.some(m => m.includes("!1 NEW!")) expect(hasNewMarker).toBe(false) }) test("/team as first command then text → no duplicate welcome", async () => { mainChat.setNextGroupMemberId(50) lastTeamMemberGId = 50 mainChat.setGroupMembers(GROUP_ID, []) await customer.sends("/team") mainChat.setGroupMembers(GROUP_ID, [ {groupMemberId: 50, memberContactId: 2, memberStatus: "connected"}, ]) await customer.sends("Can you help me?") const teamMsgs = mainChat.sentTo(TEAM_GRP_ID) const hasQueueMsg = teamMsgs.some(m => m.includes("forwarded to the team")) expect(hasQueueMsg).toBe(false) }) test("/team when already activated before → sets welcomeCompleted", async () => { mainChat.setChatItems(GROUP_ID, [ {chatDir: {type: "groupSnd"}, _text: "A team member has been added and will reply within 24 hours."}, ]) mainChat.setGroupMembers(GROUP_ID, []) await customer.sends("/team") customer.received("A team member has already been invited to this conversation and will reply when available.") mainChat.setGroupMembers(GROUP_ID, [ {groupMemberId: 50, memberContactId: 2, memberStatus: "connected"}, ]) await customer.sends("Still need help") const teamMsgs = mainChat.sentTo(TEAM_GRP_ID) const hasQueueMsg = teamMsgs.some(m => m.includes("forwarded to the team")) expect(hasQueueMsg).toBe(false) }) test("isFirstCustomerMessage detects grokActivatedMessage in history (restart resilience)", async () => { // Simulate post-restart: history has grokActivatedMessage but welcomeCompleted is empty mainChat.setChatItems(GROUP_ID, [ {chatDir: {type: "groupSnd"}, _text: "You are now chatting with Grok. You can send questions in any language."}, ]) mainChat.setGroupMembers(GROUP_ID, [ {groupMemberId: 60, memberContactId: 4, memberStatus: "connected"}, ]) grokApi.willRespond("answer") await customer.sends("Hello") const teamMsgs = mainChat.sentTo(TEAM_GRP_ID) const hasQueueMsg = teamMsgs.some(m => m.includes("forwarded to the team")) expect(hasQueueMsg).toBe(false) const hasNewMarker = teamMsgs.some(m => m.includes("!1 NEW!")) expect(hasNewMarker).toBe(false) }) test("isFirstCustomerMessage detects teamAddedMessage in history (restart resilience)", async () => { // Simulate post-restart: history has teamAddedMessage but welcomeCompleted is empty mainChat.setChatItems(GROUP_ID, [ {chatDir: {type: "groupSnd"}, _text: "A team member has been added and will reply within 24 hours."}, ]) mainChat.setGroupMembers(GROUP_ID, [ {groupMemberId: 50, memberContactId: 2, memberStatus: "connected"}, ]) await customer.sends("Hello") const teamMsgs = mainChat.sentTo(TEAM_GRP_ID) const hasQueueMsg = teamMsgs.some(m => m.includes("forwarded to the team")) expect(hasQueueMsg).toBe(false) const hasNewMarker = teamMsgs.some(m => m.includes("!1 NEW!")) expect(hasNewMarker).toBe(false) }) })