diff --git a/apps/simplex-chat-support-bot/bot.test.ts b/apps/simplex-chat-support-bot/bot.test.ts new file mode 100644 index 0000000000..bb8f664703 --- /dev/null +++ b/apps/simplex-chat-support-bot/bot.test.ts @@ -0,0 +1,1451 @@ +// ═══════════════════════════════════════════════════════════════════ +// SimpleX Support Bot — Acceptance Tests +// ═══════════════════════════════════════════════════════════════════ +// +// Human-readable TypeScript tests for the support bot. +// Uses a conversation DSL: users are variables, actions use await, +// assertions use .received() / .stateIs(). +// +// Grok API is mocked. All scenarios from the product specification +// and implementation plan are covered. +// ═══════════════════════════════════════════════════════════════════ + +import {describe, test, expect, beforeEach, 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"}, GroupMemberRole: {Member: "member"}}, + CEvt: {}, +})) + +vi.mock("./src/util", () => ({ + isWeekend: vi.fn(() => false), + log: vi.fn(), + logError: vi.fn(), +})) + +// ─── Imports (after mocks) ─────────────────────────────────────── + +import {SupportBot} from "./src/bot" +import type {GrokMessage} from "./src/state" +import {isWeekend} from "./src/util" + + +// ─── 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 } +interface AddedMember { groupId: number; contactId: number; role: string } +interface RemovedMembers { groupId: number; memberIds: number[] } + +class MockChatApi { + sent: SentMessage[] = [] + added: AddedMember[] = [] + removed: RemovedMembers[] = [] + joined: number[] = [] + + private addMemberFail = false + private nextMemberGId = 50 + + apiAddMemberWillFail() { this.addMemberFail = true } + setNextGroupMemberId(id: number) { this.nextMemberGId = id } + + async apiSendTextMessage(chat: [string, number], text: string) { + this.sent.push({chat, text}) + } + + async apiAddMember(groupId: number, contactId: number, role: string) { + if (this.addMemberFail) { this.addMemberFail = false; throw new Error("apiAddMember failed") } + const gid = this.nextMemberGId++ + this.added.push({groupId, contactId, role}) + return {groupMemberId: gid, memberId: `member-${gid}`} + } + + async apiRemoveMembers(groupId: number, memberIds: number[]) { + this.removed.push({groupId, memberIds}) + } + + async apiJoinGroup(groupId: number) { + this.joined.push(groupId) + } + + 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.addMemberFail = false; this.nextMemberGId = 50 + } +} + + +// ─── 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 +} + +function customerChatItem(text: string | null, command: string | null = null) { + return { + chatInfo: {type: "group", groupInfo: businessGroupInfo()}, + chatItem: { + chatDir: { + type: "groupRcv", + groupMember: {memberId: CUSTOMER_ID, groupMemberId: 10}, + }, + content: {type: "text", text: text ?? ""}, + _botCommand: command, + _text: text, + }, + } as any +} + +function teamMemberChatItem(teamMemberGId: number, text: string) { + return { + chatInfo: {type: "group", groupInfo: businessGroupInfo()}, + chatItem: { + chatDir: { + type: "groupRcv", + groupMember: {memberId: "team-member-1", groupMemberId: teamMemberGId}, + }, + 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}, + }, + 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 ─────────────────────────────────────────────────── +// Thin wrappers that make test bodies read like conversations. +// +// IMPORTANT: activateGrok internally blocks on waitForGrokJoin. +// When testing /grok activation, do NOT await customer.sends("/grok") +// before grokAgent.joins(). Instead use: +// +// const p = customer.sends("/grok") // starts, blocks at waitForGrokJoin +// await grokAgent.joins() // resolves the join +// await p // activateGrok completes +// +// All assertions must come after `await p`. + +let bot: SupportBot +let mainChat: MockChatApi +let grokChat: MockChatApi +let grokApi: MockGrokApi +let lastTeamMemberGId: number +let lastGrokMemberGId: number + +const customer = { + async connects(groupId = GROUP_ID) { + bot.onBusinessRequest({groupInfo: businessGroupInfo(groupId)} as any) + }, + + async sends(text: string, groupId = GROUP_ID) { + const isGrokCmd = text === "/grok" + const isTeamCmd = text === "/team" + const command = isGrokCmd ? "grok" : isTeamCmd ? "team" : null + const ci = customerChatItem(text, command) + ci.chatInfo.groupInfo = businessGroupInfo(groupId) + 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([]) + }, +} + +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) + 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}, + } 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() { + // Flush microtasks so activateGrok reaches waitForGrokJoin before we resolve it. + // activateGrok does: await apiAddMember → pendingGrokJoins.set → await sendToGroup → await waitForGrokJoin + // Each await creates a microtask. setTimeout(r, 0) fires after all microtasks drain. + await new Promise(r => setTimeout(r, 0)) + const memberId = `member-${lastGrokMemberGId}` + await bot.onGrokGroupInvitation({ + groupInfo: { + groupId: GROK_LOCAL, + membership: {memberId}, + }, + } as any) + }, + + async timesOut() { + // Advance fake timers past the 30s join timeout. + // advanceTimersByTimeAsync interleaves microtask processing, so activateGrok's + // internal awaits (apiAddMember, sendToGroup) complete before the 30s timeout fires. + await vi.advanceTimersByTimeAsync(30_001) + }, + + wasRemoved(groupId = GROUP_ID) { + const found = mainChat.removed.some( + r => r.groupId === groupId && r.memberIds.includes(lastGrokMemberGId) + ) + expect(found).toBe(true) + }, + + async leaves(groupId = GROUP_ID) { + await bot.onLeftMember({ + groupInfo: businessGroupInfo(groupId), + member: {memberId: "grok-1", groupMemberId: lastGrokMemberGId}, + } as any) + }, +} + +function stateIs(groupId: number, expectedType: string) { + const state = (bot as any).conversations.get(groupId) + expect(state).toBeDefined() + expect(state.type).toBe(expectedType) +} + +function hasNoState(groupId: number) { + expect((bot as any).conversations.has(groupId)).toBe(false) +} + + +// ─── Constants ────────────────────────────────────────────────── + +const TEAM_QUEUE_24H = + `Thank you for your message, it is forwarded to the team.\n` + + `It may take a team member up to 24 hours to reply.\n\n` + + `Click /grok if your question is about SimpleX apps or network, is not sensitive, ` + + `and you want Grok LLM to answer it right away. *Your previous message and all ` + + `subsequent messages will be forwarded to Grok* until you click /team. You can ask ` + + `Grok questions in any language and it will not see your profile name.\n\n` + + `We appreciate if you try Grok: you can learn a lot about SimpleX Chat from it. ` + + `It is objective, answers the way our team would, and it saves our team time.` + +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.` + + +// ─── Setup ────────────────────────────────────────────────────── + +const config = { + teamGroup: {id: 1, name: "SupportTeam"}, + teamMembers: [{id: 2, name: "Bob"}], + grokContact: {id: 4, name: "Grok AI"}, + timezone: "America/New_York", + groupLinks: "https://simplex.chat/contact#...", + grokApiKey: "test-key", + dbPrefix: "./test-data/bot", + grokDbPrefix:"./test-data/grok", + firstRun: false, +} + +beforeEach(() => { + mainChat = new MockChatApi() + grokChat = new MockChatApi() + grokApi = new MockGrokApi() + // Track the groupMemberIds that apiAddMember returns + mainChat.setNextGroupMemberId(50) + lastTeamMemberGId = 50 + lastGrokMemberGId = 50 + bot = new SupportBot(mainChat as any, grokChat as any, grokApi as any, config as any) + // Reset isWeekend mock to default (weekday) + vi.mocked(isWeekend).mockReturnValue(false) +}) + + +// ─── State Helpers ────────────────────────────────────────────── + +async function reachTeamQueue(...messages: string[]) { + await customer.connects() + await customer.sends(messages[0] || "Hello") + for (const msg of messages.slice(1)) { + await customer.sends(msg) + } +} + +async function reachGrokMode(grokResponse = "Grok answer") { + mainChat.setNextGroupMemberId(60) + lastGrokMemberGId = 60 + await reachTeamQueue("Hello") + grokApi.willRespond(grokResponse) + // Non-awaiting pattern: activateGrok blocks on waitForGrokJoin + const p = customer.sends("/grok") + await grokAgent.joins() + await p +} + +async function reachTeamPending() { + mainChat.setNextGroupMemberId(50) + lastTeamMemberGId = 50 + await reachTeamQueue("Hello") + await customer.sends("/team") +} + +async function reachTeamLocked() { + await reachTeamPending() + await teamMember.sends("I'll help you") +} + + +// ═══════════════════════════════════════════════════════════════ +// TESTS +// ═══════════════════════════════════════════════════════════════ + + +// ─── 1. Connection & Welcome ──────────────────────────────────── + +describe("Connection & Welcome", () => { + + test("new customer connects → welcome state", async () => { + await customer.connects() + + stateIs(GROUP_ID, "welcome") + }) + + test("first message → forwarded to team, queue reply, teamQueue state", async () => { + await customer.connects() + + await customer.sends("How do I create a group?") + + teamGroup.received("[Alice #100]\nHow do I create a group?") + customer.received(TEAM_QUEUE_24H) + stateIs(GROUP_ID, "teamQueue") + }) + + test("non-text message in welcome → ignored", async () => { + await customer.connects() + + await customer.sendsNonText() + + stateIs(GROUP_ID, "welcome") + }) +}) + + +// ─── 2. Team Queue ────────────────────────────────────────────── + +describe("Team Queue", () => { + + test("additional messages forwarded to team, no second queue reply", async () => { + await reachTeamQueue("First question") + mainChat.sent = [] // clear previous messages + + await customer.sends("More details about my issue") + + teamGroup.received("[Alice #100]\nMore details about my issue") + // No queue message sent again — only on first message + expect(mainChat.sentTo(GROUP_ID).length).toBe(0) + stateIs(GROUP_ID, "teamQueue") + }) + + test("multiple messages accumulate in userMessages", async () => { + await customer.connects() + + await customer.sends("Question 1") + await customer.sends("Question 2") + await customer.sends("Question 3") + + teamGroup.received("[Alice #100]\nQuestion 1") + teamGroup.received("[Alice #100]\nQuestion 2") + teamGroup.received("[Alice #100]\nQuestion 3") + + const state = (bot as any).conversations.get(GROUP_ID) + expect(state.userMessages).toEqual(["Question 1", "Question 2", "Question 3"]) + }) + + test("non-text message in teamQueue → ignored", async () => { + await reachTeamQueue("Hello") + mainChat.sent = [] + + await customer.sendsNonText() + + expect(mainChat.sent.length).toBe(0) + stateIs(GROUP_ID, "teamQueue") + }) + + test("unrecognized /command treated as normal text message", async () => { + await reachTeamQueue("Hello") + mainChat.sent = [] + + await customer.sends("/unknown") + + teamGroup.received("[Alice #100]\n/unknown") + stateIs(GROUP_ID, "teamQueue") + }) +}) + + +// ─── 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.") + // Non-awaiting pattern: activateGrok blocks on waitForGrokJoin + const p = customer.sends("/grok") + 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.") + + stateIs(GROUP_ID, "grokMode") + }) + + 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") + 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...") + stateIs(GROUP_ID, "grokMode") + }) +}) + + +// ─── 4. Grok Mode Conversation ───────────────────────────────── + +describe("Grok Mode Conversation", () => { + + test("user messages forwarded to both Grok API and team group", async () => { + await reachGrokMode("Initial answer") + mainChat.sent = [] + + grokApi.willRespond("Follow-up answer from Grok") + await customer.sends("What about encryption?") + + teamGroup.received("[Alice #100]\nWhat about encryption?") + + expect(grokApi.lastCall().history).toEqual([ + {role: "user", content: "Hello"}, + {role: "assistant", content: "Initial answer"}, + ]) + expect(grokApi.lastCall().message).toBe("What about encryption?") + + customer.receivedFromGrok("Follow-up answer from Grok") + stateIs(GROUP_ID, "grokMode") + }) + + test("conversation history grows with each exchange", async () => { + await reachGrokMode("Answer 1") + + grokApi.willRespond("Answer 2") + await customer.sends("Follow-up 1") + + expect(grokApi.lastCall().history).toEqual([ + {role: "user", content: "Hello"}, + {role: "assistant", content: "Answer 1"}, + ]) + + grokApi.willRespond("Answer 3") + await customer.sends("Follow-up 2") + + expect(grokApi.lastCall().history).toEqual([ + {role: "user", content: "Hello"}, + {role: "assistant", content: "Answer 1"}, + {role: "user", content: "Follow-up 1"}, + {role: "assistant", content: "Answer 2"}, + ]) + }) + + test("/grok in grokMode → silently ignored", async () => { + await reachGrokMode() + mainChat.sent = [] + grokApi.reset() + + await customer.sends("/grok") + + expect(mainChat.sent.length).toBe(0) + expect(grokApi.callCount()).toBe(0) + stateIs(GROUP_ID, "grokMode") + }) + + 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) + stateIs(GROUP_ID, "grokMode") + }) +}) + + +// ─── 5. Team Activation ──────────────────────────────────────── + +describe("Team Activation", () => { + + test("/team from teamQueue → team member invited, teamPending", async () => { + mainChat.setNextGroupMemberId(50) + lastTeamMemberGId = 50 + await reachTeamQueue("Hello") + mainChat.sent = [] + + await customer.sends("/team") + + teamMember.wasInvited() + customer.received(TEAM_ADDED_24H) + stateIs(GROUP_ID, "teamPending") + }) + + test("/team from grokMode → Grok removed, team member added", async () => { + await reachGrokMode() + mainChat.setNextGroupMemberId(70) + lastTeamMemberGId = 70 + mainChat.sent = [] + + await customer.sends("/team") + + grokAgent.wasRemoved() + teamMember.wasInvited() + customer.received(TEAM_ADDED_24H) + stateIs(GROUP_ID, "teamPending") + }) +}) + + +// ─── 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) + stateIs(GROUP_ID, "teamPending") + }) + + test("team member sends message → teamLocked", async () => { + await reachTeamPending() + + await teamMember.sends("I'll help you with that") + + stateIs(GROUP_ID, "teamLocked") + }) + + test("/grok in teamLocked → 'team mode' reply", async () => { + await reachTeamLocked() + mainChat.sent = [] + + await customer.sends("/grok") + + customer.received(TEAM_LOCKED_MSG) + stateIs(GROUP_ID, "teamLocked") + }) + + test("/team in teamPending → silently ignored", async () => { + await reachTeamPending() + mainChat.sent = [] + + await customer.sends("/team") + + expect(mainChat.sent.length).toBe(0) + stateIs(GROUP_ID, "teamPending") + }) + + test("/team in teamLocked → silently ignored", async () => { + await reachTeamLocked() + mainChat.sent = [] + + await customer.sends("/team") + + expect(mainChat.sent.length).toBe(0) + stateIs(GROUP_ID, "teamLocked") + }) + + test("customer text in teamPending → no forwarding, no reply", async () => { + await reachTeamPending() + mainChat.sent = [] + + await customer.sends("Here's more info about my issue") + + expect(mainChat.sent.length).toBe(0) + stateIs(GROUP_ID, "teamPending") + }) + + test("customer text in teamLocked → no forwarding, no reply", async () => { + await reachTeamLocked() + mainChat.sent = [] + + await customer.sends("Thank you!") + + expect(mainChat.sent.length).toBe(0) + stateIs(GROUP_ID, "teamLocked") + }) +}) + + +// ─── 7. Gate Reversal vs Irreversibility ──────────────────────── + +describe("Gate Reversal vs Irreversibility", () => { + + test("team member leaves in teamPending → revert to teamQueue", async () => { + await reachTeamPending() + + await teamMember.leaves() + + stateIs(GROUP_ID, "teamQueue") + }) + + test("after teamPending revert, /grok works again", async () => { + await reachTeamPending() + await teamMember.leaves() + // Now back in teamQueue + mainChat.setNextGroupMemberId(61) + lastGrokMemberGId = 61 + + grokApi.willRespond("Grok is back") + const p = customer.sends("/grok") + await grokAgent.joins() + await p + + customer.receivedFromGrok("Grok is back") + stateIs(GROUP_ID, "grokMode") + }) + + test("team member leaves in teamLocked → replacement added, stays locked", async () => { + await reachTeamLocked() + mainChat.added = [] + + await teamMember.leaves() + + // Replacement team member invited, state stays teamLocked + expect(mainChat.added.length).toBe(1) + expect(mainChat.added[0].contactId).toBe(2) + stateIs(GROUP_ID, "teamLocked") + }) + + test("/grok still rejected after replacement in teamLocked", async () => { + await reachTeamLocked() + await teamMember.leaves() + mainChat.sent = [] + + await customer.sends("/grok") + + customer.received(TEAM_LOCKED_MSG) + stateIs(GROUP_ID, "teamLocked") + }) +}) + + +// ─── 8. Member Leave & Cleanup ────────────────────────────────── + +describe("Member Leave & Cleanup", () => { + + test("customer leaves → state deleted", async () => { + await reachTeamQueue("Hello") + + await customer.leaves() + + hasNoState(GROUP_ID) + }) + + test("customer leaves in grokMode → state and grok maps cleaned", async () => { + await reachGrokMode() + + await customer.leaves() + + hasNoState(GROUP_ID) + // grokGroupMap also cleaned (internal) + expect((bot as any).grokGroupMap.has(GROUP_ID)).toBe(false) + }) + + test("Grok leaves during grokMode → revert to teamQueue", async () => { + await reachGrokMode() + + await grokAgent.leaves() + + stateIs(GROUP_ID, "teamQueue") + expect((bot as any).grokGroupMap.has(GROUP_ID)).toBe(false) + }) + + test("bot removed from group → state deleted", async () => { + await reachTeamQueue("Hello") + + bot.onDeletedMemberUser({groupInfo: businessGroupInfo()} as any) + + hasNoState(GROUP_ID) + }) + + test("group deleted → state deleted", async () => { + await reachGrokMode() + + bot.onGroupDeleted({groupInfo: businessGroupInfo()} as any) + + hasNoState(GROUP_ID) + expect((bot as any).grokGroupMap.has(GROUP_ID)).toBe(false) + }) + + test("customer leaves in welcome → state deleted", async () => { + await customer.connects() + + await customer.leaves() + + hasNoState(GROUP_ID) + }) +}) + + +// ─── 9. Error Handling ────────────────────────────────────────── + +describe("Error Handling", () => { + + test("Grok invitation (apiAddMember) fails → error msg, stay in teamQueue", async () => { + await reachTeamQueue("Hello") + mainChat.apiAddMemberWillFail() + mainChat.sent = [] + + await customer.sends("/grok") + + customer.received(GROK_UNAVAILABLE) + expect(grokApi.callCount()).toBe(0) + stateIs(GROUP_ID, "teamQueue") + }) + + test("Grok join timeout → error msg, stay in teamQueue", async () => { + vi.useFakeTimers() + mainChat.setNextGroupMemberId(60) + lastGrokMemberGId = 60 + await reachTeamQueue("Hello") + mainChat.sent = [] + + const sendPromise = customer.sends("/grok") + // advanceTimersByTimeAsync flushes microtasks (so activateGrok reaches waitForGrokJoin) + // then fires the 30s timeout + await grokAgent.timesOut() + await sendPromise + + customer.received(GROK_UNAVAILABLE) + expect(grokApi.callCount()).toBe(0) + stateIs(GROUP_ID, "teamQueue") + vi.useRealTimers() + }) + + 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") + await grokAgent.joins() + await p + + grokAgent.wasRemoved() + customer.received(GROK_UNAVAILABLE) + stateIs(GROUP_ID, "teamQueue") + }) + + test("Grok API error during conversation → remove Grok, revert to teamQueue", async () => { + await reachGrokMode() + grokApi.willFail() + mainChat.sent = [] + + await customer.sends("Another question") + + grokAgent.wasRemoved() + customer.received(GROK_UNAVAILABLE) + stateIs(GROUP_ID, "teamQueue") + }) + + test("after Grok API failure revert, /team still works", async () => { + await reachGrokMode() + grokApi.willFail() + await customer.sends("Failing question") + // Now back in teamQueue + mainChat.setNextGroupMemberId(51) + lastTeamMemberGId = 51 + mainChat.sent = [] + + await customer.sends("/team") + + teamMember.wasInvited() + customer.received(TEAM_ADDED_24H) + stateIs(GROUP_ID, "teamPending") + }) + + test("team member add fails from teamQueue → error, stay in teamQueue", async () => { + await reachTeamQueue("Hello") + mainChat.apiAddMemberWillFail() + mainChat.sent = [] + + await customer.sends("/team") + + customer.received(TEAM_ADD_ERROR) + stateIs(GROUP_ID, "teamQueue") + }) + + test("team member add fails after Grok removal → revert to teamQueue", async () => { + await reachGrokMode() + mainChat.apiAddMemberWillFail() + mainChat.sent = [] + + await customer.sends("/team") + + grokAgent.wasRemoved() + customer.received(TEAM_ADD_ERROR) + // grokMode state is stale (Grok removed) → explicitly reverted to teamQueue + stateIs(GROUP_ID, "teamQueue") + }) + + test("Grok failure then retry succeeds", async () => { + mainChat.setNextGroupMemberId(60) + lastGrokMemberGId = 60 + await reachTeamQueue("Hello") + + // First attempt — API fails + grokApi.willFail() + const p1 = customer.sends("/grok") + await grokAgent.joins() + await p1 + stateIs(GROUP_ID, "teamQueue") + + // Second attempt — succeeds + mainChat.setNextGroupMemberId(61) + lastGrokMemberGId = 61 + grokApi.willRespond("Hello! How can I help?") + const p2 = customer.sends("/grok") + await grokAgent.joins() + await p2 + + customer.receivedFromGrok("Hello! How can I help?") + stateIs(GROUP_ID, "grokMode") + }) +}) + + +// ─── 10. Race Conditions ──────────────────────────────────────── + +describe("Race Conditions", () => { + + test("/team sent while waiting for Grok to join → abort Grok", async () => { + mainChat.setNextGroupMemberId(60) + lastGrokMemberGId = 60 + await reachTeamQueue("Hello") + + // Start /grok — hangs on waitForGrokJoin + grokApi.willRespond("answer") + const grokPromise = customer.sends("/grok") + + // While waiting, /team is processed concurrently + mainChat.setNextGroupMemberId(70) + lastTeamMemberGId = 70 + await customer.sends("/team") + stateIs(GROUP_ID, "teamPending") + + // Grok join completes — but state changed + await grokAgent.joins() + await grokPromise + + // Bot detects state mismatch, removes Grok + grokAgent.wasRemoved() + expect(grokApi.callCount()).toBe(0) + stateIs(GROUP_ID, "teamPending") + }) + + test("state change during Grok API call → abort", 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") + await grokAgent.joins() + // activateGrok now blocked on grokApi.chat + + // While API call is pending, /team changes state + mainChat.setNextGroupMemberId(70) + lastTeamMemberGId = 70 + await customer.sends("/team") + stateIs(GROUP_ID, "teamPending") + + // API call completes — but state changed + resolveGrokCall("Grok answer") + await grokPromise + + grokAgent.wasRemoved() + stateIs(GROUP_ID, "teamPending") + }) +}) + + +// ─── 11. Weekend Hours ────────────────────────────────────────── + +describe("Weekend Hours", () => { + + test("weekend: 48 hours in queue message", async () => { + vi.mocked(isWeekend).mockReturnValue(true) + + await customer.connects() + await customer.sends("Hello") + + customer.received(TEAM_QUEUE_48H) + }) + + 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: [displayName #groupId]\\ntext", async () => { + await customer.connects() + + await customer.sends("My app crashes on startup") + + teamGroup.received("[Alice #100]\nMy app crashes on startup") + }) + + test("grokMode messages also forwarded to team", async () => { + await reachGrokMode() + mainChat.sent = [] + + grokApi.willRespond("Try clearing app data") + await customer.sends("App keeps crashing") + + teamGroup.received("[Alice #100]\nApp keeps crashing") + customer.receivedFromGrok("Try clearing app data") + }) + + test("fallback displayName when empty → group-{id}", async () => { + const emptyNameGroup = {...businessGroupInfo(101), groupProfile: {displayName: ""}} + bot.onBusinessRequest({groupInfo: emptyNameGroup} as any) + mainChat.sent = [] + + // Send message in group 101 with empty display name + const ci = customerChatItem("Hello", null) + ci.chatInfo.groupInfo = emptyNameGroup + ci.chatItem.chatDir.groupMember.memberId = emptyNameGroup.businessChat.customerId + await bot.onNewChatItems({chatItems: [ci]} as any) + + teamGroup.received("[group-101 #101]\nHello") + }) +}) + + +// ─── 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) + stateIs(GROUP_ID, "teamQueue") + }) + + 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) + + hasNoState(999) + }) + + test("message in group with no conversation state → ignored", async () => { + // Group 888 never had onBusinessRequest called + const ci = customerChatItem("Hello", null) + ci.chatInfo.groupInfo = businessGroupInfo(888) + mainChat.sent = [] + + await bot.onNewChatItems({chatItems: [ci]} as any) + + expect(mainChat.sent.length).toBe(0) + hasNoState(888) + }) + + test("Grok's own messages in grokMode → ignored by bot", 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("bot passes full history to GrokApiClient (client truncates internally)", async () => { + await reachGrokMode("Answer 0") + + // Build up 12 more exchanges → 26 history entries total + for (let i = 1; i <= 12; i++) { + grokApi.willRespond(`Answer ${i}`) + await customer.sends(`Question ${i}`) + } + + // 13th exchange — history passed to MockGrokApi has 26 entries + // The real GrokApiClient.chat() does history.slice(-20) before calling the API + grokApi.willRespond("Answer 13") + await customer.sends("Question 13") + + const lastCall = grokApi.lastCall() + expect(lastCall.history.length).toBe(26) + expect(lastCall.message).toBe("Question 13") + }) + + test("unexpected Grok group invitation → ignored", async () => { + await bot.onGrokGroupInvitation({ + groupInfo: { + groupId: 999, + membership: {memberId: "unknown-member"}, + }, + } as any) + + // No crash, no state change, no maps updated + expect(grokChat.joined.length).toBe(0) + }) + + test("multiple concurrent conversations are independent", async () => { + const GROUP_A = 100 + const GROUP_B = 300 + + // Customer A connects + bot.onBusinessRequest({groupInfo: businessGroupInfo(GROUP_A, "Alice")} as any) + stateIs(GROUP_A, "welcome") + + // Customer B connects + bot.onBusinessRequest({groupInfo: businessGroupInfo(GROUP_B, "Charlie")} as any) + stateIs(GROUP_B, "welcome") + + // Customer A sends message → teamQueue + const ciA = customerChatItem("Question A", null) + ciA.chatInfo.groupInfo = businessGroupInfo(GROUP_A, "Alice") + await bot.onNewChatItems({chatItems: [ciA]} as any) + stateIs(GROUP_A, "teamQueue") + + // Customer B still in welcome + stateIs(GROUP_B, "welcome") + }) + + test("Grok leaves during grokMode, customer retries → works", async () => { + await reachGrokMode() + + await grokAgent.leaves() + stateIs(GROUP_ID, "teamQueue") + + // Retry /grok + mainChat.setNextGroupMemberId(62) + lastGrokMemberGId = 62 + grokApi.willRespond("I'm back!") + const p = customer.sends("/grok") + await grokAgent.joins() + await p + + customer.receivedFromGrok("I'm back!") + stateIs(GROUP_ID, "grokMode") + }) + + test("/grok in welcome state → treated as regular text", async () => { + await customer.connects() + + await customer.sends("/grok") + + // welcome state has no command handling — /grok is treated as text + teamGroup.received("[Alice #100]\n/grok") + customer.received(TEAM_QUEUE_24H) + stateIs(GROUP_ID, "teamQueue") + }) + + test("/team in welcome state → treated as regular text", async () => { + await customer.connects() + + await customer.sends("/team") + + // welcome state has no command handling — /team is treated as text + teamGroup.received("[Alice #100]\n/team") + customer.received(TEAM_QUEUE_24H) + stateIs(GROUP_ID, "teamQueue") + }) + + test("non-text message in teamPending → ignored", async () => { + await reachTeamPending() + mainChat.sent = [] + + await customer.sendsNonText() + + expect(mainChat.sent.length).toBe(0) + stateIs(GROUP_ID, "teamPending") + }) + + test("non-text message in teamLocked → ignored", async () => { + await reachTeamLocked() + mainChat.sent = [] + + await customer.sendsNonText() + + expect(mainChat.sent.length).toBe(0) + stateIs(GROUP_ID, "teamLocked") + }) + + test("team member message in teamLocked → no state change", async () => { + await reachTeamLocked() + + // onTeamMemberMessage checks state.type !== "teamPending" → returns + await teamMember.sends("Just checking in") + + stateIs(GROUP_ID, "teamLocked") + }) + + test("unknown member message → silently ignored", async () => { + await reachTeamQueue("Hello") + mainChat.sent = [] + grokApi.reset() + + // A member who is neither customer, nor identified team member, nor Grok + 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) + stateIs(GROUP_ID, "teamQueue") + }) + + 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") + + // Trigger invitation — apiJoinGroup fails, resolver NOT called + await new Promise(r => setTimeout(r, 0)) + const memberId = `member-${lastGrokMemberGId}` + await bot.onGrokGroupInvitation({ + groupInfo: {groupId: GROK_LOCAL, membership: {memberId}}, + } as any) + + // Maps should NOT be set (join failed) + expect((bot as any).grokGroupMap.has(GROUP_ID)).toBe(false) + expect((bot as any).reverseGrokMap.has(GROK_LOCAL)).toBe(false) + }) + + test("replacement team member add fails → stays teamLocked", async () => { + await reachTeamLocked() + mainChat.apiAddMemberWillFail() + + await teamMember.leaves() + + // addReplacementTeamMember failed, but one-way gate holds + stateIs(GROUP_ID, "teamLocked") + }) +}) + + +// ─── 14. Full End-to-End Flows ────────────────────────────────── + +describe("End-to-End Flows", () => { + + test("full flow: welcome → grokMode → /team → teamLocked", async () => { + // Step 1: connect + await customer.connects() + stateIs(GROUP_ID, "welcome") + + // Step 2: first message → teamQueue + await customer.sends("How do I enable disappearing messages?") + teamGroup.received("[Alice #100]\nHow do I enable disappearing messages?") + customer.received(TEAM_QUEUE_24H) + stateIs(GROUP_ID, "teamQueue") + + // Step 3: /grok → grokMode + mainChat.setNextGroupMemberId(60) + lastGrokMemberGId = 60 + grokApi.willRespond("Go to conversation settings and tap 'Disappearing messages'.") + const p = customer.sends("/grok") + await grokAgent.joins() + await p + customer.received(GROK_ACTIVATED) + customer.receivedFromGrok("Go to conversation settings and tap 'Disappearing messages'.") + stateIs(GROUP_ID, "grokMode") + + // Step 4: follow-up in grokMode + grokApi.willRespond("Yes, you can set different timers per conversation.") + await customer.sends("Can I set different timers?") + teamGroup.received("[Alice #100]\nCan I set different timers?") + customer.receivedFromGrok("Yes, you can set different timers per conversation.") + stateIs(GROUP_ID, "grokMode") + + // Step 5: /team → teamPending (Grok removed) + mainChat.setNextGroupMemberId(70) + lastTeamMemberGId = 70 + await customer.sends("/team") + grokAgent.wasRemoved() + teamMember.wasInvited() + customer.received(TEAM_ADDED_24H) + stateIs(GROUP_ID, "teamPending") + + // Step 6: /grok rejected + await customer.sends("/grok") + customer.received(TEAM_LOCKED_MSG) + stateIs(GROUP_ID, "teamPending") + + // Step 7: team member responds → teamLocked + await teamMember.sends("Hi! Let me help you.") + stateIs(GROUP_ID, "teamLocked") + + // Step 8: /grok still rejected + await customer.sends("/grok") + customer.received(TEAM_LOCKED_MSG) + stateIs(GROUP_ID, "teamLocked") + + // Step 9: customer continues — team sees directly, no forwarding + mainChat.sent = [] + await customer.sends("Thanks for helping!") + expect(mainChat.sent.length).toBe(0) + }) + + test("full flow: welcome → teamQueue → /team directly (skip Grok)", async () => { + await customer.connects() + + await customer.sends("I have a billing question") + customer.received(TEAM_QUEUE_24H) + stateIs(GROUP_ID, "teamQueue") + + mainChat.setNextGroupMemberId(50) + lastTeamMemberGId = 50 + await customer.sends("/team") + teamMember.wasInvited() + customer.received(TEAM_ADDED_24H) + stateIs(GROUP_ID, "teamPending") + + await teamMember.sends("Hi, I can help with billing") + stateIs(GROUP_ID, "teamLocked") + }) +}) + + +// ═══════════════════════════════════════════════════════════════ +// Coverage Matrix +// ═══════════════════════════════════════════════════════════════ +// +// State / Input | Text msg | /grok | /team | Non-text | Team msg | Leave | Unknown member +// -------------------|-----------|---------|---------|----------|----------|----------|--------------- +// welcome | 1.2 | 13.9 | 13.10 | 1.3 | — | 8.6 | — +// teamQueue | 2.1, 2.2 | 3.1,3.2 | 5.1 | 2.3 | — | 8.1 | 13.14 +// grokMode | 4.1, 4.2 | 4.3 | 5.2 | 4.4 | — | 8.3 grok | — +// teamPending | 6.6 | 6.1 | 6.4 | 13.11 | 6.2 | 7.1 | — +// teamLocked | 6.7 | 6.3 | 6.5 | 13.12 | 13.13 | 7.3 | — +// +// Error scenario | Test +// ----------------------------------------|------- +// Grok invitation fails | 9.1 +// Grok join timeout | 9.2 +// Grok API error (activation) | 9.3 +// Grok API error (conversation) | 9.4 +// Grok API failure then retry | 9.8 +// Team add fails (teamQueue) | 9.6 +// Team add fails (after Grok removal) | 9.7 +// Grok apiJoinGroup failure | 13.15 +// Replacement team add fails | 13.16 +// Race: /team during Grok join | 10.1 +// Race: state change during API call | 10.2 +// Bot removed / group deleted | 8.4, 8.5 +// Weekend hours | 11.1, 11.2 +// Forwarding format | 12.1, 12.2, 12.3 +// Concurrent conversations | 13.7 +// History passed to GrokApiClient | 13.5 +// Full E2E flows | 14.1, 14.2 diff --git a/apps/simplex-chat-support-bot/package-lock.json b/apps/simplex-chat-support-bot/package-lock.json new file mode 100644 index 0000000000..706dec4f33 --- /dev/null +++ b/apps/simplex-chat-support-bot/package-lock.json @@ -0,0 +1,1498 @@ +{ + "name": "simplex-chat-support-bot", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "simplex-chat-support-bot", + "version": "0.1.0", + "license": "AGPL-3.0", + "dependencies": { + "@simplex-chat/types": "^0.3.0", + "simplex-chat": "^6.5.0-beta.4.4" + }, + "devDependencies": { + "@types/node": "^25.0.5", + "typescript": "^5.9.3", + "vitest": "^2.1.9" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz", + "integrity": "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.1.tgz", + "integrity": "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.1.tgz", + "integrity": "sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.1.tgz", + "integrity": "sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.1.tgz", + "integrity": "sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.1.tgz", + "integrity": "sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.1.tgz", + "integrity": "sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.1.tgz", + "integrity": "sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.1.tgz", + "integrity": "sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.1.tgz", + "integrity": "sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.1.tgz", + "integrity": "sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.1.tgz", + "integrity": "sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.1.tgz", + "integrity": "sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.1.tgz", + "integrity": "sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.1.tgz", + "integrity": "sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.1.tgz", + "integrity": "sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.1.tgz", + "integrity": "sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.1.tgz", + "integrity": "sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.1.tgz", + "integrity": "sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.1.tgz", + "integrity": "sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.1.tgz", + "integrity": "sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.1.tgz", + "integrity": "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.1.tgz", + "integrity": "sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.1.tgz", + "integrity": "sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.1.tgz", + "integrity": "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@simplex-chat/types": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@simplex-chat/types/-/types-0.3.0.tgz", + "integrity": "sha512-3Y+LEIwVvGgE2u7v7hMcLsOV8BSUxyfnJnrUn3VKKWf+bIo06a2wbsPrswVW3cb30rTUbNpfhY6GCCpIIkl2jw==", + "dependencies": { + "typescript": "^5.9.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true + }, + "node_modules/@types/node": { + "version": "25.2.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.2.3.tgz", + "integrity": "sha512-m0jEgYlYz+mDJZ2+F4v8D1AyQb+QzsNqRuI7xg1VQX/KlKS0qT9r1Mo16yo5F/MtifXFgaofIFsdFMox2SxIbQ==", + "devOptional": true, + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/@types/yauzl": { + "version": "2.10.3", + "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz", + "integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==", + "optional": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@vitest/expect": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz", + "integrity": "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==", + "dev": true, + "dependencies": { + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.9.tgz", + "integrity": "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==", + "dev": true, + "dependencies": { + "@vitest/spy": "2.1.9", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.12" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.9.tgz", + "integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==", + "dev": true, + "dependencies": { + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.9.tgz", + "integrity": "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==", + "dev": true, + "dependencies": { + "@vitest/utils": "2.1.9", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.9.tgz", + "integrity": "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==", + "dev": true, + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "magic-string": "^0.30.12", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.9.tgz", + "integrity": "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==", + "dev": true, + "dependencies": { + "tinyspy": "^3.0.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.9.tgz", + "integrity": "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==", + "dev": true, + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "loupe": "^3.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "engines": { + "node": ">=12" + } + }, + "node_modules/buffer-crc32": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", + "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", + "engines": { + "node": "*" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "engines": { + "node": ">= 16" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/extract-zip": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", + "integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==", + "dependencies": { + "debug": "^4.1.1", + "get-stream": "^5.1.0", + "yauzl": "^2.10.0" + }, + "bin": { + "extract-zip": "cli.js" + }, + "engines": { + "node": ">= 10.17.0" + }, + "optionalDependencies": { + "@types/yauzl": "^2.9.1" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" + }, + "node_modules/fd-slicer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", + "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", + "dependencies": { + "pend": "~1.2.0" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/get-stream": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", + "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", + "dependencies": { + "pump": "^3.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-addon-api": { + "version": "8.5.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.5.0.tgz", + "integrity": "sha512-/bRZty2mXUIFY/xU5HLvveNHlswNJej+RnxBjOMkidWfwZzgTbPG1E3K5TOxRLOR+5hX7bSofy8yf1hZevMS8A==", + "engines": { + "node": "^18 || ^20 || >= 21" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/pend": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", + "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/pump": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz", + "integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/rollup": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.1.tgz", + "integrity": "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==", + "dev": true, + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.57.1", + "@rollup/rollup-android-arm64": "4.57.1", + "@rollup/rollup-darwin-arm64": "4.57.1", + "@rollup/rollup-darwin-x64": "4.57.1", + "@rollup/rollup-freebsd-arm64": "4.57.1", + "@rollup/rollup-freebsd-x64": "4.57.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.57.1", + "@rollup/rollup-linux-arm-musleabihf": "4.57.1", + "@rollup/rollup-linux-arm64-gnu": "4.57.1", + "@rollup/rollup-linux-arm64-musl": "4.57.1", + "@rollup/rollup-linux-loong64-gnu": "4.57.1", + "@rollup/rollup-linux-loong64-musl": "4.57.1", + "@rollup/rollup-linux-ppc64-gnu": "4.57.1", + "@rollup/rollup-linux-ppc64-musl": "4.57.1", + "@rollup/rollup-linux-riscv64-gnu": "4.57.1", + "@rollup/rollup-linux-riscv64-musl": "4.57.1", + "@rollup/rollup-linux-s390x-gnu": "4.57.1", + "@rollup/rollup-linux-x64-gnu": "4.57.1", + "@rollup/rollup-linux-x64-musl": "4.57.1", + "@rollup/rollup-openbsd-x64": "4.57.1", + "@rollup/rollup-openharmony-arm64": "4.57.1", + "@rollup/rollup-win32-arm64-msvc": "4.57.1", + "@rollup/rollup-win32-ia32-msvc": "4.57.1", + "@rollup/rollup-win32-x64-gnu": "4.57.1", + "@rollup/rollup-win32-x64-msvc": "4.57.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true + }, + "node_modules/simplex-chat": { + "version": "6.5.0-beta.4.4", + "resolved": "https://registry.npmjs.org/simplex-chat/-/simplex-chat-6.5.0-beta.4.4.tgz", + "integrity": "sha512-IxLb/6bFfZuclfMjf6ihM9JNSIe8eNYGAhZtPXE/iG4IPeSd6clBjV1T6Ck1OzNr0coDY9uXrbQsB5JOep1Wxg==", + "hasInstallScript": true, + "dependencies": { + "@simplex-chat/types": "^0.3.0", + "extract-zip": "^2.0.1", + "fast-deep-equal": "^3.1.3", + "node-addon-api": "^8.5.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz", + "integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==", + "dev": true, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", + "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", + "dev": true, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "devOptional": true + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.9.tgz", + "integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==", + "dev": true, + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.3.7", + "es-module-lexer": "^1.5.4", + "pathe": "^1.1.2", + "vite": "^5.0.0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.9.tgz", + "integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==", + "dev": true, + "dependencies": { + "@vitest/expect": "2.1.9", + "@vitest/mocker": "2.1.9", + "@vitest/pretty-format": "^2.1.9", + "@vitest/runner": "2.1.9", + "@vitest/snapshot": "2.1.9", + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "debug": "^4.3.7", + "expect-type": "^1.1.0", + "magic-string": "^0.30.12", + "pathe": "^1.1.2", + "std-env": "^3.8.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.1", + "tinypool": "^1.0.1", + "tinyrainbow": "^1.2.0", + "vite": "^5.0.0", + "vite-node": "2.1.9", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/node": "^18.0.0 || >=20.0.0", + "@vitest/browser": "2.1.9", + "@vitest/ui": "2.1.9", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" + }, + "node_modules/yauzl": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", + "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", + "dependencies": { + "buffer-crc32": "~0.2.3", + "fd-slicer": "~1.1.0" + } + } + } +} diff --git a/apps/simplex-chat-support-bot/package.json b/apps/simplex-chat-support-bot/package.json new file mode 100644 index 0000000000..1436875289 --- /dev/null +++ b/apps/simplex-chat-support-bot/package.json @@ -0,0 +1,22 @@ +{ + "name": "simplex-chat-support-bot", + "version": "0.1.0", + "private": true, + "main": "dist/index.js", + "scripts": { + "build": "tsc", + "start": "node dist/index.js", + "test": "vitest run" + }, + "dependencies": { + "@simplex-chat/types": "^0.3.0", + "simplex-chat": "^6.5.0-beta.4.4" + }, + "devDependencies": { + "@types/node": "^25.0.5", + "typescript": "^5.9.3", + "vitest": "^2.1.9" + }, + "author": "SimpleX Chat", + "license": "AGPL-3.0" +} diff --git a/apps/simplex-chat-support-bot/src/bot.ts b/apps/simplex-chat-support-bot/src/bot.ts new file mode 100644 index 0000000000..9843907a7a --- /dev/null +++ b/apps/simplex-chat-support-bot/src/bot.ts @@ -0,0 +1,430 @@ +import {api, util} from "simplex-chat" +import {T, CEvt} from "@simplex-chat/types" +import {Config} from "./config.js" +import {ConversationState, GrokMessage} from "./state.js" +import {GrokApiClient} from "./grok.js" +import {teamQueueMessage, grokActivatedMessage, teamAddedMessage, teamLockedMessage} from "./messages.js" +import {log, logError} from "./util.js" + +export class SupportBot { + private conversations = new Map() + private pendingGrokJoins = new Map() // memberId → mainGroupId + private grokGroupMap = new Map() // mainGroupId → grokLocalGroupId + private reverseGrokMap = new Map() // grokLocalGroupId → mainGroupId + private grokJoinResolvers = new Map void>() // mainGroupId → resolve fn + + constructor( + private mainChat: api.ChatApi, + private grokChat: api.ChatApi, + private grokApi: GrokApiClient, + private config: Config, + ) {} + + // --- Event Handlers (main bot) --- + + onBusinessRequest(evt: CEvt.AcceptingBusinessRequest): void { + const groupId = evt.groupInfo.groupId + log(`New business request: groupId=${groupId}`) + this.conversations.set(groupId, {type: "welcome"}) + } + + async onNewChatItems(evt: CEvt.NewChatItems): Promise { + for (const ci of evt.chatItems) { + try { + await this.processChatItem(ci) + } catch (err) { + logError(`Error processing chat item in group`, err) + } + } + } + + async onLeftMember(evt: CEvt.LeftMember): Promise { + const groupId = evt.groupInfo.groupId + const state = this.conversations.get(groupId) + if (!state) return + + const member = evt.member + const bc = evt.groupInfo.businessChat + if (!bc) return + + // Customer left + if (member.memberId === bc.customerId) { + log(`Customer left group ${groupId}, cleaning up`) + this.conversations.delete(groupId) + this.cleanupGrokMaps(groupId) + return + } + + // Team member left — teamPending: gate not yet triggered, revert to teamQueue + if (state.type === "teamPending" && member.groupMemberId === state.teamMemberGId) { + log(`Team member left group ${groupId} (teamPending), reverting to teamQueue`) + this.conversations.set(groupId, {type: "teamQueue", userMessages: []}) + return + } + + // Team member left — teamLocked: one-way gate triggered, stay in team mode (add another member) + if (state.type === "teamLocked" && member.groupMemberId === state.teamMemberGId) { + log(`Team member left group ${groupId} (teamLocked), adding replacement team member`) + await this.addReplacementTeamMember(groupId) + return + } + + // Grok left during grokMode + if (state.type === "grokMode" && member.groupMemberId === state.grokMemberGId) { + log(`Grok left group ${groupId} during grokMode, reverting to teamQueue`) + this.conversations.set(groupId, {type: "teamQueue", userMessages: []}) + this.cleanupGrokMaps(groupId) + return + } + } + + onDeletedMemberUser(evt: CEvt.DeletedMemberUser): void { + const groupId = evt.groupInfo.groupId + log(`Bot removed from group ${groupId}`) + this.conversations.delete(groupId) + this.cleanupGrokMaps(groupId) + } + + onGroupDeleted(evt: CEvt.GroupDeleted): void { + const groupId = evt.groupInfo.groupId + log(`Group ${groupId} deleted`) + this.conversations.delete(groupId) + this.cleanupGrokMaps(groupId) + } + + onMemberConnected(evt: CEvt.ConnectedToGroupMember): void { + log(`Member connected in group ${evt.groupInfo.groupId}: ${evt.member.memberProfile.displayName}`) + } + + // --- Event Handler (Grok agent) --- + + async onGrokGroupInvitation(evt: CEvt.ReceivedGroupInvitation): Promise { + const memberId = evt.groupInfo.membership.memberId + const mainGroupId = this.pendingGrokJoins.get(memberId) + if (mainGroupId === undefined) { + log(`Grok received unexpected group invitation (memberId=${memberId}), ignoring`) + return + } + log(`Grok joining group: mainGroupId=${mainGroupId}, grokGroupId=${evt.groupInfo.groupId}`) + this.pendingGrokJoins.delete(memberId) + try { + await this.grokChat.apiJoinGroup(evt.groupInfo.groupId) + } catch (err) { + logError(`Grok failed to join group ${evt.groupInfo.groupId}`, err) + return + } + + // Join succeeded — set maps and resolve waiter + this.grokGroupMap.set(mainGroupId, evt.groupInfo.groupId) + this.reverseGrokMap.set(evt.groupInfo.groupId, mainGroupId) + const resolver = this.grokJoinResolvers.get(mainGroupId) + if (resolver) { + this.grokJoinResolvers.delete(mainGroupId) + resolver() + } + } + + // --- Internal Processing --- + + private async processChatItem(ci: T.AChatItem): Promise { + const {chatInfo, chatItem} = ci + if (chatInfo.type !== "group") return + const groupInfo = chatInfo.groupInfo + if (!groupInfo.businessChat) return + const groupId = groupInfo.groupId + const state = this.conversations.get(groupId) + if (!state) return + + if (chatItem.chatDir.type === "groupSnd") return + if (chatItem.chatDir.type !== "groupRcv") return + const sender = chatItem.chatDir.groupMember + + const isCustomer = sender.memberId === groupInfo.businessChat.customerId + const isTeamMember = (state.type === "teamPending" || state.type === "teamLocked") + && sender.groupMemberId === state.teamMemberGId + const isGrok = state.type === "grokMode" + && state.grokMemberGId === sender.groupMemberId + + if (isGrok) return + if (isCustomer) await this.onCustomerMessage(groupId, groupInfo, chatItem, state) + else if (isTeamMember) await this.onTeamMemberMessage(groupId, state) + } + + private async onCustomerMessage( + groupId: number, + groupInfo: T.GroupInfo, + chatItem: T.ChatItem, + state: ConversationState, + ): Promise { + const cmd = util.ciBotCommand(chatItem) + const text = util.ciContentText(chatItem)?.trim() || null + + switch (state.type) { + case "welcome": { + if (!text) return + await this.forwardToTeam(groupId, groupInfo, text) + await this.sendToGroup(groupId, teamQueueMessage(this.config.timezone)) + this.conversations.set(groupId, {type: "teamQueue", userMessages: [text]}) + break + } + + case "teamQueue": { + if (cmd?.keyword === "grok") { + await this.activateGrok(groupId, state) + return + } + if (cmd?.keyword === "team") { + await this.activateTeam(groupId, state) + return + } + if (!text) return + await this.forwardToTeam(groupId, groupInfo, text) + state.userMessages.push(text) + break + } + + case "grokMode": { + if (cmd?.keyword === "grok") return + if (cmd?.keyword === "team") { + await this.activateTeam(groupId, state) + return + } + if (!text) return + await this.forwardToTeam(groupId, groupInfo, text) + await this.forwardToGrok(groupId, text, state) + break + } + + case "teamPending": { + if (cmd?.keyword === "grok") { + await this.sendToGroup(groupId, teamLockedMessage) + return + } + // /team → ignore (already team). Other text → no forwarding (team sees directly). + break + } + + case "teamLocked": { + if (cmd?.keyword === "grok") { + await this.sendToGroup(groupId, teamLockedMessage) + return + } + // No action — team sees directly + break + } + } + } + + private async onTeamMemberMessage(groupId: number, state: ConversationState): Promise { + if (state.type !== "teamPending") return + log(`Team member engaged in group ${groupId}, locking to teamLocked`) + this.conversations.set(groupId, {type: "teamLocked", teamMemberGId: state.teamMemberGId}) + } + + // --- Grok Activation --- + + private async activateGrok( + groupId: number, + state: {type: "teamQueue"; userMessages: string[]}, + ): Promise { + const grokContactId = this.config.grokContact!.id + let member: T.GroupMember | undefined + try { + member = await this.mainChat.apiAddMember(groupId, grokContactId, T.GroupMemberRole.Member) + } catch (err) { + logError(`Failed to invite Grok to group ${groupId}`, err) + await this.sendToGroup(groupId, "Grok is temporarily unavailable. Please try again or click /team for a team member.") + return + } + + this.pendingGrokJoins.set(member.memberId, groupId) + await this.sendToGroup(groupId, grokActivatedMessage) + + // Wait for Grok agent to join the group + const joined = await this.waitForGrokJoin(groupId, 30000) + if (!joined) { + this.pendingGrokJoins.delete(member.memberId) + await this.sendToGroup(groupId, "Grok is temporarily unavailable. Please try again or click /team for a team member.") + return + } + + // Verify state hasn't changed while awaiting (e.g., user sent /team concurrently) + const currentState = this.conversations.get(groupId) + if (!currentState || currentState.type !== "teamQueue") { + log(`State changed during Grok activation for group ${groupId} (now ${currentState?.type}), aborting`) + try { + await this.mainChat.apiRemoveMembers(groupId, [member.groupMemberId]) + } catch { + // ignore + } + this.cleanupGrokMaps(groupId) + return + } + + // Grok joined — call API with accumulated messages + try { + const initialUserMsg = state.userMessages.join("\n") + const response = await this.grokApi.chat([], initialUserMsg) + + // Re-check state after async API call — another event may have changed it + const postApiState = this.conversations.get(groupId) + if (!postApiState || postApiState.type !== "teamQueue") { + log(`State changed during Grok API call for group ${groupId} (now ${postApiState?.type}), aborting`) + try { + await this.mainChat.apiRemoveMembers(groupId, [member.groupMemberId]) + } catch { + // ignore + } + this.cleanupGrokMaps(groupId) + return + } + + const history: GrokMessage[] = [ + {role: "user", content: initialUserMsg}, + {role: "assistant", content: response}, + ] + + const grokLocalGId = this.grokGroupMap.get(groupId) + if (grokLocalGId === undefined) { + log(`Grok map entry missing after join for group ${groupId}`) + return + } + await this.grokChat.apiSendTextMessage([T.ChatType.Group, grokLocalGId], response) + + this.conversations.set(groupId, { + type: "grokMode", + grokMemberGId: member.groupMemberId, + history, + }) + } catch (err) { + logError(`Grok API/send failed for group ${groupId}`, err) + // Remove Grok since activation failed after join + try { + await this.mainChat.apiRemoveMembers(groupId, [member.groupMemberId]) + } catch { + // ignore + } + this.cleanupGrokMaps(groupId) + await this.sendToGroup(groupId, "Grok is temporarily unavailable. Please try again or click /team for a team member.") + // Stay in teamQueue + } + } + + // --- Grok Message Forwarding --- + + private async forwardToGrok( + groupId: number, + text: string, + state: {type: "grokMode"; grokMemberGId: number; history: GrokMessage[]}, + ): Promise { + try { + const response = await this.grokApi.chat(state.history, text) + state.history.push({role: "user", content: text}) + state.history.push({role: "assistant", content: response}) + + const grokLocalGId = this.grokGroupMap.get(groupId) + if (grokLocalGId !== undefined) { + await this.grokChat.apiSendTextMessage([T.ChatType.Group, grokLocalGId], response) + } + } catch (err) { + logError(`Grok API error for group ${groupId}`, err) + // Per plan: revert to teamQueue on Grok API failure — remove Grok, clean up + try { + await this.mainChat.apiRemoveMembers(groupId, [state.grokMemberGId]) + } catch { + // ignore — may have already left + } + this.cleanupGrokMaps(groupId) + this.conversations.set(groupId, {type: "teamQueue", userMessages: []}) + await this.sendToGroup(groupId, "Grok is temporarily unavailable. Please try again or click /team for a team member.") + } + } + + // --- Team Actions --- + + private async forwardToTeam(groupId: number, groupInfo: T.GroupInfo, text: string): Promise { + const name = groupInfo.groupProfile.displayName || `group-${groupId}` + const fwd = `[${name} #${groupId}]\n${text}` + try { + await this.mainChat.apiSendTextMessage( + [T.ChatType.Group, this.config.teamGroup.id], + fwd, + ) + } catch (err) { + logError(`Failed to forward to team for group ${groupId}`, err) + } + } + + private async activateTeam(groupId: number, state: ConversationState): Promise { + // Remove Grok immediately if present (per spec: "When switching to team mode, Grok is removed") + const wasGrokMode = state.type === "grokMode" + if (wasGrokMode) { + try { + await this.mainChat.apiRemoveMembers(groupId, [state.grokMemberGId]) + } catch { + // ignore — may have already left + } + this.cleanupGrokMaps(groupId) + } + try { + const teamContactId = this.config.teamMembers[0].id + const member = await this.mainChat.apiAddMember(groupId, teamContactId, T.GroupMemberRole.Member) + this.conversations.set(groupId, { + type: "teamPending", + teamMemberGId: member.groupMemberId, + }) + await this.sendToGroup(groupId, teamAddedMessage(this.config.timezone)) + } catch (err) { + logError(`Failed to add team member to group ${groupId}`, err) + // If Grok was removed, state is stale (grokMode but Grok gone) — revert to teamQueue + if (wasGrokMode) { + this.conversations.set(groupId, {type: "teamQueue", userMessages: []}) + } + await this.sendToGroup(groupId, "Sorry, there was an error adding a team member. Please try again.") + } + } + + // --- Helpers --- + + private async addReplacementTeamMember(groupId: number): Promise { + try { + const teamContactId = this.config.teamMembers[0].id + const member = await this.mainChat.apiAddMember(groupId, teamContactId, T.GroupMemberRole.Member) + this.conversations.set(groupId, {type: "teamLocked", teamMemberGId: member.groupMemberId}) + } catch (err) { + logError(`Failed to add replacement team member to group ${groupId}`, err) + // Stay in teamLocked with stale teamMemberGId — one-way gate must hold + // Team will see the message in team group and can join manually + } + } + + private async sendToGroup(groupId: number, text: string): Promise { + try { + await this.mainChat.apiSendTextMessage([T.ChatType.Group, groupId], text) + } catch (err) { + logError(`Failed to send message to group ${groupId}`, err) + } + } + + private waitForGrokJoin(groupId: number, timeout: number): Promise { + if (this.grokGroupMap.has(groupId)) return Promise.resolve(true) + return new Promise((resolve) => { + const timer = setTimeout(() => { + this.grokJoinResolvers.delete(groupId) + resolve(false) + }, timeout) + this.grokJoinResolvers.set(groupId, () => { + clearTimeout(timer) + resolve(true) + }) + }) + } + + private cleanupGrokMaps(groupId: number): void { + const grokLocalGId = this.grokGroupMap.get(groupId) + this.grokGroupMap.delete(groupId) + if (grokLocalGId !== undefined) { + this.reverseGrokMap.delete(grokLocalGId) + } + } +} diff --git a/apps/simplex-chat-support-bot/src/config.ts b/apps/simplex-chat-support-bot/src/config.ts new file mode 100644 index 0000000000..4036886eac --- /dev/null +++ b/apps/simplex-chat-support-bot/src/config.ts @@ -0,0 +1,74 @@ +export interface IdName { + id: number + name: string +} + +export interface Config { + dbPrefix: string + grokDbPrefix: string + teamGroup: IdName + teamMembers: IdName[] + grokContact: IdName | null // null during first-run + groupLinks: string + timezone: string + grokApiKey: string + firstRun: boolean +} + +export function parseIdName(s: string): IdName { + const i = s.indexOf(":") + if (i < 1) throw new Error(`Invalid ID:name format: "${s}"`) + const id = parseInt(s.slice(0, i), 10) + if (isNaN(id)) throw new Error(`Invalid ID:name format (non-numeric ID): "${s}"`) + return {id, name: s.slice(i + 1)} +} + +function requiredArg(args: string[], flag: string): string { + const i = args.indexOf(flag) + if (i < 0 || i + 1 >= args.length) throw new Error(`Missing required argument: ${flag}`) + return args[i + 1] +} + +function optionalArg(args: string[], flag: string, defaultValue: string): string { + const i = args.indexOf(flag) + if (i < 0 || i + 1 >= args.length) return defaultValue + return args[i + 1] +} + +export function parseConfig(args: string[]): Config { + const firstRun = args.includes("--first-run") + + const grokApiKey = process.env.GROK_API_KEY + if (!grokApiKey) throw new Error("Missing environment variable: GROK_API_KEY") + + const dbPrefix = optionalArg(args, "--db-prefix", "./data/bot") + const grokDbPrefix = optionalArg(args, "--grok-db-prefix", "./data/grok") + const teamGroup = parseIdName(requiredArg(args, "--team-group")) + const teamMembers = requiredArg(args, "--team-members").split(",").map(parseIdName) + if (teamMembers.length === 0) throw new Error("--team-members must have at least one member") + + let grokContact: IdName | null = null + if (!firstRun) { + grokContact = parseIdName(requiredArg(args, "--grok-contact")) + } else { + const i = args.indexOf("--grok-contact") + if (i >= 0 && i + 1 < args.length) { + grokContact = parseIdName(args[i + 1]) + } + } + + const groupLinks = optionalArg(args, "--group-links", "") + const timezone = optionalArg(args, "--timezone", "UTC") + + return { + dbPrefix, + grokDbPrefix, + teamGroup, + teamMembers, + grokContact, + groupLinks, + timezone, + grokApiKey, + firstRun, + } +} diff --git a/apps/simplex-chat-support-bot/src/grok.ts b/apps/simplex-chat-support-bot/src/grok.ts new file mode 100644 index 0000000000..97e8922e98 --- /dev/null +++ b/apps/simplex-chat-support-bot/src/grok.ts @@ -0,0 +1,45 @@ +import {GrokMessage} from "./state.js" +import {log} from "./util.js" + +interface GrokApiMessage { + role: "system" | "user" | "assistant" + content: string +} + +interface GrokApiResponse { + choices: {message: {content: string}}[] +} + +export class GrokApiClient { + constructor(private apiKey: string, private docsContext: string) {} + + async chat(history: GrokMessage[], userMessage: string): Promise { + const messages: GrokApiMessage[] = [ + {role: "system", content: this.systemPrompt()}, + ...history.slice(-20), + {role: "user", content: userMessage}, + ] + log(`Grok API call: ${history.length} history msgs + new user msg (${userMessage.length} chars)`) + const resp = await fetch("https://api.x.ai/v1/chat/completions", { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${this.apiKey}`, + }, + body: JSON.stringify({model: "grok-3", messages, max_tokens: 2048}), + }) + if (!resp.ok) { + const body = await resp.text() + throw new Error(`Grok API ${resp.status}: ${body}`) + } + const data = (await resp.json()) as GrokApiResponse + const content = data.choices[0]?.message?.content + if (!content) throw new Error("Grok API returned empty response") + log(`Grok API response: ${content.length} chars`) + return content + } + + private systemPrompt(): string { + return `You are a privacy expert and SimpleX Chat evangelist. You know everything about SimpleX Chat apps, network, design choices, and trade-offs. Be helpful, accurate, and concise. If you don't know something, say so honestly rather than guessing. For every criticism, explain why the team made that design choice.\n\n${this.docsContext}` + } +} diff --git a/apps/simplex-chat-support-bot/src/index.ts b/apps/simplex-chat-support-bot/src/index.ts new file mode 100644 index 0000000000..8f35bebb9e --- /dev/null +++ b/apps/simplex-chat-support-bot/src/index.ts @@ -0,0 +1,179 @@ +import {readFileSync} from "fs" +import {join} from "path" +import {bot, api} from "simplex-chat" +import {parseConfig} from "./config.js" +import {SupportBot} from "./bot.js" +import {GrokApiClient} from "./grok.js" +import {welcomeMessage} from "./messages.js" +import {log, logError} from "./util.js" + +async function main(): Promise { + const config = parseConfig(process.argv.slice(2)) + log("Config parsed", { + dbPrefix: config.dbPrefix, + grokDbPrefix: config.grokDbPrefix, + teamGroup: config.teamGroup, + teamMembers: config.teamMembers, + grokContact: config.grokContact, + firstRun: config.firstRun, + timezone: config.timezone, + }) + + // --- Init Grok agent (direct ChatApi) --- + log("Initializing Grok agent...") + const grokChat = await api.ChatApi.init(config.grokDbPrefix) + let grokUser = await grokChat.apiGetActiveUser() + if (!grokUser) { + log("No Grok user, creating...") + grokUser = await grokChat.apiCreateActiveUser({displayName: "Grok AI", fullName: ""}) + } + log(`Grok user: ${grokUser.profile.displayName}`) + await grokChat.startChat() + + // --- First-run mode: establish contact between bot and Grok agent --- + if (config.firstRun) { + log("First-run mode: establishing bot↔Grok contact...") + // We need to init the main bot first to create the invitation link + const mainChat = await api.ChatApi.init(config.dbPrefix) + let mainUser = await mainChat.apiGetActiveUser() + if (!mainUser) { + log("No main bot user, creating...") + mainUser = await mainChat.apiCreateActiveUser({displayName: "SimpleX Support", fullName: ""}) + } + await mainChat.startChat() + + const invLink = await mainChat.apiCreateLink(mainUser.userId) + log(`Invitation link created: ${invLink}`) + + await grokChat.apiConnectActiveUser(invLink) + log("Grok agent connecting...") + + const evt = await mainChat.wait("contactConnected", 60000) + if (!evt) { + console.error("Timeout waiting for Grok agent to connect (60s). Exiting.") + process.exit(1) + } + const contactId = evt.contact.contactId + const displayName = evt.contact.profile.displayName + log(`Grok contact established. ContactId=${contactId}`) + console.log(`\nGrok contact established. Use: --grok-contact ${contactId}:${displayName}\n`) + process.exit(0) + } + + // --- Normal mode: validate config, init main bot --- + if (!config.grokContact) { + console.error("--grok-contact is required (unless --first-run)") + process.exit(1) + } + + // SupportBot forward-reference: assigned after bot.run returns. + // Events use optional chaining so any events during init are safely skipped. + let supportBot: SupportBot | undefined + + const events: api.EventSubscribers = { + acceptingBusinessRequest: (evt) => supportBot?.onBusinessRequest(evt), + newChatItems: (evt) => supportBot?.onNewChatItems(evt), + leftMember: (evt) => supportBot?.onLeftMember(evt), + deletedMemberUser: (evt) => supportBot?.onDeletedMemberUser(evt), + groupDeleted: (evt) => supportBot?.onGroupDeleted(evt), + connectedToGroupMember: (evt) => supportBot?.onMemberConnected(evt), + } + + log("Initializing main bot...") + const [mainChat, mainUser, _mainAddress] = await bot.run({ + profile: {displayName: "SimpleX Support", fullName: ""}, + dbOpts: {dbFilePrefix: config.dbPrefix}, + options: { + addressSettings: { + businessAddress: true, + autoAccept: true, + welcomeMessage: welcomeMessage(config.groupLinks), + }, + commands: [ + {type: "command", keyword: "grok", label: "Ask Grok AI"}, + {type: "command", keyword: "team", label: "Switch to team"}, + ], + useBotProfile: true, + }, + events, + }) + log(`Main bot user: ${mainUser.profile.displayName}`) + + // --- Startup validation --- + log("Validating config against live data...") + + // Validate team group + const groups = await mainChat.apiListGroups(mainUser.userId) + const teamGroup = groups.find(g => g.groupId === config.teamGroup.id) + if (!teamGroup) { + console.error(`Team group not found: ID=${config.teamGroup.id}. Available groups: ${groups.map(g => `${g.groupId}:${g.groupProfile.displayName}`).join(", ") || "(none)"}`) + process.exit(1) + } + if (teamGroup.groupProfile.displayName !== config.teamGroup.name) { + console.error(`Team group name mismatch: expected "${config.teamGroup.name}", got "${teamGroup.groupProfile.displayName}" (ID=${config.teamGroup.id})`) + process.exit(1) + } + log(`Team group validated: ${config.teamGroup.id}:${config.teamGroup.name}`) + + // Validate contacts (team members + Grok) + const contacts = await mainChat.apiListContacts(mainUser.userId) + for (const member of config.teamMembers) { + const contact = contacts.find(c => c.contactId === member.id) + if (!contact) { + console.error(`Team member not found: ID=${member.id}. Available contacts: ${contacts.map(c => `${c.contactId}:${c.profile.displayName}`).join(", ") || "(none)"}`) + process.exit(1) + } + if (contact.profile.displayName !== member.name) { + console.error(`Team member name mismatch: expected "${member.name}", got "${contact.profile.displayName}" (ID=${member.id})`) + process.exit(1) + } + log(`Team member validated: ${member.id}:${member.name}`) + } + + const grokContact = contacts.find(c => c.contactId === config.grokContact!.id) + if (!grokContact) { + console.error(`Grok contact not found: ID=${config.grokContact.id}. Available contacts: ${contacts.map(c => `${c.contactId}:${c.profile.displayName}`).join(", ") || "(none)"}`) + process.exit(1) + } + if (grokContact.profile.displayName !== config.grokContact.name) { + console.error(`Grok contact name mismatch: expected "${config.grokContact.name}", got "${grokContact.profile.displayName}" (ID=${config.grokContact.id})`) + process.exit(1) + } + log(`Grok contact validated: ${config.grokContact.id}:${config.grokContact.name}`) + + log("All config validated.") + + // Load Grok context docs + let docsContext = "" + try { + docsContext = readFileSync(join(process.cwd(), "docs", "simplex-context.md"), "utf-8") + log(`Loaded Grok context docs: ${docsContext.length} chars`) + } catch { + log("Warning: docs/simplex-context.md not found, Grok will operate without context docs") + } + const grokApi = new GrokApiClient(config.grokApiKey, docsContext) + + // Create SupportBot — event handlers now route through it + supportBot = new SupportBot(mainChat, grokChat, grokApi, config) + log("SupportBot initialized. Bot running.") + + // Subscribe Grok agent event handlers + grokChat.on("receivedGroupInvitation", async (evt) => { + await supportBot?.onGrokGroupInvitation(evt) + }) + + // Keep process alive + process.on("SIGINT", () => { + log("Received SIGINT, shutting down...") + process.exit(0) + }) + process.on("SIGTERM", () => { + log("Received SIGTERM, shutting down...") + process.exit(0) + }) +} + +main().catch(err => { + logError("Fatal error", err) + process.exit(1) +}) diff --git a/apps/simplex-chat-support-bot/src/messages.ts b/apps/simplex-chat-support-bot/src/messages.ts new file mode 100644 index 0000000000..211f07f764 --- /dev/null +++ b/apps/simplex-chat-support-bot/src/messages.ts @@ -0,0 +1,19 @@ +import {isWeekend} from "./util.js" + +export function welcomeMessage(groupLinks: string): string { + return `Hello! Feel free to ask any question about SimpleX Chat.\n*Only SimpleX Chat team has access to your messages.* This is a SimpleX Chat team bot — it is not any LLM or AI.${groupLinks ? `\n*Join public groups*: ${groupLinks}` : ""}\nPlease send questions in English, you can use translator.` +} + +export function teamQueueMessage(timezone: string): string { + const hours = isWeekend(timezone) ? "48" : "24" + return `Thank you for your message, it is forwarded to the team.\nIt may take a team member up to ${hours} hours to reply.\n\nClick /grok if your question is about SimpleX apps or network, is not sensitive, and you want Grok LLM to answer it right away. *Your previous message and all subsequent messages will be forwarded to Grok* until you click /team. You can ask Grok questions in any language and it will not see your profile name.\n\nWe appreciate if you try Grok: you can learn a lot about SimpleX Chat from it. It is objective, answers the way our team would, and it saves our team time.` +} + +export const grokActivatedMessage = `*You are now chatting with Grok. You can send questions in any language.* Your message(s) have been forwarded.\nSend /team at any time to switch to a human team member.` + +export function teamAddedMessage(timezone: string): string { + const hours = isWeekend(timezone) ? "48" : "24" + return `A team member has been added and will reply within ${hours} hours. You can keep describing your issue — they will see the full conversation.` +} + +export const teamLockedMessage = "You are now in team mode. A team member will reply to your message." diff --git a/apps/simplex-chat-support-bot/src/state.ts b/apps/simplex-chat-support-bot/src/state.ts new file mode 100644 index 0000000000..98546a1ca1 --- /dev/null +++ b/apps/simplex-chat-support-bot/src/state.ts @@ -0,0 +1,11 @@ +export interface GrokMessage { + role: "user" | "assistant" + content: string +} + +export type ConversationState = + | {type: "welcome"} + | {type: "teamQueue"; userMessages: string[]} + | {type: "grokMode"; grokMemberGId: number; history: GrokMessage[]} + | {type: "teamPending"; teamMemberGId: number} + | {type: "teamLocked"; teamMemberGId: number} diff --git a/apps/simplex-chat-support-bot/src/util.ts b/apps/simplex-chat-support-bot/src/util.ts new file mode 100644 index 0000000000..89fad64b9a --- /dev/null +++ b/apps/simplex-chat-support-bot/src/util.ts @@ -0,0 +1,14 @@ +export function isWeekend(timezone: string): boolean { + const day = new Intl.DateTimeFormat("en-US", {timeZone: timezone, weekday: "short"}).format(new Date()) + return day === "Sat" || day === "Sun" +} + +export function log(msg: string, ...args: unknown[]): void { + const ts = new Date().toISOString() + console.log(`[${ts}] ${msg}`, ...args) +} + +export function logError(msg: string, err: unknown): void { + const ts = new Date().toISOString() + console.error(`[${ts}] ${msg}`, err) +} diff --git a/apps/simplex-chat-support-bot/support-bot-tests.md b/apps/simplex-chat-support-bot/support-bot-tests.md new file mode 100644 index 0000000000..bb8f664703 --- /dev/null +++ b/apps/simplex-chat-support-bot/support-bot-tests.md @@ -0,0 +1,1451 @@ +// ═══════════════════════════════════════════════════════════════════ +// SimpleX Support Bot — Acceptance Tests +// ═══════════════════════════════════════════════════════════════════ +// +// Human-readable TypeScript tests for the support bot. +// Uses a conversation DSL: users are variables, actions use await, +// assertions use .received() / .stateIs(). +// +// Grok API is mocked. All scenarios from the product specification +// and implementation plan are covered. +// ═══════════════════════════════════════════════════════════════════ + +import {describe, test, expect, beforeEach, 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"}, GroupMemberRole: {Member: "member"}}, + CEvt: {}, +})) + +vi.mock("./src/util", () => ({ + isWeekend: vi.fn(() => false), + log: vi.fn(), + logError: vi.fn(), +})) + +// ─── Imports (after mocks) ─────────────────────────────────────── + +import {SupportBot} from "./src/bot" +import type {GrokMessage} from "./src/state" +import {isWeekend} from "./src/util" + + +// ─── 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 } +interface AddedMember { groupId: number; contactId: number; role: string } +interface RemovedMembers { groupId: number; memberIds: number[] } + +class MockChatApi { + sent: SentMessage[] = [] + added: AddedMember[] = [] + removed: RemovedMembers[] = [] + joined: number[] = [] + + private addMemberFail = false + private nextMemberGId = 50 + + apiAddMemberWillFail() { this.addMemberFail = true } + setNextGroupMemberId(id: number) { this.nextMemberGId = id } + + async apiSendTextMessage(chat: [string, number], text: string) { + this.sent.push({chat, text}) + } + + async apiAddMember(groupId: number, contactId: number, role: string) { + if (this.addMemberFail) { this.addMemberFail = false; throw new Error("apiAddMember failed") } + const gid = this.nextMemberGId++ + this.added.push({groupId, contactId, role}) + return {groupMemberId: gid, memberId: `member-${gid}`} + } + + async apiRemoveMembers(groupId: number, memberIds: number[]) { + this.removed.push({groupId, memberIds}) + } + + async apiJoinGroup(groupId: number) { + this.joined.push(groupId) + } + + 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.addMemberFail = false; this.nextMemberGId = 50 + } +} + + +// ─── 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 +} + +function customerChatItem(text: string | null, command: string | null = null) { + return { + chatInfo: {type: "group", groupInfo: businessGroupInfo()}, + chatItem: { + chatDir: { + type: "groupRcv", + groupMember: {memberId: CUSTOMER_ID, groupMemberId: 10}, + }, + content: {type: "text", text: text ?? ""}, + _botCommand: command, + _text: text, + }, + } as any +} + +function teamMemberChatItem(teamMemberGId: number, text: string) { + return { + chatInfo: {type: "group", groupInfo: businessGroupInfo()}, + chatItem: { + chatDir: { + type: "groupRcv", + groupMember: {memberId: "team-member-1", groupMemberId: teamMemberGId}, + }, + 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}, + }, + 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 ─────────────────────────────────────────────────── +// Thin wrappers that make test bodies read like conversations. +// +// IMPORTANT: activateGrok internally blocks on waitForGrokJoin. +// When testing /grok activation, do NOT await customer.sends("/grok") +// before grokAgent.joins(). Instead use: +// +// const p = customer.sends("/grok") // starts, blocks at waitForGrokJoin +// await grokAgent.joins() // resolves the join +// await p // activateGrok completes +// +// All assertions must come after `await p`. + +let bot: SupportBot +let mainChat: MockChatApi +let grokChat: MockChatApi +let grokApi: MockGrokApi +let lastTeamMemberGId: number +let lastGrokMemberGId: number + +const customer = { + async connects(groupId = GROUP_ID) { + bot.onBusinessRequest({groupInfo: businessGroupInfo(groupId)} as any) + }, + + async sends(text: string, groupId = GROUP_ID) { + const isGrokCmd = text === "/grok" + const isTeamCmd = text === "/team" + const command = isGrokCmd ? "grok" : isTeamCmd ? "team" : null + const ci = customerChatItem(text, command) + ci.chatInfo.groupInfo = businessGroupInfo(groupId) + 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([]) + }, +} + +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) + 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}, + } 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() { + // Flush microtasks so activateGrok reaches waitForGrokJoin before we resolve it. + // activateGrok does: await apiAddMember → pendingGrokJoins.set → await sendToGroup → await waitForGrokJoin + // Each await creates a microtask. setTimeout(r, 0) fires after all microtasks drain. + await new Promise(r => setTimeout(r, 0)) + const memberId = `member-${lastGrokMemberGId}` + await bot.onGrokGroupInvitation({ + groupInfo: { + groupId: GROK_LOCAL, + membership: {memberId}, + }, + } as any) + }, + + async timesOut() { + // Advance fake timers past the 30s join timeout. + // advanceTimersByTimeAsync interleaves microtask processing, so activateGrok's + // internal awaits (apiAddMember, sendToGroup) complete before the 30s timeout fires. + await vi.advanceTimersByTimeAsync(30_001) + }, + + wasRemoved(groupId = GROUP_ID) { + const found = mainChat.removed.some( + r => r.groupId === groupId && r.memberIds.includes(lastGrokMemberGId) + ) + expect(found).toBe(true) + }, + + async leaves(groupId = GROUP_ID) { + await bot.onLeftMember({ + groupInfo: businessGroupInfo(groupId), + member: {memberId: "grok-1", groupMemberId: lastGrokMemberGId}, + } as any) + }, +} + +function stateIs(groupId: number, expectedType: string) { + const state = (bot as any).conversations.get(groupId) + expect(state).toBeDefined() + expect(state.type).toBe(expectedType) +} + +function hasNoState(groupId: number) { + expect((bot as any).conversations.has(groupId)).toBe(false) +} + + +// ─── Constants ────────────────────────────────────────────────── + +const TEAM_QUEUE_24H = + `Thank you for your message, it is forwarded to the team.\n` + + `It may take a team member up to 24 hours to reply.\n\n` + + `Click /grok if your question is about SimpleX apps or network, is not sensitive, ` + + `and you want Grok LLM to answer it right away. *Your previous message and all ` + + `subsequent messages will be forwarded to Grok* until you click /team. You can ask ` + + `Grok questions in any language and it will not see your profile name.\n\n` + + `We appreciate if you try Grok: you can learn a lot about SimpleX Chat from it. ` + + `It is objective, answers the way our team would, and it saves our team time.` + +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.` + + +// ─── Setup ────────────────────────────────────────────────────── + +const config = { + teamGroup: {id: 1, name: "SupportTeam"}, + teamMembers: [{id: 2, name: "Bob"}], + grokContact: {id: 4, name: "Grok AI"}, + timezone: "America/New_York", + groupLinks: "https://simplex.chat/contact#...", + grokApiKey: "test-key", + dbPrefix: "./test-data/bot", + grokDbPrefix:"./test-data/grok", + firstRun: false, +} + +beforeEach(() => { + mainChat = new MockChatApi() + grokChat = new MockChatApi() + grokApi = new MockGrokApi() + // Track the groupMemberIds that apiAddMember returns + mainChat.setNextGroupMemberId(50) + lastTeamMemberGId = 50 + lastGrokMemberGId = 50 + bot = new SupportBot(mainChat as any, grokChat as any, grokApi as any, config as any) + // Reset isWeekend mock to default (weekday) + vi.mocked(isWeekend).mockReturnValue(false) +}) + + +// ─── State Helpers ────────────────────────────────────────────── + +async function reachTeamQueue(...messages: string[]) { + await customer.connects() + await customer.sends(messages[0] || "Hello") + for (const msg of messages.slice(1)) { + await customer.sends(msg) + } +} + +async function reachGrokMode(grokResponse = "Grok answer") { + mainChat.setNextGroupMemberId(60) + lastGrokMemberGId = 60 + await reachTeamQueue("Hello") + grokApi.willRespond(grokResponse) + // Non-awaiting pattern: activateGrok blocks on waitForGrokJoin + const p = customer.sends("/grok") + await grokAgent.joins() + await p +} + +async function reachTeamPending() { + mainChat.setNextGroupMemberId(50) + lastTeamMemberGId = 50 + await reachTeamQueue("Hello") + await customer.sends("/team") +} + +async function reachTeamLocked() { + await reachTeamPending() + await teamMember.sends("I'll help you") +} + + +// ═══════════════════════════════════════════════════════════════ +// TESTS +// ═══════════════════════════════════════════════════════════════ + + +// ─── 1. Connection & Welcome ──────────────────────────────────── + +describe("Connection & Welcome", () => { + + test("new customer connects → welcome state", async () => { + await customer.connects() + + stateIs(GROUP_ID, "welcome") + }) + + test("first message → forwarded to team, queue reply, teamQueue state", async () => { + await customer.connects() + + await customer.sends("How do I create a group?") + + teamGroup.received("[Alice #100]\nHow do I create a group?") + customer.received(TEAM_QUEUE_24H) + stateIs(GROUP_ID, "teamQueue") + }) + + test("non-text message in welcome → ignored", async () => { + await customer.connects() + + await customer.sendsNonText() + + stateIs(GROUP_ID, "welcome") + }) +}) + + +// ─── 2. Team Queue ────────────────────────────────────────────── + +describe("Team Queue", () => { + + test("additional messages forwarded to team, no second queue reply", async () => { + await reachTeamQueue("First question") + mainChat.sent = [] // clear previous messages + + await customer.sends("More details about my issue") + + teamGroup.received("[Alice #100]\nMore details about my issue") + // No queue message sent again — only on first message + expect(mainChat.sentTo(GROUP_ID).length).toBe(0) + stateIs(GROUP_ID, "teamQueue") + }) + + test("multiple messages accumulate in userMessages", async () => { + await customer.connects() + + await customer.sends("Question 1") + await customer.sends("Question 2") + await customer.sends("Question 3") + + teamGroup.received("[Alice #100]\nQuestion 1") + teamGroup.received("[Alice #100]\nQuestion 2") + teamGroup.received("[Alice #100]\nQuestion 3") + + const state = (bot as any).conversations.get(GROUP_ID) + expect(state.userMessages).toEqual(["Question 1", "Question 2", "Question 3"]) + }) + + test("non-text message in teamQueue → ignored", async () => { + await reachTeamQueue("Hello") + mainChat.sent = [] + + await customer.sendsNonText() + + expect(mainChat.sent.length).toBe(0) + stateIs(GROUP_ID, "teamQueue") + }) + + test("unrecognized /command treated as normal text message", async () => { + await reachTeamQueue("Hello") + mainChat.sent = [] + + await customer.sends("/unknown") + + teamGroup.received("[Alice #100]\n/unknown") + stateIs(GROUP_ID, "teamQueue") + }) +}) + + +// ─── 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.") + // Non-awaiting pattern: activateGrok blocks on waitForGrokJoin + const p = customer.sends("/grok") + 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.") + + stateIs(GROUP_ID, "grokMode") + }) + + 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") + 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...") + stateIs(GROUP_ID, "grokMode") + }) +}) + + +// ─── 4. Grok Mode Conversation ───────────────────────────────── + +describe("Grok Mode Conversation", () => { + + test("user messages forwarded to both Grok API and team group", async () => { + await reachGrokMode("Initial answer") + mainChat.sent = [] + + grokApi.willRespond("Follow-up answer from Grok") + await customer.sends("What about encryption?") + + teamGroup.received("[Alice #100]\nWhat about encryption?") + + expect(grokApi.lastCall().history).toEqual([ + {role: "user", content: "Hello"}, + {role: "assistant", content: "Initial answer"}, + ]) + expect(grokApi.lastCall().message).toBe("What about encryption?") + + customer.receivedFromGrok("Follow-up answer from Grok") + stateIs(GROUP_ID, "grokMode") + }) + + test("conversation history grows with each exchange", async () => { + await reachGrokMode("Answer 1") + + grokApi.willRespond("Answer 2") + await customer.sends("Follow-up 1") + + expect(grokApi.lastCall().history).toEqual([ + {role: "user", content: "Hello"}, + {role: "assistant", content: "Answer 1"}, + ]) + + grokApi.willRespond("Answer 3") + await customer.sends("Follow-up 2") + + expect(grokApi.lastCall().history).toEqual([ + {role: "user", content: "Hello"}, + {role: "assistant", content: "Answer 1"}, + {role: "user", content: "Follow-up 1"}, + {role: "assistant", content: "Answer 2"}, + ]) + }) + + test("/grok in grokMode → silently ignored", async () => { + await reachGrokMode() + mainChat.sent = [] + grokApi.reset() + + await customer.sends("/grok") + + expect(mainChat.sent.length).toBe(0) + expect(grokApi.callCount()).toBe(0) + stateIs(GROUP_ID, "grokMode") + }) + + 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) + stateIs(GROUP_ID, "grokMode") + }) +}) + + +// ─── 5. Team Activation ──────────────────────────────────────── + +describe("Team Activation", () => { + + test("/team from teamQueue → team member invited, teamPending", async () => { + mainChat.setNextGroupMemberId(50) + lastTeamMemberGId = 50 + await reachTeamQueue("Hello") + mainChat.sent = [] + + await customer.sends("/team") + + teamMember.wasInvited() + customer.received(TEAM_ADDED_24H) + stateIs(GROUP_ID, "teamPending") + }) + + test("/team from grokMode → Grok removed, team member added", async () => { + await reachGrokMode() + mainChat.setNextGroupMemberId(70) + lastTeamMemberGId = 70 + mainChat.sent = [] + + await customer.sends("/team") + + grokAgent.wasRemoved() + teamMember.wasInvited() + customer.received(TEAM_ADDED_24H) + stateIs(GROUP_ID, "teamPending") + }) +}) + + +// ─── 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) + stateIs(GROUP_ID, "teamPending") + }) + + test("team member sends message → teamLocked", async () => { + await reachTeamPending() + + await teamMember.sends("I'll help you with that") + + stateIs(GROUP_ID, "teamLocked") + }) + + test("/grok in teamLocked → 'team mode' reply", async () => { + await reachTeamLocked() + mainChat.sent = [] + + await customer.sends("/grok") + + customer.received(TEAM_LOCKED_MSG) + stateIs(GROUP_ID, "teamLocked") + }) + + test("/team in teamPending → silently ignored", async () => { + await reachTeamPending() + mainChat.sent = [] + + await customer.sends("/team") + + expect(mainChat.sent.length).toBe(0) + stateIs(GROUP_ID, "teamPending") + }) + + test("/team in teamLocked → silently ignored", async () => { + await reachTeamLocked() + mainChat.sent = [] + + await customer.sends("/team") + + expect(mainChat.sent.length).toBe(0) + stateIs(GROUP_ID, "teamLocked") + }) + + test("customer text in teamPending → no forwarding, no reply", async () => { + await reachTeamPending() + mainChat.sent = [] + + await customer.sends("Here's more info about my issue") + + expect(mainChat.sent.length).toBe(0) + stateIs(GROUP_ID, "teamPending") + }) + + test("customer text in teamLocked → no forwarding, no reply", async () => { + await reachTeamLocked() + mainChat.sent = [] + + await customer.sends("Thank you!") + + expect(mainChat.sent.length).toBe(0) + stateIs(GROUP_ID, "teamLocked") + }) +}) + + +// ─── 7. Gate Reversal vs Irreversibility ──────────────────────── + +describe("Gate Reversal vs Irreversibility", () => { + + test("team member leaves in teamPending → revert to teamQueue", async () => { + await reachTeamPending() + + await teamMember.leaves() + + stateIs(GROUP_ID, "teamQueue") + }) + + test("after teamPending revert, /grok works again", async () => { + await reachTeamPending() + await teamMember.leaves() + // Now back in teamQueue + mainChat.setNextGroupMemberId(61) + lastGrokMemberGId = 61 + + grokApi.willRespond("Grok is back") + const p = customer.sends("/grok") + await grokAgent.joins() + await p + + customer.receivedFromGrok("Grok is back") + stateIs(GROUP_ID, "grokMode") + }) + + test("team member leaves in teamLocked → replacement added, stays locked", async () => { + await reachTeamLocked() + mainChat.added = [] + + await teamMember.leaves() + + // Replacement team member invited, state stays teamLocked + expect(mainChat.added.length).toBe(1) + expect(mainChat.added[0].contactId).toBe(2) + stateIs(GROUP_ID, "teamLocked") + }) + + test("/grok still rejected after replacement in teamLocked", async () => { + await reachTeamLocked() + await teamMember.leaves() + mainChat.sent = [] + + await customer.sends("/grok") + + customer.received(TEAM_LOCKED_MSG) + stateIs(GROUP_ID, "teamLocked") + }) +}) + + +// ─── 8. Member Leave & Cleanup ────────────────────────────────── + +describe("Member Leave & Cleanup", () => { + + test("customer leaves → state deleted", async () => { + await reachTeamQueue("Hello") + + await customer.leaves() + + hasNoState(GROUP_ID) + }) + + test("customer leaves in grokMode → state and grok maps cleaned", async () => { + await reachGrokMode() + + await customer.leaves() + + hasNoState(GROUP_ID) + // grokGroupMap also cleaned (internal) + expect((bot as any).grokGroupMap.has(GROUP_ID)).toBe(false) + }) + + test("Grok leaves during grokMode → revert to teamQueue", async () => { + await reachGrokMode() + + await grokAgent.leaves() + + stateIs(GROUP_ID, "teamQueue") + expect((bot as any).grokGroupMap.has(GROUP_ID)).toBe(false) + }) + + test("bot removed from group → state deleted", async () => { + await reachTeamQueue("Hello") + + bot.onDeletedMemberUser({groupInfo: businessGroupInfo()} as any) + + hasNoState(GROUP_ID) + }) + + test("group deleted → state deleted", async () => { + await reachGrokMode() + + bot.onGroupDeleted({groupInfo: businessGroupInfo()} as any) + + hasNoState(GROUP_ID) + expect((bot as any).grokGroupMap.has(GROUP_ID)).toBe(false) + }) + + test("customer leaves in welcome → state deleted", async () => { + await customer.connects() + + await customer.leaves() + + hasNoState(GROUP_ID) + }) +}) + + +// ─── 9. Error Handling ────────────────────────────────────────── + +describe("Error Handling", () => { + + test("Grok invitation (apiAddMember) fails → error msg, stay in teamQueue", async () => { + await reachTeamQueue("Hello") + mainChat.apiAddMemberWillFail() + mainChat.sent = [] + + await customer.sends("/grok") + + customer.received(GROK_UNAVAILABLE) + expect(grokApi.callCount()).toBe(0) + stateIs(GROUP_ID, "teamQueue") + }) + + test("Grok join timeout → error msg, stay in teamQueue", async () => { + vi.useFakeTimers() + mainChat.setNextGroupMemberId(60) + lastGrokMemberGId = 60 + await reachTeamQueue("Hello") + mainChat.sent = [] + + const sendPromise = customer.sends("/grok") + // advanceTimersByTimeAsync flushes microtasks (so activateGrok reaches waitForGrokJoin) + // then fires the 30s timeout + await grokAgent.timesOut() + await sendPromise + + customer.received(GROK_UNAVAILABLE) + expect(grokApi.callCount()).toBe(0) + stateIs(GROUP_ID, "teamQueue") + vi.useRealTimers() + }) + + 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") + await grokAgent.joins() + await p + + grokAgent.wasRemoved() + customer.received(GROK_UNAVAILABLE) + stateIs(GROUP_ID, "teamQueue") + }) + + test("Grok API error during conversation → remove Grok, revert to teamQueue", async () => { + await reachGrokMode() + grokApi.willFail() + mainChat.sent = [] + + await customer.sends("Another question") + + grokAgent.wasRemoved() + customer.received(GROK_UNAVAILABLE) + stateIs(GROUP_ID, "teamQueue") + }) + + test("after Grok API failure revert, /team still works", async () => { + await reachGrokMode() + grokApi.willFail() + await customer.sends("Failing question") + // Now back in teamQueue + mainChat.setNextGroupMemberId(51) + lastTeamMemberGId = 51 + mainChat.sent = [] + + await customer.sends("/team") + + teamMember.wasInvited() + customer.received(TEAM_ADDED_24H) + stateIs(GROUP_ID, "teamPending") + }) + + test("team member add fails from teamQueue → error, stay in teamQueue", async () => { + await reachTeamQueue("Hello") + mainChat.apiAddMemberWillFail() + mainChat.sent = [] + + await customer.sends("/team") + + customer.received(TEAM_ADD_ERROR) + stateIs(GROUP_ID, "teamQueue") + }) + + test("team member add fails after Grok removal → revert to teamQueue", async () => { + await reachGrokMode() + mainChat.apiAddMemberWillFail() + mainChat.sent = [] + + await customer.sends("/team") + + grokAgent.wasRemoved() + customer.received(TEAM_ADD_ERROR) + // grokMode state is stale (Grok removed) → explicitly reverted to teamQueue + stateIs(GROUP_ID, "teamQueue") + }) + + test("Grok failure then retry succeeds", async () => { + mainChat.setNextGroupMemberId(60) + lastGrokMemberGId = 60 + await reachTeamQueue("Hello") + + // First attempt — API fails + grokApi.willFail() + const p1 = customer.sends("/grok") + await grokAgent.joins() + await p1 + stateIs(GROUP_ID, "teamQueue") + + // Second attempt — succeeds + mainChat.setNextGroupMemberId(61) + lastGrokMemberGId = 61 + grokApi.willRespond("Hello! How can I help?") + const p2 = customer.sends("/grok") + await grokAgent.joins() + await p2 + + customer.receivedFromGrok("Hello! How can I help?") + stateIs(GROUP_ID, "grokMode") + }) +}) + + +// ─── 10. Race Conditions ──────────────────────────────────────── + +describe("Race Conditions", () => { + + test("/team sent while waiting for Grok to join → abort Grok", async () => { + mainChat.setNextGroupMemberId(60) + lastGrokMemberGId = 60 + await reachTeamQueue("Hello") + + // Start /grok — hangs on waitForGrokJoin + grokApi.willRespond("answer") + const grokPromise = customer.sends("/grok") + + // While waiting, /team is processed concurrently + mainChat.setNextGroupMemberId(70) + lastTeamMemberGId = 70 + await customer.sends("/team") + stateIs(GROUP_ID, "teamPending") + + // Grok join completes — but state changed + await grokAgent.joins() + await grokPromise + + // Bot detects state mismatch, removes Grok + grokAgent.wasRemoved() + expect(grokApi.callCount()).toBe(0) + stateIs(GROUP_ID, "teamPending") + }) + + test("state change during Grok API call → abort", 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") + await grokAgent.joins() + // activateGrok now blocked on grokApi.chat + + // While API call is pending, /team changes state + mainChat.setNextGroupMemberId(70) + lastTeamMemberGId = 70 + await customer.sends("/team") + stateIs(GROUP_ID, "teamPending") + + // API call completes — but state changed + resolveGrokCall("Grok answer") + await grokPromise + + grokAgent.wasRemoved() + stateIs(GROUP_ID, "teamPending") + }) +}) + + +// ─── 11. Weekend Hours ────────────────────────────────────────── + +describe("Weekend Hours", () => { + + test("weekend: 48 hours in queue message", async () => { + vi.mocked(isWeekend).mockReturnValue(true) + + await customer.connects() + await customer.sends("Hello") + + customer.received(TEAM_QUEUE_48H) + }) + + 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: [displayName #groupId]\\ntext", async () => { + await customer.connects() + + await customer.sends("My app crashes on startup") + + teamGroup.received("[Alice #100]\nMy app crashes on startup") + }) + + test("grokMode messages also forwarded to team", async () => { + await reachGrokMode() + mainChat.sent = [] + + grokApi.willRespond("Try clearing app data") + await customer.sends("App keeps crashing") + + teamGroup.received("[Alice #100]\nApp keeps crashing") + customer.receivedFromGrok("Try clearing app data") + }) + + test("fallback displayName when empty → group-{id}", async () => { + const emptyNameGroup = {...businessGroupInfo(101), groupProfile: {displayName: ""}} + bot.onBusinessRequest({groupInfo: emptyNameGroup} as any) + mainChat.sent = [] + + // Send message in group 101 with empty display name + const ci = customerChatItem("Hello", null) + ci.chatInfo.groupInfo = emptyNameGroup + ci.chatItem.chatDir.groupMember.memberId = emptyNameGroup.businessChat.customerId + await bot.onNewChatItems({chatItems: [ci]} as any) + + teamGroup.received("[group-101 #101]\nHello") + }) +}) + + +// ─── 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) + stateIs(GROUP_ID, "teamQueue") + }) + + 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) + + hasNoState(999) + }) + + test("message in group with no conversation state → ignored", async () => { + // Group 888 never had onBusinessRequest called + const ci = customerChatItem("Hello", null) + ci.chatInfo.groupInfo = businessGroupInfo(888) + mainChat.sent = [] + + await bot.onNewChatItems({chatItems: [ci]} as any) + + expect(mainChat.sent.length).toBe(0) + hasNoState(888) + }) + + test("Grok's own messages in grokMode → ignored by bot", 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("bot passes full history to GrokApiClient (client truncates internally)", async () => { + await reachGrokMode("Answer 0") + + // Build up 12 more exchanges → 26 history entries total + for (let i = 1; i <= 12; i++) { + grokApi.willRespond(`Answer ${i}`) + await customer.sends(`Question ${i}`) + } + + // 13th exchange — history passed to MockGrokApi has 26 entries + // The real GrokApiClient.chat() does history.slice(-20) before calling the API + grokApi.willRespond("Answer 13") + await customer.sends("Question 13") + + const lastCall = grokApi.lastCall() + expect(lastCall.history.length).toBe(26) + expect(lastCall.message).toBe("Question 13") + }) + + test("unexpected Grok group invitation → ignored", async () => { + await bot.onGrokGroupInvitation({ + groupInfo: { + groupId: 999, + membership: {memberId: "unknown-member"}, + }, + } as any) + + // No crash, no state change, no maps updated + expect(grokChat.joined.length).toBe(0) + }) + + test("multiple concurrent conversations are independent", async () => { + const GROUP_A = 100 + const GROUP_B = 300 + + // Customer A connects + bot.onBusinessRequest({groupInfo: businessGroupInfo(GROUP_A, "Alice")} as any) + stateIs(GROUP_A, "welcome") + + // Customer B connects + bot.onBusinessRequest({groupInfo: businessGroupInfo(GROUP_B, "Charlie")} as any) + stateIs(GROUP_B, "welcome") + + // Customer A sends message → teamQueue + const ciA = customerChatItem("Question A", null) + ciA.chatInfo.groupInfo = businessGroupInfo(GROUP_A, "Alice") + await bot.onNewChatItems({chatItems: [ciA]} as any) + stateIs(GROUP_A, "teamQueue") + + // Customer B still in welcome + stateIs(GROUP_B, "welcome") + }) + + test("Grok leaves during grokMode, customer retries → works", async () => { + await reachGrokMode() + + await grokAgent.leaves() + stateIs(GROUP_ID, "teamQueue") + + // Retry /grok + mainChat.setNextGroupMemberId(62) + lastGrokMemberGId = 62 + grokApi.willRespond("I'm back!") + const p = customer.sends("/grok") + await grokAgent.joins() + await p + + customer.receivedFromGrok("I'm back!") + stateIs(GROUP_ID, "grokMode") + }) + + test("/grok in welcome state → treated as regular text", async () => { + await customer.connects() + + await customer.sends("/grok") + + // welcome state has no command handling — /grok is treated as text + teamGroup.received("[Alice #100]\n/grok") + customer.received(TEAM_QUEUE_24H) + stateIs(GROUP_ID, "teamQueue") + }) + + test("/team in welcome state → treated as regular text", async () => { + await customer.connects() + + await customer.sends("/team") + + // welcome state has no command handling — /team is treated as text + teamGroup.received("[Alice #100]\n/team") + customer.received(TEAM_QUEUE_24H) + stateIs(GROUP_ID, "teamQueue") + }) + + test("non-text message in teamPending → ignored", async () => { + await reachTeamPending() + mainChat.sent = [] + + await customer.sendsNonText() + + expect(mainChat.sent.length).toBe(0) + stateIs(GROUP_ID, "teamPending") + }) + + test("non-text message in teamLocked → ignored", async () => { + await reachTeamLocked() + mainChat.sent = [] + + await customer.sendsNonText() + + expect(mainChat.sent.length).toBe(0) + stateIs(GROUP_ID, "teamLocked") + }) + + test("team member message in teamLocked → no state change", async () => { + await reachTeamLocked() + + // onTeamMemberMessage checks state.type !== "teamPending" → returns + await teamMember.sends("Just checking in") + + stateIs(GROUP_ID, "teamLocked") + }) + + test("unknown member message → silently ignored", async () => { + await reachTeamQueue("Hello") + mainChat.sent = [] + grokApi.reset() + + // A member who is neither customer, nor identified team member, nor Grok + 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) + stateIs(GROUP_ID, "teamQueue") + }) + + 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") + + // Trigger invitation — apiJoinGroup fails, resolver NOT called + await new Promise(r => setTimeout(r, 0)) + const memberId = `member-${lastGrokMemberGId}` + await bot.onGrokGroupInvitation({ + groupInfo: {groupId: GROK_LOCAL, membership: {memberId}}, + } as any) + + // Maps should NOT be set (join failed) + expect((bot as any).grokGroupMap.has(GROUP_ID)).toBe(false) + expect((bot as any).reverseGrokMap.has(GROK_LOCAL)).toBe(false) + }) + + test("replacement team member add fails → stays teamLocked", async () => { + await reachTeamLocked() + mainChat.apiAddMemberWillFail() + + await teamMember.leaves() + + // addReplacementTeamMember failed, but one-way gate holds + stateIs(GROUP_ID, "teamLocked") + }) +}) + + +// ─── 14. Full End-to-End Flows ────────────────────────────────── + +describe("End-to-End Flows", () => { + + test("full flow: welcome → grokMode → /team → teamLocked", async () => { + // Step 1: connect + await customer.connects() + stateIs(GROUP_ID, "welcome") + + // Step 2: first message → teamQueue + await customer.sends("How do I enable disappearing messages?") + teamGroup.received("[Alice #100]\nHow do I enable disappearing messages?") + customer.received(TEAM_QUEUE_24H) + stateIs(GROUP_ID, "teamQueue") + + // Step 3: /grok → grokMode + mainChat.setNextGroupMemberId(60) + lastGrokMemberGId = 60 + grokApi.willRespond("Go to conversation settings and tap 'Disappearing messages'.") + const p = customer.sends("/grok") + await grokAgent.joins() + await p + customer.received(GROK_ACTIVATED) + customer.receivedFromGrok("Go to conversation settings and tap 'Disappearing messages'.") + stateIs(GROUP_ID, "grokMode") + + // Step 4: follow-up in grokMode + grokApi.willRespond("Yes, you can set different timers per conversation.") + await customer.sends("Can I set different timers?") + teamGroup.received("[Alice #100]\nCan I set different timers?") + customer.receivedFromGrok("Yes, you can set different timers per conversation.") + stateIs(GROUP_ID, "grokMode") + + // Step 5: /team → teamPending (Grok removed) + mainChat.setNextGroupMemberId(70) + lastTeamMemberGId = 70 + await customer.sends("/team") + grokAgent.wasRemoved() + teamMember.wasInvited() + customer.received(TEAM_ADDED_24H) + stateIs(GROUP_ID, "teamPending") + + // Step 6: /grok rejected + await customer.sends("/grok") + customer.received(TEAM_LOCKED_MSG) + stateIs(GROUP_ID, "teamPending") + + // Step 7: team member responds → teamLocked + await teamMember.sends("Hi! Let me help you.") + stateIs(GROUP_ID, "teamLocked") + + // Step 8: /grok still rejected + await customer.sends("/grok") + customer.received(TEAM_LOCKED_MSG) + stateIs(GROUP_ID, "teamLocked") + + // Step 9: customer continues — team sees directly, no forwarding + mainChat.sent = [] + await customer.sends("Thanks for helping!") + expect(mainChat.sent.length).toBe(0) + }) + + test("full flow: welcome → teamQueue → /team directly (skip Grok)", async () => { + await customer.connects() + + await customer.sends("I have a billing question") + customer.received(TEAM_QUEUE_24H) + stateIs(GROUP_ID, "teamQueue") + + mainChat.setNextGroupMemberId(50) + lastTeamMemberGId = 50 + await customer.sends("/team") + teamMember.wasInvited() + customer.received(TEAM_ADDED_24H) + stateIs(GROUP_ID, "teamPending") + + await teamMember.sends("Hi, I can help with billing") + stateIs(GROUP_ID, "teamLocked") + }) +}) + + +// ═══════════════════════════════════════════════════════════════ +// Coverage Matrix +// ═══════════════════════════════════════════════════════════════ +// +// State / Input | Text msg | /grok | /team | Non-text | Team msg | Leave | Unknown member +// -------------------|-----------|---------|---------|----------|----------|----------|--------------- +// welcome | 1.2 | 13.9 | 13.10 | 1.3 | — | 8.6 | — +// teamQueue | 2.1, 2.2 | 3.1,3.2 | 5.1 | 2.3 | — | 8.1 | 13.14 +// grokMode | 4.1, 4.2 | 4.3 | 5.2 | 4.4 | — | 8.3 grok | — +// teamPending | 6.6 | 6.1 | 6.4 | 13.11 | 6.2 | 7.1 | — +// teamLocked | 6.7 | 6.3 | 6.5 | 13.12 | 13.13 | 7.3 | — +// +// Error scenario | Test +// ----------------------------------------|------- +// Grok invitation fails | 9.1 +// Grok join timeout | 9.2 +// Grok API error (activation) | 9.3 +// Grok API error (conversation) | 9.4 +// Grok API failure then retry | 9.8 +// Team add fails (teamQueue) | 9.6 +// Team add fails (after Grok removal) | 9.7 +// Grok apiJoinGroup failure | 13.15 +// Replacement team add fails | 13.16 +// Race: /team during Grok join | 10.1 +// Race: state change during API call | 10.2 +// Bot removed / group deleted | 8.4, 8.5 +// Weekend hours | 11.1, 11.2 +// Forwarding format | 12.1, 12.2, 12.3 +// Concurrent conversations | 13.7 +// History passed to GrokApiClient | 13.5 +// Full E2E flows | 14.1, 14.2 diff --git a/apps/simplex-chat-support-bot/tsconfig.json b/apps/simplex-chat-support-bot/tsconfig.json new file mode 100644 index 0000000000..821fa663e3 --- /dev/null +++ b/apps/simplex-chat-support-bot/tsconfig.json @@ -0,0 +1,23 @@ +{ + "include": ["src"], + "compilerOptions": { + "declaration": true, + "forceConsistentCasingInFileNames": true, + "lib": ["ES2022"], + "module": "Node16", + "moduleResolution": "Node16", + "noFallthroughCasesInSwitch": true, + "noImplicitAny": true, + "noImplicitReturns": true, + "noImplicitThis": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noEmitOnError": true, + "outDir": "dist", + "sourceMap": true, + "strict": true, + "strictNullChecks": true, + "target": "ES2022", + "types": ["node"] + } +} diff --git a/apps/simplex-chat-support-bot/vitest.config.ts b/apps/simplex-chat-support-bot/vitest.config.ts new file mode 100644 index 0000000000..7966066ea7 --- /dev/null +++ b/apps/simplex-chat-support-bot/vitest.config.ts @@ -0,0 +1,10 @@ +import {defineConfig} from "vitest/config" + +export default defineConfig({ + test: { + include: ["bot.test.ts"], + typecheck: { + include: ["bot.test.ts"], + }, + }, +})