diff --git a/apps/simplex-chat-support-bot/support-bot-tests.md b/apps/simplex-chat-support-bot/support-bot-tests.md deleted file mode 100644 index bb8f664703..0000000000 --- a/apps/simplex-chat-support-bot/support-bot-tests.md +++ /dev/null @@ -1,1451 +0,0 @@ -// ═══════════════════════════════════════════════════════════════════ -// 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/bot.test.ts b/apps/simplex-support-bot/bot.test.ts similarity index 100% rename from apps/simplex-chat-support-bot/bot.test.ts rename to apps/simplex-support-bot/bot.test.ts diff --git a/apps/simplex-support-bot/docs/simplex-context.md b/apps/simplex-support-bot/docs/simplex-context.md new file mode 100644 index 0000000000..bc9e2cc7be --- /dev/null +++ b/apps/simplex-support-bot/docs/simplex-context.md @@ -0,0 +1,143 @@ +# SimpleX Chat — Context for AI Assistant + +## What is SimpleX Chat? + +SimpleX Chat is a private and secure messaging platform. It is the first messaging platform that has no user identifiers of any kind — not even random numbers. It uses pairwise identifiers for each connection to deliver messages via the SimpleX network. + +### Core Privacy Guarantees + +- **No user identifiers**: No phone numbers, usernames, or account IDs. Users connect via one-time invitation links or QR codes. +- **End-to-end encryption**: All messages use double ratchet protocol with post-quantum key exchange (ML-KEM). Even if encryption keys are compromised in the future, past messages remain secure. +- **No metadata access**: Relay servers cannot correlate senders and receivers — each conversation uses separate unidirectional messaging queues with different addresses on each side. +- **Decentralized**: No central server. Messages are relayed through SMP (SimpleX Messaging Protocol) servers. Users can choose or self-host their own servers. +- **Open source**: All client and server code is available on GitHub under AGPL-3.0 license. The protocol design is published and peer-reviewed. +- **No global identity**: There is no way to discover users on the platform — you can only connect to someone if they share a link or QR code with you. + +## Available Platforms + +- **Mobile**: iOS (App Store), Android (Google Play, F-Droid, APK) +- **Desktop**: macOS, Windows, Linux (AppImage, deb, Flatpak) +- All platforms support the same features and can be used simultaneously with linked devices + +## Key Features + +### Messaging +- Text messages with markdown formatting +- Voice messages +- Images and videos +- File sharing (any file type, up to 1GB via XFTP) +- Message reactions and replies +- Message editing and deletion +- Disappearing messages (configurable per contact/group) +- Live messages (recipient sees you typing in real-time) +- Message delivery receipts + +### Calls +- End-to-end encrypted audio and video calls +- Calls work peer-to-peer when possible, relayed through TURN servers otherwise +- WebRTC-based + +### Groups +- Group chats with roles: owner, admin, moderator, member, observer +- Groups can have hundreds of members +- Group links for easy joining +- Group moderation tools +- Business chat groups for customer support + +### Privacy Features +- **Incognito mode**: Use a random profile name per contact — your real profile is never shared +- **Multiple chat profiles**: Maintain separate identities +- **Hidden profiles**: Protect profiles with a password +- **Contact verification**: Verify contacts via security code comparison +- **SimpleX Lock**: App lock with passcode or biometric +- **Private routing**: Route messages through multiple servers to hide your IP from destination servers +- **No tracking or analytics**: The app does not collect or send any telemetry + +### Device & Data Management +- **Database export/import**: Migrate to a new device by exporting the database (encrypted or unencrypted) +- **Database passphrase**: Encrypt the local database with a passphrase +- **Linked devices**: Use SimpleX on multiple devices simultaneously (mobile + desktop) +- **Chat archive**: Export and import full chat history + +## SimpleX Network Architecture + +### SMP (SimpleX Messaging Protocol) +- Asynchronous message delivery via relay servers +- Each conversation uses **separate unidirectional messaging queues** +- Queues have different addresses on sender and receiver sides — servers cannot correlate them +- Messages are end-to-end encrypted; servers only see encrypted blobs +- Servers do not store any user profiles or contact lists +- Messages are deleted from servers once delivered + +### XFTP (SimpleX File Transfer Protocol) +- Used for large files (images, videos, documents) +- Files are encrypted, split into chunks, and sent through multiple relay servers +- Temporary file storage — files are deleted after download or expiry + +### Server Architecture +- **Preset servers**: SimpleX Chat Inc. operates preset relay servers, but they can be changed +- **Self-hosting**: Users can run their own SMP and XFTP servers +- **No federation**: Servers don't communicate with each other. Each message queue is independent +- **Tor support**: SimpleX supports connecting through Tor for additional IP privacy + +## Comparison with Other Messengers + +### vs Signal +- SimpleX requires no phone number or any identifier to register +- SimpleX is decentralized — Signal has a central server +- SimpleX relay servers cannot access metadata (who talks to whom) — Signal's server knows your contacts +- Both use strong end-to-end encryption + +### vs Telegram +- SimpleX is fully end-to-end encrypted for all chats — Telegram only encrypts "secret chats" +- SimpleX has no phone number requirement +- SimpleX is fully open source (clients and servers) — Telegram server is closed source +- SimpleX collects no metadata + +### vs Matrix/Element +- SimpleX has better metadata privacy — Matrix servers see who is in which room +- SimpleX is simpler to use — no server selection or account creation +- SimpleX does not use federated identity + +### vs Session +- SimpleX doesn't use a blockchain or cryptocurrency +- SimpleX has better group support and more features +- Both have no phone number requirement + +## Common User Questions & Troubleshooting + +### Getting Started +- **How do I add contacts?** Create a one-time invitation link (or QR code) and share it with your contact. They open it in their SimpleX app to connect. Links are single-use by default for maximum privacy, but you can create reusable address links. +- **Can I use SimpleX without a phone number?** Yes, SimpleX requires no phone number, email, or any identifier. Just install the app and start chatting. +- **How do I join a group?** Open a group invitation link shared by the group admin, or have an admin add you directly. + +### Device Migration +- **How do I move to a new phone?** Go to Settings > Database > Export database. Transfer the file to your new device, install SimpleX, and import the database. Note: you should stop using the old device after export to avoid message duplication. +- **Can I use SimpleX on multiple devices?** Yes, link a desktop app to your mobile app. Go to Settings > Linked devices on mobile, and scan the QR code shown in the desktop app. + +### Privacy & Security +- **Can SimpleX servers read my messages?** No. All messages are end-to-end encrypted. Servers only relay encrypted data and cannot decrypt it. +- **Can SimpleX see who I'm talking to?** No. Each conversation uses separate queues with different addresses. Servers cannot correlate senders and receivers. +- **How do I verify my contact?** Open the contact's profile, tap "Verify security code", and compare the code with your contact (in person or via another channel). +- **What is incognito mode?** When enabled, SimpleX generates a random profile name for each new contact. Your real profile name is never shared. Enable it in Settings > Incognito. + +### Servers +- **How do I self-host a server?** Follow the guide at https://simplex.chat/docs/server.html. You need a Linux server with a public IP. Install the SMP server package and configure it. +- **How do I change relay servers?** Go to Settings > Network & servers. You can add your own server addresses and disable preset servers. +- **Do I need to use SimpleX's servers?** No. You can use any SMP/XFTP servers, including your own. However, you and your contacts need to be able to reach each other's servers. + +### Troubleshooting +- **Messages not delivering?** Check your internet connection. Try switching between WiFi and mobile data. Go to Settings > Network & servers and check server status. You can also try restarting the app. +- **Cannot connect to a contact?** The invitation link may have expired or already been used. Create a new invitation link and share it again. +- **App is slow?** Large databases can slow down the app. Consider archiving old chats or deleting unused contacts/groups. +- **Notifications not working (Android)?** SimpleX needs to run a background service for notifications. Go to Settings > Notifications and enable background service. You may need to disable battery optimization for the app. +- **Notifications not working (iOS)?** Ensure notifications are enabled in iOS Settings > SimpleX Chat. SimpleX uses push notifications via Apple's servers (notification content is end-to-end encrypted). + +## Links + +- Website: https://simplex.chat +- GitHub: https://github.com/simplex-chat +- Documentation: https://simplex.chat/docs +- Server setup: https://simplex.chat/docs/server.html +- Protocol whitepaper: https://github.com/simplex-chat/simplexmq/blob/stable/protocol/overview-tjr.md +- Security audit: https://simplex.chat/blog/20221108-simplex-chat-v4.2-security-audit-new-website.html diff --git a/apps/simplex-chat-support-bot/package-lock.json b/apps/simplex-support-bot/package-lock.json similarity index 100% rename from apps/simplex-chat-support-bot/package-lock.json rename to apps/simplex-support-bot/package-lock.json diff --git a/apps/simplex-chat-support-bot/package.json b/apps/simplex-support-bot/package.json similarity index 100% rename from apps/simplex-chat-support-bot/package.json rename to apps/simplex-support-bot/package.json diff --git a/apps/simplex-support-bot/plans/20260207-support-bot-implementation.md b/apps/simplex-support-bot/plans/20260207-support-bot-implementation.md index 911a148f7c..38f098d980 100644 --- a/apps/simplex-support-bot/plans/20260207-support-bot-implementation.md +++ b/apps/simplex-support-bot/plans/20260207-support-bot-implementation.md @@ -112,7 +112,7 @@ type ConversationState = | {type: "welcome"} | {type: "teamQueue"; userMessages: string[]} | {type: "grokMode"; grokMemberGId: number; history: GrokMessage[]} - | {type: "teamPending"; teamMemberGId: number; grokMemberGId?: number; history?: GrokMessage[]} + | {type: "teamPending"; teamMemberGId: number} | {type: "teamLocked"; teamMemberGId: number} ``` @@ -123,12 +123,11 @@ type ConversationState = welcome ──(1st user msg)──> teamQueue teamQueue ──(user msg)──> teamQueue (append to userMessages) teamQueue ──(/grok)──> grokMode (userMessages → initial Grok history) -teamQueue ──(/team)──> teamPending +teamQueue ──(/team)──> teamPending (add team member) grokMode ──(user msg)──> grokMode (forward to Grok API, append to history) -grokMode ──(/team)──> teamPending (carry grokMemberGId + history) -teamPending ──(team member msg)──> teamLocked (remove Grok if present) -teamPending ──(/grok, grok present)──> teamPending (forward to Grok, still usable) -teamPending ──(/grok, no grok)──> reply "team mode" +grokMode ──(/team)──> teamPending (remove Grok immediately, add team member) +teamPending ──(team member msg)──> teamLocked +teamPending ──(/grok)──> reply "team mode" teamLocked ──(/grok)──> reply "team mode", stay locked teamLocked ──(any)──> no action (team sees directly) ``` @@ -227,11 +226,13 @@ await grokChat.startChat() |-------|---------|--------| | `acceptingBusinessRequest` | `onBusinessRequest` | `conversations.set(groupInfo.groupId, {type: "welcome"})` | | `newChatItems` | `onNewChatItems` | For each chatItem: identify sender, extract text, dispatch to routing | -| `leftMember` | `onLeftMember` | If customer left → delete state. If team member left → revert to teamQueue. If Grok left → clear grokMemberGId. | +| `leftMember` | `onLeftMember` | If customer left → delete state. If team member left → revert to teamQueue. If Grok left during grokMode → revert to teamQueue. | | `deletedMemberUser` | `onDeletedMemberUser` | Bot removed from group → delete state | | `groupDeleted` | `onGroupDeleted` | Delete state, delete grokGroupMap entry | | `connectedToGroupMember` | `onMemberConnected` | Log for debugging | +We do NOT use `onMessage`/`onCommands` from `bot.run()` — all routing is done in the `newChatItems` event handler for full control over state-dependent command handling. + **Sender identification in `newChatItems`:** ```typescript for (const ci of evt.chatItems) { @@ -248,10 +249,9 @@ for (const ci of evt.chatItems) { const sender = chatItem.chatDir.groupMember const isCustomer = sender.memberId === groupInfo.businessChat.customerId - const isTeamMember = state.type === "teamPending" || state.type === "teamLocked" - ? sender.groupMemberId === state.teamMemberGId - : false - const isGrok = (state.type === "grokMode" || state.type === "teamPending") + const isTeamMember = (state.type === "teamPending" || state.type === "teamLocked") + && sender.groupMemberId === state.teamMemberGId + const isGrok = state.type === "grokMode" && state.grokMemberGId === sender.groupMemberId if (isGrok) continue // skip Grok messages (we sent them via grokChat) @@ -260,12 +260,18 @@ for (const ci of evt.chatItems) { } ``` -**Text extraction:** +**Command detection** — use `util.ciBotCommand()` for `/grok` and `/team`; all other text (including unrecognized `/commands`) is routed as "other text" per spec ("Unrecognized commands: treated as normal messages in the current mode"): ```typescript function extractText(chatItem: T.ChatItem): string | null { const text = util.ciContentText(chatItem) return text?.trim() || null } + +// In onCustomerMessage: +const cmd = util.ciBotCommand(chatItem) +if (cmd?.keyword === "grok") { /* handle /grok */ } +else if (cmd?.keyword === "team") { /* handle /team */ } +else { /* handle as normal text message, including unrecognized /commands */ } ``` ## 9. Message Routing Table @@ -279,10 +285,9 @@ function extractText(chatItem: T.ChatItem): string | null { | `teamQueue` | `/team` | Add team member | `mainChat.apiAddMember(groupId, teamContactId, "member")` + `mainChat.apiSendTextMessage([Group, groupId], teamAddedMsg)` | `teamPending` | | `teamQueue` | other text | Forward to team, append to userMessages | `mainChat.apiSendTextMessage([Group, teamGroupId], fwd)` | `teamQueue` | | `grokMode` | `/grok` | Ignore (already in grok mode) | — | `grokMode` | -| `grokMode` | `/team` | Add team member (keep Grok for now) | `mainChat.apiAddMember(groupId, teamContactId, "member")` + `mainChat.apiSendTextMessage([Group, groupId], teamAddedMsg)` | `teamPending` (carry grokMemberGId + history) | -| `grokMode` | other text | Forward to Grok API + team | Grok API call + `grokChat.apiSendTextMessage([Group, grokLocalGId], response)` + `mainChat.apiSendTextMessage([Group, teamGroupId], fwd)` | `grokMode` (append history) | -| `teamPending` (grok present) | `/grok` | Forward to Grok (still usable) | Grok API call + `grokChat.apiSendTextMessage(...)` | `teamPending` | -| `teamPending` (no grok) | `/grok` | Reply "team mode" | `mainChat.apiSendTextMessage([Group, groupId], teamLockedMsg)` | `teamPending` | +| `grokMode` | `/team` | Remove Grok, add team member | `mainChat.apiRemoveMembers(groupId, [grokMemberGId])` + `mainChat.apiAddMember(groupId, teamContactId, "member")` + `mainChat.apiSendTextMessage([Group, groupId], teamAddedMsg)` | `teamPending` | +| `grokMode` | other text | Forward to Grok API + forward to team | Grok API call + `grokChat.apiSendTextMessage([Group, grokLocalGId], response)` + `mainChat.apiSendTextMessage([Group, teamGroupId], fwd)` | `grokMode` (append history) | +| `teamPending` | `/grok` | Reply "team mode" | `mainChat.apiSendTextMessage([Group, groupId], teamLockedMsg)` | `teamPending` | | `teamPending` | `/team` | Ignore (already team) | — | `teamPending` | | `teamPending` | other text | No forwarding (team sees directly in group) | — | `teamPending` | | `teamLocked` | `/grok` | Reply "team mode" | `mainChat.apiSendTextMessage([Group, groupId], teamLockedMsg)` | `teamLocked` | @@ -302,13 +307,18 @@ async forwardToTeam(groupId: number, groupInfo: T.GroupInfo, text: string): Prom } async activateTeam(groupId: number, state: ConversationState): Promise { + // Remove Grok immediately if present (per spec: "When switching to team mode, Grok is removed") + if (state.type === "grokMode") { + try { await this.mainChat.apiRemoveMembers(groupId, [state.grokMemberGId]) } catch {} + const grokLocalGId = grokGroupMap.get(groupId) + grokGroupMap.delete(groupId) + if (grokLocalGId) reverseGrokMap.delete(grokLocalGId) + } const teamContactId = this.config.teamMembers[0].id // round-robin or first available const member = await this.mainChat.apiAddMember(groupId, teamContactId, "member") this.conversations.set(groupId, { type: "teamPending", teamMemberGId: member.groupMemberId, - grokMemberGId: state.type === "grokMode" ? state.grokMemberGId : undefined, - history: state.type === "grokMode" ? state.history : undefined, }) await this.mainChat.apiSendTextMessage( [T.ChatType.Group, groupId], @@ -358,25 +368,20 @@ class GrokApiClient { ## 12. One-Way Gate Logic +Per spec: "When switching to team mode, Grok is removed" and "once the user switches to team mode, /grok command is permanently disabled." Grok removal happens immediately in `activateTeam` (section 10). The one-way gate locks the state after team member engages: + ```typescript async onTeamMemberMessage(groupId: number, state: ConversationState): Promise { if (state.type !== "teamPending") return - - // Remove Grok if present - if (state.grokMemberGId) { - try { await this.mainChat.apiRemoveMembers(groupId, [state.grokMemberGId]) } catch {} - grokGroupMap.delete(groupId) - reverseGrokMap.delete(/* grokLocalGroupId */) - } - this.conversations.set(groupId, {type: "teamLocked", teamMemberGId: state.teamMemberGId}) } ``` Timeline per spec: -1. User sends `/team` → `apiAddMember` → state = `teamPending` (Grok still usable if present) -2. Team member sends message → `onTeamMemberMessage` → state = `teamLocked`, Grok removed via `apiRemoveMembers` -3. Any `/grok` → reply "You are now in team mode. A team member will reply to your message." +1. User sends `/team` → Grok removed immediately (if present) → team member added → state = `teamPending` +2. `/grok` in `teamPending` → reply "team mode" (Grok already gone, command disabled) +3. Team member sends message → `onTeamMemberMessage` → state = `teamLocked` +4. Any subsequent `/grok` → reply "You are now in team mode. A team member will reply to your message." ## 13. Message Templates (verbatim from spec) @@ -417,7 +422,7 @@ function isWeekend(timezone: string): boolean { | # | Operation | When | ChatApi Instance | Method | Parameters | Response Type | Error Handling | |---|-----------|------|-----------------|--------|------------|---------------|----------------| -| 1 | Init main bot | Startup | mainChat | `bot.run()` (wraps `ChatApi.init`) | dbFilePrefix, profile, addressSettings | `[ChatApi, User, UserContactLink]` | Exit on failure | +| 1 | Init main bot | Startup | mainChat | `bot.run()` (wraps `ChatApi.init`) | dbFilePrefix, profile, addressSettings | `[ChatApi, User, UserContactLink \| undefined]` | Exit on failure | | 2 | Init Grok agent | Startup | grokChat | `ChatApi.init(grokDbPrefix)` | dbFilePrefix | `ChatApi` | Exit on failure | | 3 | Get/create Grok user | Startup | grokChat | `apiGetActiveUser()` / `apiCreateActiveUser(profile)` | profile: {displayName: "Grok AI"} | `User` | Exit on failure | | 4 | Start Grok chat | Startup | grokChat | `startChat()` | — | void | Exit on failure | @@ -427,12 +432,12 @@ function isWeekend(timezone: string): boolean { | 8 | First-run: connect | First-run | grokChat | `apiConnectActiveUser(invLink)` | connLink | `ConnReqType` | Exit on failure | | 9 | First-run: wait | First-run | mainChat | `wait("contactConnected", 60000)` | event, timeout | `ChatEvent \| undefined` | Exit on timeout | | 10 | Send msg to customer | Various | mainChat | `apiSendTextMessage([Group, groupId], text)` | chat, text | `AChatItem[]` | Log error | -| 11 | Forward to team | welcome→teamQueue, teamQueue msg | mainChat | `apiSendTextMessage([Group, teamGroupId], fwd)` | chat, formatted text | `AChatItem[]` | Log error | +| 11 | Forward to team | welcome→teamQueue, teamQueue msg, grokMode msg | mainChat | `apiSendTextMessage([Group, teamGroupId], fwd)` | chat, formatted text | `AChatItem[]` | Log error | | 12 | Invite Grok to group | /grok in teamQueue | mainChat | `apiAddMember(groupId, grokContactId, "member")` | groupId, contactId, role | `GroupMember` | Send error msg, stay in teamQueue | | 13 | Grok joins group | receivedGroupInvitation | grokChat | `apiJoinGroup(groupId)` | groupId | `GroupInfo` | Log error | | 14 | Grok sends response | After Grok API reply | grokChat | `apiSendTextMessage([Group, grokLocalGId], text)` | chat, text | `AChatItem[]` | Send error msg via mainChat | | 15 | Invite team member | /team | mainChat | `apiAddMember(groupId, teamContactId, "member")` | groupId, contactId, role | `GroupMember` | Send error msg to customer | -| 16 | Remove Grok | Team member msg in teamPending | mainChat | `apiRemoveMembers(groupId, [grokMemberGId])` | groupId, memberIds | `GroupMember[]` | Ignore (may have left) | +| 16 | Remove Grok | /team from grokMode | mainChat | `apiRemoveMembers(groupId, [grokMemberGId])` | groupId, memberIds | `GroupMember[]` | Ignore (may have left) | | 17 | Update bot profile | Startup (via bot.run) | mainChat | `apiUpdateProfile(userId, profile)` | userId, profile with peerType+commands | `UserProfileUpdateSummary` | Log warning | | 18 | Set address settings | Startup (via bot.run) | mainChat | `apiSetAddressSettings(userId, settings)` | userId, {businessAddress, autoAccept, welcomeMessage} | void | Exit on failure | @@ -448,7 +453,7 @@ function isWeekend(timezone: string): boolean { | Grok join timeout (30s) | `mainChat.apiSendTextMessage` "Grok unavailable", stay in `teamQueue` | | Customer leaves (`leftMember` where member is customer) | Delete conversation state, delete grokGroupMap entry | | Group deleted | Delete conversation state, delete grokGroupMap entry | -| Grok leaves during `teamPending` | Clear `grokMemberGId` from state, keep `teamPending` | +| Grok leaves during `grokMode` | Revert to `teamQueue`, delete grokGroupMap entry | | Team member leaves | Revert to `teamQueue` (accumulate messages again) | | Bot removed from group (`deletedMemberUser`) | Delete conversation state | | Grok agent connection lost | Log error; Grok features unavailable until restart | @@ -481,10 +486,10 @@ function isWeekend(timezone: string): boolean { - **Verify:** `/grok` → Grok joins as separate participant → Grok responses appear from Grok profile **Phase 4: Team mode + one-way gate** -- Implement `activateTeam`: add team member -- Implement `onTeamMemberMessage`: detect team msg → lock → remove Grok -- Implement `/grok` rejection in `teamLocked` -- **Verify:** Full flow including: teamQueue → /grok → grokMode → /team → teamPending → team msg → teamLocked → /grok rejected +- Implement `activateTeam`: remove Grok if present, add team member +- Implement `onTeamMemberMessage`: detect team msg → lock state +- Implement `/grok` rejection in `teamPending` and `teamLocked` +- **Verify:** Full flow: teamQueue → /grok → grokMode → /team → Grok removed + teamPending → /grok rejected → team msg → teamLocked **Phase 5: Polish + first-run** - Implement `--first-run` auto-contact establishment @@ -533,9 +538,9 @@ npx ts-node src/index.ts \ 2. Send question → verify forwarded to team group with `[CustomerName #groupId]` prefix, queue reply received 3. Send `/grok` → verify Grok joins as separate participant, responses appear from "Grok AI" profile 4. Send text in grokMode → verify Grok response + forwarded to team -5. Send `/team` → verify team member added, team added message -6. Send team member message → verify Grok removed, state locked -7. Send `/grok` after lock → verify "team mode" reply +5. Send `/team` → verify Grok removed, team member added, team added message +6. Send `/grok` after `/team` (before team member message) → verify "team mode" reply +7. Send team member message → verify state locked, `/grok` still rejected 8. Test weekend: set timezone to weekend timezone → verify "48 hours" in messages 9. Customer disconnects → verify state cleanup 10. Grok API failure → verify error message, graceful fallback to teamQueue diff --git a/apps/simplex-support-bot/plans/20260209-moderation-bot.md b/apps/simplex-support-bot/plans/20260209-moderation-bot.md new file mode 100644 index 0000000000..3e55a8900d --- /dev/null +++ b/apps/simplex-support-bot/plans/20260209-moderation-bot.md @@ -0,0 +1,34 @@ +A SimpleX Chat bot that monitors public groups, summarizes conversations using + Grok LLM, moderates content, and forwards important messages to a private + staff group. + + Core Features + + 1. Message Summarization + - Periodically summarizes public group messages using Grok API + - Posts summaries to the group on a configurable schedule (e.g. daily/hourly) + - Summaries capture key topics, decisions, and action items + + 2. Moderation + - Detects spam, abuse, and policy violations using Grok + - Configurable actions per severity: flag-only, auto-delete, or remove member + - All moderation events are forwarded to the staff group for visibility + + 3. Important Message Forwarding + - Grok classifies messages by importance (urgency, issues, support requests) + - Forwards important messages to a designated private staff group + - Includes context: sender, group, timestamp, and reason for flagging + + Configuration + + - GROK_API_KEY — Grok API credentials + - PUBLIC_GROUPS — list of monitored public groups + - STAFF_GROUP — private group for forwarded alerts + - SUMMARY_INTERVAL — how often summaries are generated + - MODERATION_RULES — content policy and action thresholds + + Non-Goals + + - No interactive Q&A or general chatbot behavior in groups + - No direct user communication from the bot (all escalation goes to staff + group) diff --git a/apps/simplex-chat-support-bot/src/bot.ts b/apps/simplex-support-bot/src/bot.ts similarity index 100% rename from apps/simplex-chat-support-bot/src/bot.ts rename to apps/simplex-support-bot/src/bot.ts diff --git a/apps/simplex-chat-support-bot/src/config.ts b/apps/simplex-support-bot/src/config.ts similarity index 100% rename from apps/simplex-chat-support-bot/src/config.ts rename to apps/simplex-support-bot/src/config.ts diff --git a/apps/simplex-chat-support-bot/src/grok.ts b/apps/simplex-support-bot/src/grok.ts similarity index 100% rename from apps/simplex-chat-support-bot/src/grok.ts rename to apps/simplex-support-bot/src/grok.ts diff --git a/apps/simplex-chat-support-bot/src/index.ts b/apps/simplex-support-bot/src/index.ts similarity index 100% rename from apps/simplex-chat-support-bot/src/index.ts rename to apps/simplex-support-bot/src/index.ts diff --git a/apps/simplex-chat-support-bot/src/messages.ts b/apps/simplex-support-bot/src/messages.ts similarity index 100% rename from apps/simplex-chat-support-bot/src/messages.ts rename to apps/simplex-support-bot/src/messages.ts diff --git a/apps/simplex-chat-support-bot/src/state.ts b/apps/simplex-support-bot/src/state.ts similarity index 100% rename from apps/simplex-chat-support-bot/src/state.ts rename to apps/simplex-support-bot/src/state.ts diff --git a/apps/simplex-chat-support-bot/src/util.ts b/apps/simplex-support-bot/src/util.ts similarity index 100% rename from apps/simplex-chat-support-bot/src/util.ts rename to apps/simplex-support-bot/src/util.ts diff --git a/apps/simplex-chat-support-bot/tsconfig.json b/apps/simplex-support-bot/tsconfig.json similarity index 100% rename from apps/simplex-chat-support-bot/tsconfig.json rename to apps/simplex-support-bot/tsconfig.json diff --git a/apps/simplex-chat-support-bot/vitest.config.ts b/apps/simplex-support-bot/vitest.config.ts similarity index 100% rename from apps/simplex-chat-support-bot/vitest.config.ts rename to apps/simplex-support-bot/vitest.config.ts