From 5711cb5a830bec357fffeb57e748c0221d0465ea Mon Sep 17 00:00:00 2001 From: Narasimha-sc <166327228+Narasimha-sc@users.noreply.github.com> Date: Tue, 17 Mar 2026 12:00:41 +0200 Subject: [PATCH] Further usability improvements --- apps/simplex-support-bot/bot.test.ts | 2365 ++++++++++++++++- .../docs/simplex-context.md | 34 +- apps/simplex-support-bot/src/bot.ts | 896 ++++++- apps/simplex-support-bot/src/config.ts | 15 +- apps/simplex-support-bot/src/index.ts | 105 +- apps/simplex-support-bot/src/messages.ts | 2 + 6 files changed, 3171 insertions(+), 246 deletions(-) diff --git a/apps/simplex-support-bot/bot.test.ts b/apps/simplex-support-bot/bot.test.ts index eb7afac64d..31df67085f 100644 --- a/apps/simplex-support-bot/bot.test.ts +++ b/apps/simplex-support-bot/bot.test.ts @@ -23,7 +23,7 @@ vi.mock("simplex-chat", () => ({ vi.mock("@simplex-chat/types", () => ({ T: { - ChatType: {Group: "group"}, + ChatType: {Group: "group", Direct: "direct"}, GroupMemberRole: {Member: "member"}, GroupMemberStatus: { Connected: "connected", @@ -56,6 +56,7 @@ vi.mock("child_process", () => ({ import {SupportBot} from "./src/bot" import {GrokApiClient} from "./src/grok" +import {parseConfig, parseIdName} from "./src/config" import {resolveDisplayNameConflict} from "./src/startup" import type {GrokMessage} from "./src/state" import {isWeekend} from "./src/util" @@ -88,7 +89,7 @@ class MockGrokApi { // ─── Mock Chat API ────────────────────────────────────────────── -interface SentMessage { chat: [string, number]; text: string } +interface SentMessage { chat: [string, number]; text: string; inReplyTo?: number } interface AddedMember { groupId: number; contactId: number; role: string } interface RemovedMembers { groupId: number; memberIds: number[] } @@ -101,6 +102,7 @@ class MockChatApi { chatItems: Map = new Map() // groupId → chat items (simulates DB) updatedProfiles: {groupId: number; profile: any}[] = [] updatedChatItems: {chatType: string; chatId: number; chatItemId: number; msgContent: any}[] = [] + roleChanges: {groupId: number; memberIds: number[]; role: string}[] = [] private addMemberFail = false private addMemberDuplicate = false @@ -113,8 +115,8 @@ class MockChatApi { setGroupMembers(groupId: number, members: any[]) { this.members.set(groupId, members) } setChatItems(groupId: number, items: any[]) { this.chatItems.set(groupId, items) } - async apiSendTextMessage(chat: [string, number], text: string) { - this.sent.push({chat, text}) + async apiSendTextMessage(chat: [string, number], text: string, inReplyTo?: number) { + this.sent.push({chat, text, inReplyTo}) // Track bot-sent messages as groupSnd chat items (for isFirstCustomerMessage detection) const groupId = chat[1] if (!this.chatItems.has(groupId)) this.chatItems.set(groupId, []) @@ -158,6 +160,10 @@ class MockChatApi { } } + async apiSetMembersRole(groupId: number, memberIds: number[], role: string) { + this.roleChanges.push({groupId, memberIds, role}) + } + async apiJoinGroup(groupId: number) { this.joined.push(groupId) } @@ -166,8 +172,12 @@ class MockChatApi { return this.members.get(groupId) || [] } - // sendChatCmd is used by apiGetChat (interim approach) + sentCmds: string[] = [] + private nextContactId = 100 + + // sendChatCmd is used by apiGetChat, /_create member contact, /_invite member contact async sendChatCmd(cmd: string) { + this.sentCmds.push(cmd) // Parse "/_get chat # count=" const match = cmd.match(/\/_get chat #(\d+) count=(\d+)/) if (match) { @@ -181,6 +191,16 @@ class MockChatApi { }, } } + // Parse "/_create member contact # " + const createMatch = cmd.match(/\/_create member contact #(\d+) (\d+)/) + if (createMatch) { + const contactId = this.nextContactId++ + return {type: "newMemberContact", contact: {contactId}} + } + // Parse "/_invite member contact @" + if (cmd.startsWith("/_invite member contact @")) { + return {type: "newMemberContactSentInv"} + } return {type: "cmdOk"} } @@ -194,10 +214,10 @@ class MockChatApi { } reset() { - this.sent = []; this.added = []; this.removed = []; this.joined = [] + this.sent = []; this.added = []; this.removed = []; this.joined = []; this.sentCmds = [] this.members.clear(); this.chatItems.clear() this.updatedProfiles = []; this.updatedChatItems = [] - this.addMemberFail = false; this.addMemberDuplicate = false; this.nextMemberGId = 50; this.nextItemId = 1000 + this.addMemberFail = false; this.addMemberDuplicate = false; this.nextMemberGId = 50; this.nextItemId = 1000; this.nextContactId = 100 } } @@ -305,6 +325,21 @@ const customer = { await bot.onNewChatItems({chatItems: [ci]} as any) }, + async sendsReplyTo(text: string, quotedItemId: number, groupId = GROUP_ID) { + const ci = customerChatItem(text, null) + ci.chatInfo.groupInfo = businessGroupInfo(groupId) + ci.chatItem.quotedItem = {itemId: quotedItemId} + if (!mainChat.chatItems.has(groupId)) mainChat.chatItems.set(groupId, []) + mainChat.chatItems.get(groupId)!.push({ + chatDir: { + type: "groupRcv", + groupMember: {memberId: CUSTOMER_ID, groupMemberId: 10}, + }, + _text: text, + }) + await bot.onNewChatItems({chatItems: [ci]} as any) + }, + async sendsNonText(groupId = GROUP_ID) { const ci = customerChatItem(null, null) ci.chatInfo.groupInfo = businessGroupInfo(groupId) @@ -333,12 +368,19 @@ const customer = { }, } -// Format helpers for expected forwarded messages -function fmtCustomer(text: string, name = "Alice", groupId = GROUP_ID) { - return `${name}:${groupId}: ${text}` +// Format helpers for expected forwarded messages (new A1-A6 format) +// Note: in tests, duration is always <60s so it's omitted from the header +function fmtCustomer(text: string, state = "QUEUE", msgNum = 2, name = "Alice", groupId = GROUP_ID) { + return `*${groupId}:${name} · ${state} · #${msgNum}*\n${text}` } -function fmtTeamMember(tmContactId: number, text: string, tmName = "Bob", customerName = "Alice", groupId = GROUP_ID) { - return `${tmName}:${tmContactId} > ${customerName}:${groupId}: ${text}` +function fmtTeamMember(tmContactId: number, text: string, state = "TEAM", msgNum: number, tmName = "Bob", customerName = "Alice", groupId = GROUP_ID) { + return `!2 >>! *${tmContactId}:${tmName} > ${groupId}:${customerName} · ${state} · #${msgNum}*\n${text}` +} +function fmtGrok(text: string, state = "GROK", msgNum: number, name = "Alice", groupId = GROUP_ID) { + return `!5 AI! *Grok > ${groupId}:${name} · ${state} · #${msgNum}*\n_${text}_` +} +function fmtNewCustomer(text: string, state = "QUEUE", msgNum = 1, name = "Alice", groupId = GROUP_ID) { + return `!1 NEW! *${groupId}:${name} · ${state} · #${msgNum}*\n${text}` } const teamGroup = { @@ -373,6 +415,21 @@ const teamMember = { await bot.onNewChatItems({chatItems: [ci]} as any) }, + async sendsReplyTo(text: string, quotedItemId: number, groupId = GROUP_ID) { + const ci = teamMemberChatItem(lastTeamMemberGId, text) + ci.chatInfo.groupInfo = businessGroupInfo(groupId) + ci.chatItem.quotedItem = {itemId: quotedItemId} + if (!mainChat.chatItems.has(groupId)) mainChat.chatItems.set(groupId, []) + mainChat.chatItems.get(groupId)!.push({ + chatDir: { + type: "groupRcv", + groupMember: {memberId: "team-member-1", groupMemberId: lastTeamMemberGId, memberContactId: 2}, + }, + _text: text, + }) + await bot.onNewChatItems({chatItems: [ci]} as any) + }, + async leaves(groupId = GROUP_ID) { await bot.onLeftMember({ groupInfo: businessGroupInfo(groupId), @@ -413,6 +470,13 @@ const grokAgent = { expect(found).toBe(true) }, + wasNotRemoved(groupId = GROUP_ID) { + const found = mainChat.removed.some( + r => r.groupId === groupId && r.memberIds.includes(lastGrokMemberGId) + ) + expect(found).toBe(false) + }, + async leaves(groupId = GROUP_ID) { // Remove Grok from members list (simulates DB state after leave) const currentMembers = mainChat.members.get(groupId) || [] @@ -454,6 +518,9 @@ const GROK_UNAVAILABLE = const TEAM_ADD_ERROR = `Sorry, there was an error adding a team member. Please try again.` +const TEAM_ALREADY_ADDED = + `A team member has already been invited to this conversation and will reply when available.` + // ─── Setup ────────────────────────────────────────────────────── @@ -538,11 +605,11 @@ async function reachTeamLocked() { describe("Connection & Welcome", () => { - test("first message → forwarded to team, queue reply sent", async () => { + test("first message → forwarded to team with NEW, queue reply sent", async () => { // No prior bot messages → isFirstCustomerMessage returns true → welcome flow await customer.sends("How do I create a group?") - teamGroup.received(fmtCustomer("How do I create a group?")) + teamGroup.received(fmtNewCustomer("How do I create a group?", "QUEUE", 1)) customer.received(TEAM_QUEUE_24H) }) @@ -564,7 +631,7 @@ describe("Team Queue", () => { await customer.sends("More details about my issue") - teamGroup.received(fmtCustomer("More details about my issue")) + teamGroup.received(fmtCustomer("More details about my issue", "QUEUE", 2)) // No queue message sent again — bot already sent a message (groupSnd in DB) expect(mainChat.sentTo(GROUP_ID).length).toBe(0) }) @@ -584,7 +651,7 @@ describe("Team Queue", () => { await customer.sends("/unknown") - teamGroup.received(fmtCustomer("/unknown")) + teamGroup.received(fmtCustomer("/unknown", "QUEUE", 2)) }) }) @@ -658,7 +725,8 @@ describe("Grok Mode Conversation", () => { grokApi.willRespond("Follow-up answer from Grok") await customer.sends("What about encryption?") - teamGroup.received(fmtCustomer("What about encryption?")) + // msgNum=3: #1=Hello, #2=Grok initial answer, #3=customer follow-up + teamGroup.received(fmtCustomer("What about encryption?", "GROK", 3)) // History should include the initial exchange (from chat items in DB) const lastCall = grokApi.lastCall() @@ -708,7 +776,7 @@ describe("Team Activation", () => { customer.received(TEAM_ADDED_24H) }) - test("/team from grokMode → Grok removed, team member added", async () => { + test("/team from grokMode → team member added, Grok stays until team member connects", async () => { await reachGrokMode() mainChat.setNextGroupMemberId(70) lastTeamMemberGId = 70 @@ -716,9 +784,18 @@ describe("Team Activation", () => { await customer.sends("/team") - grokAgent.wasRemoved() + // Grok NOT removed yet — stays functional during transition + grokAgent.wasNotRemoved() teamMember.wasInvited() customer.received(TEAM_ADDED_24H) + + // Team member sends first message → Grok removed + mainChat.setGroupMembers(GROUP_ID, [ + {groupMemberId: lastGrokMemberGId, memberContactId: 4, memberStatus: "connected"}, + {groupMemberId: 70, memberContactId: 2, memberStatus: "connected"}, + ]) + await teamMember.sends("Hi, I'll help you") + grokAgent.wasRemoved() }) }) @@ -763,21 +840,27 @@ describe("One-Way Gate", () => { expect(mainChat.sentTo(GROUP_ID).length).toBe(0) }) - test("customer text in teamPending → no forwarding, no reply", async () => { + test("customer text in teamPending → forwarded to team group", async () => { await reachTeamPending() mainChat.sent = [] await customer.sends("Here's more info about my issue") + // msgNum=2: #1=Hello, #2=this message; TEAM state (team member present) + teamGroup.received(fmtCustomer("Here's more info about my issue", "TEAM", 2)) + // No reply sent to customer group expect(mainChat.sentTo(GROUP_ID).length).toBe(0) }) - test("customer text in teamLocked → no forwarding, no reply", async () => { + test("customer text in teamLocked → forwarded to team group", async () => { await reachTeamLocked() mainChat.sent = [] await customer.sends("Thank you!") + // msgNum=3: #1=Hello, #2=team "I'll help you", #3=customer "Thank you!" + teamGroup.received(fmtCustomer("Thank you!", "TEAM", 3)) + // No reply sent to customer group expect(mainChat.sentTo(GROUP_ID).length).toBe(0) }) }) @@ -820,29 +903,148 @@ describe("Gate Reversal vs Irreversibility", () => { customer.receivedFromGrok("Grok is back") }) - test("team member leaves in teamLocked → replacement added", async () => { + test("team member leaves in teamLocked → no replacement added", async () => { await reachTeamLocked() mainChat.added = [] await teamMember.leaves() - // Replacement team member invited - expect(mainChat.added.length).toBe(1) - expect(mainChat.added[0].contactId).toBe(2) + // No replacement — team member is not auto-invited back + expect(mainChat.added.length).toBe(0) }) +}) - test("/grok still rejected after replacement in teamLocked", async () => { - await reachTeamLocked() + +// ─── 7b. Team Re-addition Prevention ───────────────────────────── + +describe("Team Re-addition Prevention", () => { + + test("/team after team member left teamPending → not re-added, already-added message", async () => { + await reachTeamPending() + // Team member leaves (teamPending revert) + mainChat.setGroupMembers(GROUP_ID, []) await teamMember.leaves() - // Replacement added, set in members - mainChat.setGroupMembers(GROUP_ID, [ - {groupMemberId: 51, memberContactId: 2, memberStatus: "connected"}, - ]) + mainChat.added = [] mainChat.sent = [] - await customer.sends("/grok") + // Customer sends /team again + await customer.sends("/team") - customer.received(TEAM_LOCKED_MSG) + // Team member NOT re-added + expect(mainChat.added.length).toBe(0) + // Customer gets the already-added message + customer.received(TEAM_ALREADY_ADDED) + }) + + test("/team after team member left teamLocked → not re-added", async () => { + await reachTeamLocked() + // Team member leaves + mainChat.setGroupMembers(GROUP_ID, []) + await teamMember.leaves() + mainChat.added = [] + mainChat.sent = [] + + // Customer sends /team again + await customer.sends("/team") + + // Team member NOT re-added — hasTeamBeenActivatedBefore returns true + expect(mainChat.added.length).toBe(0) + customer.received(TEAM_ALREADY_ADDED) + }) + + test("/team from grokMode after prior team activation → Grok NOT removed, not re-added", async () => { + // First: activate team, then team member leaves, then customer activates Grok + await reachTeamPending() + mainChat.setGroupMembers(GROUP_ID, []) + await teamMember.leaves() + + // Now in teamQueue equivalent — activate Grok + mainChat.setNextGroupMemberId(61) + lastGrokMemberGId = 61 + grokApi.willRespond("Grok answer") + const p = customer.sends("/grok") + mainChat.setGroupMembers(GROUP_ID, [ + {groupMemberId: 61, memberContactId: 4, memberStatus: "connected"}, + ]) + await grokAgent.joins() + await p + + mainChat.added = [] + mainChat.removed = [] + mainChat.sent = [] + + // Customer sends /team while in grokMode — but team was already activated before + await customer.sends("/team") + + // Grok NOT removed (activateTeam returned early) + expect(mainChat.removed.length).toBe(0) + // Team member NOT re-added + expect(mainChat.added.length).toBe(0) + customer.received(TEAM_ALREADY_ADDED) + }) + + test("first /team still works normally", async () => { + await reachTeamQueue("Hello") + mainChat.setGroupMembers(GROUP_ID, []) + mainChat.setNextGroupMemberId(50) + lastTeamMemberGId = 50 + mainChat.added = [] + mainChat.sent = [] + + await customer.sends("/team") + + teamMember.wasInvited() + customer.received(TEAM_ADDED_24H) + }) + + test("restart after team activation → /team still blocked", async () => { + await reachTeamPending() + mainChat.setGroupMembers(GROUP_ID, []) + await teamMember.leaves() + + // Simulate restart: create new bot instance, but chat history persists + const freshBot = new SupportBot(mainChat as any, grokChat as any, grokApi as any, config as any) + mainChat.added = [] + mainChat.sent = [] + + // Customer sends /team via the restarted bot + const ci = customerChatItem("/team", "team") + mainChat.chatItems.get(GROUP_ID)!.push({ + chatDir: {type: "groupRcv", groupMember: {memberId: CUSTOMER_ID, groupMemberId: 10}}, + _text: "/team", + _botCommand: "team", + }) + await freshBot.onNewChatItems({chatItems: [ci]} as any) + + // Team member NOT re-added + expect(mainChat.added.length).toBe(0) + customer.received(TEAM_ALREADY_ADDED) + }) + + test("/add command still works after team activation (team-initiated)", async () => { + await reachTeamPending() + mainChat.setGroupMembers(GROUP_ID, []) + await teamMember.leaves() + mainChat.added = [] + + // Team member uses /add in team group — should bypass the check + const addCi = { + chatInfo: {type: "group", groupInfo: {groupId: TEAM_GRP_ID, businessChat: null}}, + chatItem: { + chatDir: { + type: "groupRcv", + groupMember: {memberId: "tm-1", groupMemberId: 30, memberContactId: 2, memberProfile: {displayName: "Bob"}}, + }, + meta: {itemId: 900}, + _text: `/add ${GROUP_ID}:Alice`, + }, + } as any + await bot.onNewChatItems({chatItems: [addCi]} as any) + + // /add bypasses activateTeam — team member added directly + expect(mainChat.added.length).toBe(1) + expect(mainChat.added[0].groupId).toBe(GROUP_ID) + expect(mainChat.added[0].contactId).toBe(2) }) }) @@ -879,7 +1081,8 @@ describe("Member Leave & Cleanup", () => { // Bot has already sent messages (groupSnd), so not welcome → forward to team await customer.sends("Another question") - teamGroup.received(fmtCustomer("Another question")) + // msgNum=3: #1=Hello, #2=Grok answer in reachGrokMode, #3=this + teamGroup.received(fmtCustomer("Another question", "QUEUE", 3)) expect((bot as any).grokGroupMap.has(GROUP_ID)).toBe(false) }) @@ -911,7 +1114,7 @@ describe("Error Handling", () => { expect(grokApi.callCount()).toBe(0) }) - test("Grok join timeout → error msg", async () => { + test("Grok join timeout → error msg, Grok member removed", async () => { vi.useFakeTimers() mainChat.setNextGroupMemberId(60) lastGrokMemberGId = 60 @@ -924,6 +1127,8 @@ describe("Error Handling", () => { customer.received(GROK_UNAVAILABLE) expect(grokApi.callCount()).toBe(0) + // Grok member should be removed on timeout to prevent ghost grokMode + grokAgent.wasRemoved() vi.useRealTimers() }) @@ -982,14 +1187,14 @@ describe("Error Handling", () => { customer.received(TEAM_ADD_ERROR) }) - test("team member add fails after Grok removal → error msg", async () => { + test("team member add fails in grokMode → error msg, Grok stays", async () => { await reachGrokMode() mainChat.apiAddMemberWillFail() mainChat.sent = [] await customer.sends("/team") - grokAgent.wasRemoved() + grokAgent.wasNotRemoved() customer.received(TEAM_ADD_ERROR) }) @@ -1029,7 +1234,7 @@ describe("Error Handling", () => { describe("Race Conditions", () => { - test("/team sent while waiting for Grok to join → abort Grok", async () => { + test("/team sent while waiting for Grok to join → Grok continues, team member added", async () => { mainChat.setNextGroupMemberId(60) lastGrokMemberGId = 60 await reachTeamQueue("Hello") @@ -1047,21 +1252,20 @@ describe("Race Conditions", () => { await customer.sends("/team") customer.received(TEAM_ADDED_24H) - // After /team, team member is now in the group + // Grok join completes — Grok keeps working (team member not yet connected) mainChat.setGroupMembers(GROUP_ID, [ - {groupMemberId: 70, memberContactId: 2, memberStatus: "connected"}, + {groupMemberId: 60, memberContactId: 4, memberStatus: "connected"}, ]) - - // Grok join completes — but team member is now present await grokAgent.joins() await grokPromise - // Bot detects team member, removes Grok - grokAgent.wasRemoved() - expect(grokApi.callCount()).toBe(0) + // Grok NOT removed — still functional + grokAgent.wasNotRemoved() + // Grok API was called (activation succeeded) + expect(grokApi.callCount()).toBe(1) }) - test("state change during Grok API call → abort", async () => { + test("team member connects during Grok session → Grok removed", async () => { mainChat.setNextGroupMemberId(60) lastGrokMemberGId = 60 await reachTeamQueue("Hello") @@ -1078,22 +1282,52 @@ describe("Race Conditions", () => { // Flush microtasks so activateGrok proceeds past waitForGrokJoin into grokApi.chat await new Promise(r => setTimeout(r, 0)) - // While API call is pending, /team changes composition + // While API call is pending, /team adds team member mainChat.setNextGroupMemberId(70) lastTeamMemberGId = 70 - // Update members to include team member (Grok still there from DB perspective) + mainChat.setGroupMembers(GROUP_ID, [ + {groupMemberId: 60, memberContactId: 4, memberStatus: "connected"}, + ]) + await customer.sends("/team") + + // API call completes — Grok answer is sent (no abort) + resolveGrokCall("Grok answer") + await grokPromise + grokAgent.wasNotRemoved() + + // Team member sends message → Grok removed mainChat.setGroupMembers(GROUP_ID, [ {groupMemberId: 60, memberContactId: 4, memberStatus: "connected"}, {groupMemberId: 70, memberContactId: 2, memberStatus: "connected"}, ]) - await customer.sends("/team") - - // API call completes — but team member appeared - resolveGrokCall("Grok answer") - await grokPromise - + await teamMember.sends("I'll take over") grokAgent.wasRemoved() }) + + test("team member non-text event (join notification) does NOT remove Grok", async () => { + await reachGrokMode() + mainChat.sent = [] + + // Simulate a non-text system event from a team member (e.g., join notification) + const ci = { + chatInfo: {type: "group", groupInfo: businessGroupInfo()}, + chatItem: { + chatDir: { + type: "groupRcv", + groupMember: {memberId: "team-member-1", groupMemberId: 70, memberContactId: 2, memberProfile: {displayName: "Bob"}}, + }, + meta: {itemId: nextChatItemId++}, + content: {type: "rcvGroupEvent", rcvGroupEvent: {type: "memberConnected"}}, + _text: null, + }, + } as any + ci.chatInfo.groupInfo = businessGroupInfo() + await bot.onNewChatItems({chatItems: [ci]} as any) + + // Grok should NOT be removed — only a real text message should trigger removal + grokAgent.wasNotRemoved() + expect((bot as any).grokGroupMap.has(GROUP_ID)).toBe(true) + }) }) @@ -1124,10 +1358,10 @@ describe("Weekend Hours", () => { describe("Team Forwarding", () => { - test("format: CustomerName:groupId: text", async () => { + test("format: first message has !1 NEW! color-coded prefix", async () => { await customer.sends("My app crashes on startup") - teamGroup.received(fmtCustomer("My app crashes on startup")) + teamGroup.received(fmtNewCustomer("My app crashes on startup", "QUEUE", 1)) }) test("grokMode messages also forwarded to team", async () => { @@ -1137,7 +1371,8 @@ describe("Team Forwarding", () => { grokApi.willRespond("Try clearing app data") await customer.sends("App keeps crashing") - teamGroup.received(fmtCustomer("App keeps crashing")) + // msgNum=3: #1=Hello, #2=Grok answer, #3=customer follow-up + teamGroup.received(fmtCustomer("App keeps crashing", "GROK", 3)) customer.receivedFromGrok("Try clearing app data") }) @@ -1151,7 +1386,7 @@ describe("Team Forwarding", () => { // No prior bot messages for group 101 → welcome flow await bot.onNewChatItems({chatItems: [ci]} as any) - teamGroup.received(fmtCustomer("Hello", "group-101", 101)) + teamGroup.received(fmtNewCustomer("Hello", "QUEUE", 1, "group-101", 101)) }) }) @@ -1203,7 +1438,8 @@ describe("Edge Cases", () => { await bot.onNewChatItems({chatItems: [ci]} as any) // Handled as teamQueue (not welcome, since bot already has groupSnd), forwarded to team - teamGroup.received(fmtCustomer("I had a question earlier", "Alice", 888)) + // First message for group 888 in this bot instance → msgNum=1 + teamGroup.received(fmtCustomer("I had a question earlier", "QUEUE", 1, "Alice", 888)) }) test("Grok's own messages in grokMode → ignored by bot (non-customer)", async () => { @@ -1278,19 +1514,36 @@ describe("Edge Cases", () => { customer.receivedFromGrok("I'm back!") }) - test("/grok as first message → treated as text (welcome state)", async () => { - await customer.sends("/grok") + test("/grok as first message → activates grok directly", async () => { + mainChat.setNextGroupMemberId(60) + lastGrokMemberGId = 60 + grokApi.willRespond("Hello! How can I help?") - // In welcome state, /grok is treated as a regular text message - teamGroup.received(fmtCustomer("/grok")) - customer.received(TEAM_QUEUE_24H) + const p = customer.sends("/grok") + mainChat.setGroupMembers(GROUP_ID, [ + {groupMemberId: 60, memberContactId: 4, memberStatus: "connected"}, + ]) + await grokAgent.joins() + await p + + // Grok activated, no teamQueue message + customer.received(GROK_ACTIVATED) + const teamMsgs = mainChat.sentTo(TEAM_GRP_ID) + expect(teamMsgs.some(m => m.includes("/grok"))).toBe(false) // Commands not forwarded + // /add not sent — only sent on first forwarded text message + expect(teamMsgs.some(m => m.startsWith("/add"))).toBe(false) }) - test("/team as first message → treated as text (welcome state)", async () => { + test("/team as first message → activates team directly", async () => { + mainChat.setGroupMembers(GROUP_ID, []) await customer.sends("/team") - teamGroup.received(fmtCustomer("/team")) - customer.received(TEAM_QUEUE_24H) + // Team member added, no teamQueue message + customer.received(TEAM_ADDED_24H) + const teamMsgs = mainChat.sentTo(TEAM_GRP_ID) + expect(teamMsgs.some(m => m.includes("/team"))).toBe(false) // Commands not forwarded + // /add not sent — only sent on first forwarded text message + expect(teamMsgs.some(m => m.startsWith("/add"))).toBe(false) }) test("non-text message in teamPending → ignored", async () => { @@ -1354,14 +1607,14 @@ describe("Edge Cases", () => { expect((bot as any).reverseGrokMap.has(GROK_LOCAL)).toBe(false) }) - test("replacement team member add fails → still in team mode", async () => { + test("team member leaves teamLocked → no auto-replacement attempted", async () => { await reachTeamLocked() - mainChat.apiAddMemberWillFail() + mainChat.added = [] await teamMember.leaves() - // addReplacementTeamMember failed, but team mode continues - // (next time a message arrives and no team member is found, it will be teamQueue) + // No replacement attempted + expect(mainChat.added.length).toBe(0) }) test("/grok with null grokContactId → unavailable message", async () => { @@ -1388,6 +1641,37 @@ describe("Edge Cases", () => { expect(msgs).toContain(GROK_UNAVAILABLE) }) + test("null grokContactId → members with null memberContactId not matched as Grok", async () => { + const nullGrokConfig = {...config, grokContactId: null} + const nullBot = new SupportBot(mainChat as any, grokChat as any, grokApi as any, nullGrokConfig as any) + // A member with null memberContactId is in the group (should NOT be treated as Grok) + mainChat.setGroupMembers(GROUP_ID, [ + {groupMemberId: 99, memberContactId: null, memberStatus: "connected"}, + ]) + // Send first message to move past welcome + const ci1 = customerChatItem("Hello", null) + mainChat.chatItems.get(GROUP_ID)!.push({ + chatDir: {type: "groupRcv", groupMember: {memberId: CUSTOMER_ID, groupMemberId: 10}}, + _text: "Hello", + }) + await nullBot.onNewChatItems({chatItems: [ci1]} as any) + + // Should route to handleNoSpecialMembers (welcome→teamQueue), NOT handleGrokMode + customer.received(TEAM_QUEUE_24H) + }) + + test("null grokContactId → leftMember with null memberContactId not treated as Grok leave", async () => { + const nullGrokConfig = {...config, grokContactId: null} + const nullBot = new SupportBot(mainChat as any, grokChat as any, grokApi as any, nullGrokConfig as any) + // Simulate a member with null memberContactId leaving — should not crash or misidentify + await nullBot.onLeftMember({ + groupInfo: businessGroupInfo(), + member: {memberId: "unknown-member", groupMemberId: 99, memberContactId: null}, + } as any) + // No crash, and grok maps unchanged (was never set) + expect((nullBot as any).grokGroupMap.size).toBe(0) + }) + test("/team with empty teamMembers → unavailable message", async () => { const noTeamConfig = {...config, teamMembers: []} const noTeamBot = new SupportBot(mainChat as any, grokChat as any, grokApi as any, noTeamConfig as any) @@ -1419,9 +1703,9 @@ describe("Edge Cases", () => { describe("End-to-End Flows", () => { test("full flow: welcome → grokMode → /team → teamLocked", async () => { - // Step 1: first message → teamQueue + // Step 1: first message → teamQueue (#1) await customer.sends("How do I enable disappearing messages?") - teamGroup.received(fmtCustomer("How do I enable disappearing messages?")) + teamGroup.received(fmtNewCustomer("How do I enable disappearing messages?", "QUEUE", 1)) customer.received(TEAM_QUEUE_24H) // Step 2: /grok → grokMode @@ -1444,36 +1728,40 @@ describe("End-to-End Flows", () => { }) grokApi.willRespond("Yes, you can set different timers per conversation.") await customer.sends("Can I set different timers?") - teamGroup.received(fmtCustomer("Can I set different timers?")) + // msgNum=3: #1=customer msg, #2=Grok initial, #3=customer follow-up + teamGroup.received(fmtCustomer("Can I set different timers?", "GROK", 3)) customer.receivedFromGrok("Yes, you can set different timers per conversation.") - // Step 4: /team → team added (Grok removed) + // Step 4: /team → team added, Grok stays during transition mainChat.setNextGroupMemberId(70) lastTeamMemberGId = 70 await customer.sends("/team") - grokAgent.wasRemoved() + grokAgent.wasNotRemoved() teamMember.wasInvited() customer.received(TEAM_ADDED_24H) + // Step 4b: team member sends first message → Grok removed + mainChat.setGroupMembers(GROUP_ID, [ + {groupMemberId: lastGrokMemberGId, memberContactId: 4, memberStatus: "connected"}, + {groupMemberId: 70, memberContactId: 2, memberStatus: "connected"}, + ]) + await teamMember.sends("Hi! Let me help you.") + grokAgent.wasRemoved() + // Update members: Grok gone, team member present mainChat.setGroupMembers(GROUP_ID, [ {groupMemberId: 70, memberContactId: 2, memberStatus: "connected"}, ]) - // Step 5: /grok rejected - await customer.sends("/grok") - customer.received(TEAM_LOCKED_MSG) - - // Step 6: team member responds (the message in DB is the state change) - await teamMember.sends("Hi! Let me help you.") - // Step 7: /grok still rejected await customer.sends("/grok") customer.received(TEAM_LOCKED_MSG) - // Step 8: customer continues — team sees directly, no forwarding + // Step 8: customer continues — forwarded to team group, no reply to customer mainChat.sent = [] await customer.sends("Thanks for helping!") + // msgNum=6: #1=customer, #2=grok, #3=customer, #4=grok, #5=team, #6=customer + teamGroup.received(fmtCustomer("Thanks for helping!", "TEAM", 6)) expect(mainChat.sentTo(GROUP_ID).length).toBe(0) }) @@ -1522,7 +1810,7 @@ describe("Restart Recovery", () => { await bot.onNewChatItems({chatItems: [ci]} as any) // Treated as teamQueue (not welcome), message forwarded to team - teamGroup.received(fmtCustomer("I had a question earlier", "Alice", 777)) + teamGroup.received(fmtCustomer("I had a question earlier", "QUEUE", 1, "Alice", 777)) }) test("after restart, /grok works in recovered group", async () => { @@ -1606,6 +1894,28 @@ describe("Grok connectedToGroupMember", () => { member: {memberProfile: {displayName: "Someone"}}, } as any) }) + + test("grokGroupMap set does NOT satisfy waitForGrokJoin (only grokFullyConnected does)", async () => { + // Verify the fast-path checks grokFullyConnected, not grokGroupMap + // grokGroupMap can be set (by onGrokGroupInvitation) before connectedToGroupMember fires + expect((bot as any).grokGroupMap.has(GROUP_ID)).toBe(false) + expect((bot as any).grokFullyConnected.has(GROUP_ID)).toBe(false) + + // Manually set grokGroupMap but NOT grokFullyConnected (simulates invitation processed) + ;(bot as any).grokGroupMap.set(GROUP_ID, GROK_LOCAL) + ;(bot as any).reverseGrokMap.set(GROK_LOCAL, GROUP_ID) + + // waitForGrokJoin should NOT resolve immediately (grokGroupMap is set but grokFullyConnected isn't) + vi.useFakeTimers() + const result = (bot as any).waitForGrokJoin(GROUP_ID, 100) + await vi.advanceTimersByTimeAsync(101) + expect(await result).toBe(false) + vi.useRealTimers() + + // Cleanup + ;(bot as any).grokGroupMap.delete(GROUP_ID) + ;(bot as any).reverseGrokMap.delete(GROK_LOCAL) + }) }) @@ -1639,39 +1949,147 @@ describe("groupDuplicateMember Handling", () => { customer.received(TEAM_ADD_ERROR) }) - test("replacement team member with duplicate → finds existing", async () => { + test("team member leaves → no replacement, no duplicate handling needed", async () => { await reachTeamLocked() - mainChat.apiAddMemberWillDuplicate() - mainChat.setGroupMembers(GROUP_ID, [ - {groupMemberId: 99, memberContactId: 2, memberStatus: "connected"}, - ]) + mainChat.added = [] await teamMember.leaves() - // No error — replacement found via duplicate handling - expect(mainChat.added.length).toBeGreaterThanOrEqual(1) + expect(mainChat.added.length).toBe(0) }) }) -// ─── 18. DM Contact Received ─────────────────────────────────── +// ─── 18. DM Contact — Proactive Member Contact Creation ──────── -describe("DM Contact Received", () => { +describe("DM Contact — Proactive Member Contact Creation", () => { - test("onMemberContactReceivedInv from team group → no crash", () => { - bot.onMemberContactReceivedInv({ + test("member with existing contact (auto-accept) → DM sent directly", async () => { + mainChat.sent = [] + mainChat.sentCmds = [] + + await bot.onMemberConnected({ + groupInfo: {groupId: TEAM_GRP_ID}, + member: {groupMemberId: 30, memberContactId: 5, memberProfile: {displayName: "TeamGuy"}}, + } as any) + + // No /_create command — contact already exists + expect(mainChat.sentCmds.some(c => c.includes("/_create member contact"))).toBe(false) + + // DM sent directly via existing contact + const dm = mainChat.sent.find(m => m.chat[0] === "direct" && m.chat[1] === 5) + expect(dm).toBeDefined() + expect(dm!.text).toContain("keep this contact") + expect(dm!.text).toContain("5:TeamGuy") + }) + + test("member with memberContact on event → DM sent directly via memberContact", async () => { + mainChat.sent = [] + mainChat.sentCmds = [] + + await bot.onMemberConnected({ + groupInfo: {groupId: TEAM_GRP_ID}, + member: {groupMemberId: 30, memberContactId: null, memberProfile: {displayName: "TeamGuy"}}, + memberContact: {contactId: 42}, + } as any) + + // No /_create command — memberContact provided + expect(mainChat.sentCmds.some(c => c.includes("/_create member contact"))).toBe(false) + + // DM sent directly via memberContact + const dm = mainChat.sent.find(m => m.chat[0] === "direct" && m.chat[1] === 42) + expect(dm).toBeDefined() + expect(dm!.text).toContain("keep this contact") + expect(dm!.text).toContain("42:TeamGuy") + }) + + test("member with no contact → create contact, invite, DM on contactConnected", async () => { + mainChat.sent = [] + mainChat.sentCmds = [] + + await bot.onMemberConnected({ + groupInfo: {groupId: TEAM_GRP_ID}, + member: {groupMemberId: 30, memberContactId: null, memberProfile: {displayName: "TeamGuy"}}, + } as any) + + // /_create member contact and /_invite member contact sent + expect(mainChat.sentCmds.some(c => c.includes("/_create member contact #1 30"))).toBe(true) + expect(mainChat.sentCmds.some(c => c.includes("/_invite member contact @"))).toBe(true) + + // DM not sent yet — contact not connected + expect(mainChat.sent.find(m => m.chat[0] === "direct")).toBeUndefined() + + // contactConnected fires → DM sent + await bot.onContactConnected({contact: {contactId: 100}} as any) + + const dm = mainChat.sent.find(m => m.chat[0] === "direct" && m.chat[1] === 100) + expect(dm).toBeDefined() + expect(dm!.text).toContain("keep this contact") + expect(dm!.text).toContain("100:TeamGuy") + }) + + test("member with spaces in name → name quoted in DM", async () => { + mainChat.sent = [] + + await bot.onMemberConnected({ + groupInfo: {groupId: TEAM_GRP_ID}, + member: {groupMemberId: 31, memberContactId: 7, memberProfile: {displayName: "Team Guy"}}, + } as any) + + const dm = mainChat.sent.find(m => m.chat[0] === "direct" && m.chat[1] === 7) + expect(dm).toBeDefined() + expect(dm!.text).toContain("7:'Team Guy'") + }) + + test("non-team group member connects → no create, no DM", async () => { + mainChat.sent = [] + mainChat.sentCmds = [] + + await bot.onMemberConnected({ + groupInfo: {groupId: 999}, + member: {groupMemberId: 30, memberContactId: null, memberProfile: {displayName: "Someone"}}, + } as any) + + expect(mainChat.sentCmds.some(c => c.includes("/_create member contact"))).toBe(false) + expect(mainChat.sent.find(m => m.chat[0] === "direct")).toBeUndefined() + }) + + test("contactConnected for unknown contact → ignored", async () => { + mainChat.sent = [] + await bot.onContactConnected({contact: {contactId: 999}} as any) + + expect(mainChat.sent.find(m => m.chat[0] === "direct")).toBeUndefined() + }) + + test("receivedInv fallback → DM queued and sent on contactConnected", async () => { + mainChat.sent = [] + await bot.onMemberContactReceivedInv({ contact: {contactId: 10}, groupInfo: {groupId: TEAM_GRP_ID}, member: {memberProfile: {displayName: "TeamGuy"}}, } as any) + + // DM not sent yet + expect(mainChat.sent.find(m => m.chat[0] === "direct")).toBeUndefined() + + // contactConnected fires → DM sent + await bot.onContactConnected({contact: {contactId: 10}} as any) + + const dm = mainChat.sent.find(m => m.chat[0] === "direct" && m.chat[1] === 10) + expect(dm).toBeDefined() + expect(dm!.text).toContain("keep this contact") }) - test("onMemberContactReceivedInv from non-team group → no crash", () => { - bot.onMemberContactReceivedInv({ + test("non-team group receivedInv → no DM", async () => { + mainChat.sent = [] + await bot.onMemberContactReceivedInv({ contact: {contactId: 11}, groupInfo: {groupId: 999}, member: {memberProfile: {displayName: "Stranger"}}, } as any) + await bot.onContactConnected({contact: {contactId: 11}} as any) + + expect(mainChat.sent.find(m => m.chat[0] === "direct")).toBeUndefined() }) }) @@ -1717,13 +2135,13 @@ describe("Business Request — Media Upload", () => { describe("Edit Forwarding", () => { - test("customer edits forwarded message → team group message updated", async () => { + test("customer edits forwarded message → team group message updated (with *NEW:* if still new)", async () => { // Send first message → forwarded to team (stores mapping) await customer.sends("Original question") // The customer chat item had itemId=500, the forwarded team msg got itemId=1000 mainChat.sent = [] - // Simulate edit event + // Simulate edit event — first message still has *NEW:* marker await bot.onChatItemUpdated({ chatItem: { chatInfo: {type: "group", groupInfo: businessGroupInfo()}, @@ -1739,12 +2157,13 @@ describe("Edit Forwarding", () => { expect(mainChat.updatedChatItems.length).toBe(1) expect(mainChat.updatedChatItems[0].chatId).toBe(TEAM_GRP_ID) expect(mainChat.updatedChatItems[0].chatItemId).toBe(1000) - expect(mainChat.updatedChatItems[0].msgContent).toEqual({type: "text", text: fmtCustomer("Edited question")}) + // Edit uses stored header from original forward. Original was first msg with QUEUE state, #1 + expect(mainChat.updatedChatItems[0].msgContent).toEqual({type: "text", text: fmtNewCustomer("Edited question", "QUEUE", 1)}) }) test("team member edits forwarded message → team group message updated", async () => { await reachTeamPending() - // After reachTeamPending: nextChatItemId=502, nextItemId=1004 + // After reachTeamPending: nextChatItemId=502, nextItemId=1004 (no command fwd) // Team member sends → itemId=502, forwarded teamItemId=1004 await teamMember.sends("I'll help you") mainChat.updatedChatItems = [] @@ -1768,7 +2187,8 @@ describe("Edit Forwarding", () => { expect(mainChat.updatedChatItems.length).toBe(1) expect(mainChat.updatedChatItems[0].chatId).toBe(TEAM_GRP_ID) expect(mainChat.updatedChatItems[0].chatItemId).toBe(1004) - expect(mainChat.updatedChatItems[0].msgContent).toEqual({type: "text", text: fmtTeamMember(2, "Actually, let me rephrase")}) + // Team member msg was #2 in TEAM state + expect(mainChat.updatedChatItems[0].msgContent).toEqual({type: "text", text: fmtTeamMember(2, "Actually, let me rephrase", "TEAM", 2)}) }) test("edit for non-forwarded message → ignored", async () => { @@ -1825,7 +2245,7 @@ describe("Edit Forwarding", () => { // Customer sends a text message in grokMode (forwarded to team) grokApi.willRespond("Follow-up answer") await customer.sends("My question about encryption") - // customerChatItem itemId=502, forwarded to team as itemId=1004 + // customerChatItem itemId=502, forwarded to team as itemId=1005 (no command fwd) mainChat.updatedChatItems = [] // Customer edits the message @@ -1843,8 +2263,9 @@ describe("Edit Forwarding", () => { expect(mainChat.updatedChatItems.length).toBe(1) expect(mainChat.updatedChatItems[0].chatId).toBe(TEAM_GRP_ID) - expect(mainChat.updatedChatItems[0].chatItemId).toBe(1004) - expect(mainChat.updatedChatItems[0].msgContent).toEqual({type: "text", text: fmtCustomer("Edited encryption question")}) + expect(mainChat.updatedChatItems[0].chatItemId).toBe(1005) + // Edit uses stored header from original forward: GROK state, #3 + expect(mainChat.updatedChatItems[0].msgContent).toEqual({type: "text", text: fmtCustomer("Edited encryption question", "GROK", 3)}) }) test("edit with null text → ignored", async () => { @@ -1879,7 +2300,8 @@ describe("Team Member Reply Forwarding", () => { await teamMember.sends("I'll help you with this") - teamGroup.received(fmtTeamMember(2, "I'll help you with this")) + // Team member msg #2 in TEAM state + teamGroup.received(fmtTeamMember(2, "I'll help you with this", "TEAM", 2)) }) test("team member message in teamLocked → forwarded to team group", async () => { @@ -1888,7 +2310,8 @@ describe("Team Member Reply Forwarding", () => { await teamMember.sends("Here is the solution") - teamGroup.received(fmtTeamMember(2, "Here is the solution")) + // Team member msg #3 in TEAM state (after #1=Hello, #2=team "I'll help you") + teamGroup.received(fmtTeamMember(2, "Here is the solution", "TEAM", 3)) }) test("Grok message → not forwarded to team group", async () => { @@ -1955,8 +2378,9 @@ describe("Grok Group Map Persistence", () => { // Response sent via grokChat to GROK_LOCAL customer.receivedFromGrok("Here is the answer about encryption") - // Also forwarded to team group - teamGroup.received(fmtCustomer("How does encryption work?")) + // Also forwarded to team group (mock has no chat history after reset, so isFirstCustomerMessage → true → NEW) + // State is GROK (grok member present), #1 (first tracked msg) + teamGroup.received(fmtNewCustomer("How does encryption work?", "GROK", 1)) }) test("onGrokMapChanged fires on Grok join", async () => { @@ -2001,8 +2425,8 @@ describe("/add Command", () => { test("first customer message → /add command sent to team group", async () => { await customer.sends("Hello, I need help") - // Team group receives forwarded message + /add command - teamGroup.received(fmtCustomer("Hello, I need help")) + // Team group receives forwarded message (with !1 NEW!) + /add command + teamGroup.received(fmtNewCustomer("Hello, I need help", "QUEUE", 1)) teamGroup.received(`/add ${GROUP_ID}:Alice`) }) @@ -2033,7 +2457,7 @@ describe("/add Command", () => { // Only the forwarded message, no /add const teamMsgs = mainChat.sentTo(TEAM_GRP_ID) - expect(teamMsgs).toEqual([fmtCustomer("More details")]) + expect(teamMsgs).toEqual([fmtCustomer("More details", "QUEUE", 2)]) }) test("team member sends /add → invited to customer group", async () => { @@ -2247,6 +2671,305 @@ describe("Grok System Prompt", () => { }) +// ─── 25b. Forwarded Message Reply-To ───────────────────────────── + +describe("Forwarded Message Reply-To", () => { + + test("customer reply-to is forwarded with inReplyTo to team group", async () => { + // "Hello" gets chatItemId 500, forwarded → teamItemId 1000 + await reachTeamQueue("Hello") + // Send a reply to "Hello" (quotedItemId 500) + await customer.sendsReplyTo("Following up on that", 500) + + const fwdMsg = mainChat.sent.find(m => + m.chat[1] === TEAM_GRP_ID && m.text.includes("Following up on that")) + expect(fwdMsg).toBeDefined() + expect(fwdMsg!.inReplyTo).toBe(1000) + }) + + test("customer reply-to unknown item → A1 threading falls back to lastTeamItemByGroup", async () => { + await reachTeamQueue("Hello") + // "Hello" teamItemId=1000. Reply-to unknown (999) → resolveTeamReplyTo returns undefined + // But A1 threading: effectiveReplyTo = lastTeamItemByGroup = 1000 + await customer.sendsReplyTo("Reply to unknown", 999) + + const fwdMsg = mainChat.sent.find(m => + m.chat[1] === TEAM_GRP_ID && m.text.includes("Reply to unknown")) + expect(fwdMsg).toBeDefined() + expect(fwdMsg!.inReplyTo).toBe(1000) // A1: auto-thread to last team item + }) + + test("customer message without reply-to → A1 auto-threads to last team item", async () => { + await reachTeamQueue("Hello") + // "Hello" teamItemId=1000 + mainChat.sent = [] + await customer.sends("Another question") + + const fwdMsg = mainChat.sent.find(m => + m.chat[1] === TEAM_GRP_ID && m.text.includes("Another question")) + expect(fwdMsg).toBeDefined() + expect(fwdMsg!.inReplyTo).toBe(1000) // A1: auto-thread to last team item + }) + + test("team member reply-to is forwarded with inReplyTo", async () => { + // Customer "Hello" (chatItemId 500) → teamItemId 1000 + await reachTeamPending() + await teamMember.sendsReplyTo("I'll help with that", 500) + + const fwdMsg = mainChat.sent.find(m => + m.chat[1] === TEAM_GRP_ID && m.text.includes("I'll help with that")) + expect(fwdMsg).toBeDefined() + expect(fwdMsg!.inReplyTo).toBe(1000) + }) + + test("customer reply-to in grok mode forwarded with inReplyTo", async () => { + await reachGrokMode("Initial answer") + mainChat.chatItems.get(GROUP_ID)!.push({ + chatDir: {type: "groupRcv", groupMember: {memberId: "grok-1", groupMemberId: 60, memberContactId: 4}}, + _text: "Initial answer", + }) + grokChat.setChatItems(GROK_LOCAL, [ + {chatDir: {type: "groupRcv"}, meta: {itemId: 5001}, _text: "Follow-up on my hello"}, + ]) + grokApi.willRespond("Follow-up answer") + mainChat.sent = [] + + // Customer replies to their own "Hello" (itemId 500) which was forwarded (teamItemId 1000) + await customer.sendsReplyTo("Follow-up on my hello", 500) + + // After reachGrokMode: #1=Hello, #2=Grok initial. Customer follow-up is #3 in GROK state + const custFwd = mainChat.sent.find(m => + m.chat[1] === TEAM_GRP_ID && m.text === fmtCustomer("Follow-up on my hello", "GROK", 3)) + expect(custFwd).toBeDefined() + expect(custFwd!.inReplyTo).toBe(1000) + }) +}) + + +// ─── 25c. Grok Response Forwarded to Team ─────────────────────── + +describe("Grok Response Forwarded to Team", () => { + + test("activateGrok forwards grok response to team with reply-to", async () => { + mainChat.setNextGroupMemberId(60) + lastGrokMemberGId = 60 + await reachTeamQueue("Hello") + // "Hello" (chatItemId 500) → teamItemId 1000 + + grokChat.setChatItems(GROK_LOCAL, [ + {chatDir: {type: "groupRcv"}, meta: {itemId: 6001}, _text: "Hello"}, + ]) + grokApi.willRespond("Hi there!") + const p = customer.sends("/grok") + mainChat.setGroupMembers(GROUP_ID, [ + {groupMemberId: 60, memberContactId: 4, memberStatus: "connected"}, + ]) + await grokAgent.joins() + await p + + // activateGrok: #1=Hello, Grok response=#2 in GROK state + const grokFwd = mainChat.sent.find(m => + m.chat[1] === TEAM_GRP_ID && m.text === fmtGrok("Hi there!", "GROK", 2)) + expect(grokFwd).toBeDefined() + expect(grokFwd!.inReplyTo).toBe(1000) + }) + + test("forwardToGrok forwards grok response to team with reply-to", async () => { + await reachGrokMode("Initial answer") + // "Hello" (chatItemId 500) → teamItemId 1000 + mainChat.chatItems.get(GROUP_ID)!.push({ + chatDir: {type: "groupRcv", groupMember: {memberId: "grok-1", groupMemberId: 60, memberContactId: 4}}, + _text: "Initial answer", + }) + grokChat.setChatItems(GROK_LOCAL, [ + {chatDir: {type: "groupRcv"}, meta: {itemId: 5001}, _text: "What about encryption?"}, + ]) + grokApi.willRespond("Encryption answer") + mainChat.sent = [] + + await customer.sends("What about encryption?") + + // Customer msg forwarded: #3 in GROK state (#1=Hello, #2=Grok initial) + const custFwd = mainChat.sent.find(m => + m.chat[1] === TEAM_GRP_ID && m.text === fmtCustomer("What about encryption?", "GROK", 3)) + expect(custFwd).toBeDefined() + + // Grok response forwarded: #4 in GROK state + const grokFwd = mainChat.sent.find(m => + m.chat[1] === TEAM_GRP_ID && m.text === fmtGrok("Encryption answer", "GROK", 4)) + expect(grokFwd).toBeDefined() + // After reachGrokMode, mainChat.nextItemId = 1005 (no cmd fwd). Customer fwd gets 1005. + expect(grokFwd!.inReplyTo).toBe(1005) + }) + + test("grok response format includes customer prefix", async () => { + await reachGrokMode("Test response") + + // activateGrok: #2 in GROK state + const grokFwd = mainChat.sent.find(m => + m.chat[1] === TEAM_GRP_ID && m.text === fmtGrok("Test response", "GROK", 2)) + expect(grokFwd).toBeDefined() + }) + + test("grok API failure does not forward to team", async () => { + await reachGrokMode("Initial answer") + mainChat.chatItems.get(GROUP_ID)!.push({ + chatDir: {type: "groupRcv", groupMember: {memberId: "grok-1", groupMemberId: 60, memberContactId: 4}}, + _text: "Initial answer", + }) + grokChat.setChatItems(GROK_LOCAL, [ + {chatDir: {type: "groupRcv"}, meta: {itemId: 5001}, _text: "Fail me"}, + ]) + grokApi.willFail() + mainChat.sent = [] + + await customer.sends("Fail me") + + // No Grok response forwarded to team (look for AI prefix) + const grokFwd = mainChat.sent.find(m => + m.chat[1] === TEAM_GRP_ID && m.text.startsWith("!5 AI!")) + expect(grokFwd).toBeUndefined() + }) +}) + + +// ─── 25d. Grok Reply-To ───────────────────────────────────────── + +describe("Grok Reply-To", () => { + + test("forwardToGrok replies to the last received message in grok chat", async () => { + await reachGrokMode("Initial answer") + // Simulate Grok agent's view: it has the previous customer message in its local chat + mainChat.chatItems.get(GROUP_ID)!.push({ + chatDir: {type: "groupRcv", groupMember: {memberId: "grok-1", groupMemberId: 60, memberContactId: 4}}, + _text: "Initial answer", + }) + // Set up Grok agent's local chat with the new customer message (as Grok would see it) + grokChat.setChatItems(GROK_LOCAL, [ + {chatDir: {type: "groupRcv"}, meta: {itemId: 5001}, _text: "What about encryption?"}, + ]) + grokApi.willRespond("Encryption answer") + grokChat.sent = [] + + await customer.sends("What about encryption?") + + // Grok response sent with inReplyTo matching the customer message item ID in Grok's view + const grokSent = grokChat.sent.find(m => m.chat[1] === GROK_LOCAL && m.text === "Encryption answer") + expect(grokSent).toBeDefined() + expect(grokSent!.inReplyTo).toBe(5001) + }) + + test("activateGrok replies to the last customer message", async () => { + mainChat.setNextGroupMemberId(60) + lastGrokMemberGId = 60 + await reachTeamQueue("Hello") + + // Set up Grok agent's local chat — simulates Grok seeing the customer's message after join + grokChat.setChatItems(GROK_LOCAL, [ + {chatDir: {type: "groupRcv"}, meta: {itemId: 6001}, _text: "Hello"}, + ]) + + grokApi.willRespond("Hi there!") + const p = customer.sends("/grok") + mainChat.setGroupMembers(GROUP_ID, [ + {groupMemberId: 60, memberContactId: 4, memberStatus: "connected"}, + ]) + await grokAgent.joins() + await p + + const grokSent = grokChat.sent.find(m => m.chat[1] === GROK_LOCAL && m.text === "Hi there!") + expect(grokSent).toBeDefined() + expect(grokSent!.inReplyTo).toBe(6001) + }) + + test("activateGrok with multiple customer messages replies to the last one", async () => { + mainChat.setNextGroupMemberId(60) + lastGrokMemberGId = 60 + await reachTeamQueue("First question", "Second question") + + // Grok agent sees both customer messages — reply should target the last one + grokChat.setChatItems(GROK_LOCAL, [ + {chatDir: {type: "groupRcv"}, meta: {itemId: 7001}, _text: "First question"}, + {chatDir: {type: "groupRcv"}, meta: {itemId: 7002}, _text: "Second question"}, + ]) + + grokApi.willRespond("Answer to both") + const p = customer.sends("/grok") + mainChat.setGroupMembers(GROUP_ID, [ + {groupMemberId: 60, memberContactId: 4, memberStatus: "connected"}, + ]) + await grokAgent.joins() + await p + + const grokSent = grokChat.sent.find(m => m.chat[1] === GROK_LOCAL && m.text === "Answer to both") + expect(grokSent).toBeDefined() + expect(grokSent!.inReplyTo).toBe(7002) + }) + + test("graceful fallback when grok chat has no matching item", async () => { + await reachGrokMode("Initial answer") + mainChat.chatItems.get(GROUP_ID)!.push({ + chatDir: {type: "groupRcv", groupMember: {memberId: "grok-1", groupMemberId: 60, memberContactId: 4}}, + _text: "Initial answer", + }) + // Grok agent's chat is empty — no item to reply to + grokChat.setChatItems(GROK_LOCAL, []) + grokApi.willRespond("Some answer") + grokChat.sent = [] + + await customer.sends("New question") + + // Response sent without inReplyTo (graceful fallback) + const grokSent = grokChat.sent.find(m => m.chat[1] === GROK_LOCAL && m.text === "Some answer") + expect(grokSent).toBeDefined() + expect(grokSent!.inReplyTo).toBeUndefined() + }) + + test("skips grok's own messages (groupSnd) when searching for reply target", async () => { + await reachGrokMode("Initial answer") + mainChat.chatItems.get(GROUP_ID)!.push({ + chatDir: {type: "groupRcv", groupMember: {memberId: "grok-1", groupMemberId: 60, memberContactId: 4}}, + _text: "Initial answer", + }) + // Grok's chat: has Grok's own previous response (groupSnd) then the customer message + grokChat.setChatItems(GROK_LOCAL, [ + {chatDir: {type: "groupSnd"}, meta: {itemId: 8001}, _text: "Follow-up question"}, + {chatDir: {type: "groupRcv"}, meta: {itemId: 8002}, _text: "Follow-up question"}, + ]) + grokApi.willRespond("Follow-up answer") + grokChat.sent = [] + + await customer.sends("Follow-up question") + + // Should reply to 8002 (groupRcv), not 8001 (groupSnd) + const grokSent = grokChat.sent.find(m => m.chat[1] === GROK_LOCAL && m.text === "Follow-up answer") + expect(grokSent).toBeDefined() + expect(grokSent!.inReplyTo).toBe(8002) + }) + + test("replies to last received even if text differs", async () => { + await reachGrokMode("Initial answer") + mainChat.chatItems.get(GROUP_ID)!.push({ + chatDir: {type: "groupRcv", groupMember: {memberId: "grok-1", groupMemberId: 60, memberContactId: 4}}, + _text: "Initial answer", + }) + // Grok's chat has a message with different text (e.g., previous message arrived but current hasn't yet) + grokChat.setChatItems(GROK_LOCAL, [ + {chatDir: {type: "groupRcv"}, meta: {itemId: 9001}, _text: "How does encryption work exactly?"}, + ]) + grokApi.willRespond("Partial answer") + grokChat.sent = [] + + await customer.sends("How does encryption work?") + + // Replies to last received item regardless of text match + const grokSent = grokChat.sent.find(m => m.chat[1] === GROK_LOCAL && m.text === "Partial answer") + expect(grokSent).toBeDefined() + expect(grokSent!.inReplyTo).toBe(9001) + }) +}) + + // ─── 25. resolveDisplayNameConflict ────────────────────────── describe("resolveDisplayNameConflict", () => { @@ -2341,3 +3064,1419 @@ describe("resolveDisplayNameConflict", () => { ) }) }) + + +// ─── 26. parseConfig & parseIdName ─────────────────────────────── + +describe("parseIdName", () => { + test("parses valid id:name", () => { + expect(parseIdName("2:Bob")).toEqual({id: 2, name: "Bob"}) + }) + + test("parses name with colons", () => { + expect(parseIdName("5:Alice:Admin")).toEqual({id: 5, name: "Alice:Admin"}) + }) + + test("throws on missing colon", () => { + expect(() => parseIdName("Bob")).toThrow('Invalid ID:name format: "Bob"') + }) + + test("throws on non-numeric id", () => { + expect(() => parseIdName("abc:Bob")).toThrow('Invalid ID:name format (non-numeric ID): "abc:Bob"') + }) + + test("throws on colon at start", () => { + expect(() => parseIdName(":Bob")).toThrow('Invalid ID:name format: ":Bob"') + }) +}) + +describe("parseConfig --team-members / --team-member aliases", () => { + const baseArgs = ["--team-group", "Support Team"] + + beforeEach(() => { + vi.stubEnv("GROK_API_KEY", "test-key") + }) + + afterEach(() => { + vi.unstubAllEnvs() + }) + + test("--team-members with single member", () => { + const config = parseConfig([...baseArgs, "--team-members", "2:Bob"]) + expect(config.teamMembers).toEqual([{id: 2, name: "Bob"}]) + }) + + test("--team-members with multiple comma-separated members", () => { + const config = parseConfig([...baseArgs, "--team-members", "2:Bob,5:Alice"]) + expect(config.teamMembers).toEqual([ + {id: 2, name: "Bob"}, + {id: 5, name: "Alice"}, + ]) + }) + + test("--team-member with single member", () => { + const config = parseConfig([...baseArgs, "--team-member", "2:Bob"]) + expect(config.teamMembers).toEqual([{id: 2, name: "Bob"}]) + }) + + test("--team-member with multiple comma-separated members", () => { + const config = parseConfig([...baseArgs, "--team-member", "2:Bob,5:Alice"]) + expect(config.teamMembers).toEqual([ + {id: 2, name: "Bob"}, + {id: 5, name: "Alice"}, + ]) + }) + + test("both flags provided → members merged", () => { + const config = parseConfig([...baseArgs, "--team-members", "2:Bob", "--team-member", "5:Alice"]) + expect(config.teamMembers).toEqual([ + {id: 2, name: "Bob"}, + {id: 5, name: "Alice"}, + ]) + }) + + test("both flags with comma-separated values → all merged", () => { + const config = parseConfig([...baseArgs, "--team-members", "2:Bob,3:Carol", "--team-member", "5:Alice"]) + expect(config.teamMembers).toEqual([ + {id: 2, name: "Bob"}, + {id: 3, name: "Carol"}, + {id: 5, name: "Alice"}, + ]) + }) + + test("neither flag → empty array", () => { + const config = parseConfig(baseArgs) + expect(config.teamMembers).toEqual([]) + }) + + test("other config fields still parsed correctly", () => { + const config = parseConfig([...baseArgs, "--team-member", "2:Bob", "--timezone", "US/Eastern"]) + expect(config.teamGroup).toEqual({id: 0, name: "Support Team"}) + expect(config.timezone).toBe("US/Eastern") + expect(config.teamMembers).toEqual([{id: 2, name: "Bob"}]) + }) +}) + + +// ─── 27. Message Truncation ────────────────────────────────── + +describe("Message Truncation", () => { + + test("short message forwarded unchanged (with !1 NEW! on first)", async () => { + await customer.sends("Short question") + + teamGroup.received(fmtNewCustomer("Short question", "QUEUE", 1)) + }) + + test("message exceeding limit is truncated with suffix", async () => { + // Create a message that exceeds 15000 bytes when combined with prefix + const longText = "A".repeat(15000) + await customer.sends(longText) + + const teamMsgs = mainChat.sentTo(TEAM_GRP_ID) + const fwdMsg = teamMsgs.find(m => m.startsWith("!1 NEW!")) + expect(fwdMsg).toBeDefined() + expect(fwdMsg!.endsWith("… [truncated]")).toBe(true) + expect(new TextEncoder().encode(fwdMsg!).length).toBeLessThanOrEqual(15000) + }) + + test("prefix is preserved in truncated message", async () => { + const longText = "B".repeat(15000) + await customer.sends(longText) + + const teamMsgs = mainChat.sentTo(TEAM_GRP_ID) + const fwdMsg = teamMsgs.find(m => m.startsWith("!1 NEW!")) + expect(fwdMsg).toBeDefined() + // Header is intact at the start (with !1 NEW!) + expect(fwdMsg!.startsWith(`!1 NEW! *${GROUP_ID}:Alice`)).toBe(true) + expect(fwdMsg!.endsWith("… [truncated]")).toBe(true) + }) + + test("edit of a long message is also truncated", async () => { + // Send first message → forwarded to team (stores mapping) + await customer.sends("Original question") + // customerChatItem itemId=500, forwarded teamItemId=1000 + mainChat.updatedChatItems = [] + + // Simulate edit with very long text — first message still has !1 NEW! marker + const longEditText = "C".repeat(15000) + await bot.onChatItemUpdated({ + chatItem: { + chatInfo: {type: "group", groupInfo: businessGroupInfo()}, + chatItem: { + chatDir: {type: "groupRcv", groupMember: {memberId: CUSTOMER_ID, groupMemberId: 10}}, + meta: {itemId: 500}, + content: {type: "text", text: longEditText}, + _text: longEditText, + }, + }, + } as any) + + expect(mainChat.updatedChatItems.length).toBe(1) + const updatedText = mainChat.updatedChatItems[0].msgContent.text + expect(updatedText.endsWith("… [truncated]")).toBe(true) + expect(updatedText.startsWith(`!1 NEW! *${GROUP_ID}:Alice`)).toBe(true) + expect(new TextEncoder().encode(updatedText).length).toBeLessThanOrEqual(15000) + }) + + test("Grok response to customer group is truncated when too long", async () => { + const longGrokResponse = "D".repeat(16000) + mainChat.setNextGroupMemberId(60) + lastGrokMemberGId = 60 + await reachTeamQueue("Hello") + + grokApi.willRespond(longGrokResponse) + const p = customer.sends("/grok") + mainChat.setGroupMembers(GROUP_ID, [ + {groupMemberId: 60, memberContactId: 4, memberStatus: "connected"}, + ]) + await grokAgent.joins() + await p + + // Grok response sent to customer group (via grokChat) should be truncated + const grokMsgs = grokChat.sentTo(GROK_LOCAL) + const grokMsg = grokMsgs.find(m => m.endsWith("… [truncated]")) + expect(grokMsg).toBeDefined() + expect(new TextEncoder().encode(grokMsg!).length).toBeLessThanOrEqual(15000) + }) + + test("multi-byte characters are not broken by truncation", async () => { + // Create a message with multi-byte chars that would be split mid-character + const emoji = "\u{1F600}" // 4-byte emoji + const longText = emoji.repeat(4000) // 16000 bytes + await customer.sends(longText) + + const teamMsgs = mainChat.sentTo(TEAM_GRP_ID) + const fwdMsg = teamMsgs.find(m => m.startsWith("!1 NEW!")) + expect(fwdMsg).toBeDefined() + expect(fwdMsg!.endsWith("… [truncated]")).toBe(true) + // Verify no replacement character (U+FFFD) from broken multi-byte sequences + expect(fwdMsg!).not.toContain("\uFFFD") + expect(new TextEncoder().encode(fwdMsg!).length).toBeLessThanOrEqual(15000) + }) +}) + + +// ─── 28. NEW: Prefix ──────────────────────────────────────────── + +describe("NEW: Prefix", () => { + + test("first customer text gets !1 NEW! prefix in team group", async () => { + await customer.sends("How do I create a group?") + + teamGroup.received(fmtNewCustomer("How do I create a group?", "QUEUE", 1)) + }) + + test("second customer message does NOT get !1 NEW!", async () => { + await reachTeamQueue("First question") + mainChat.sent = [] + + await customer.sends("More details") + + // Should be forwarded without !1 NEW! + const teamMsgs = mainChat.sentTo(TEAM_GRP_ID) + expect(teamMsgs).toContain(fmtCustomer("More details", "QUEUE", 2)) + expect(teamMsgs.some(m => m.includes("!1 NEW!"))).toBe(false) + }) + + test("/grok removes !1 NEW! (team message edited)", async () => { + await customer.sends("Hello") + // First message: chatItemId=500, teamItemId=1000 + mainChat.updatedChatItems = [] + + mainChat.setNextGroupMemberId(60) + lastGrokMemberGId = 60 + grokApi.willRespond("Grok answer") + const p = customer.sends("/grok") + mainChat.setGroupMembers(GROUP_ID, [ + {groupMemberId: 60, memberContactId: 4, memberStatus: "connected"}, + ]) + await grokAgent.joins() + await p + + // Team message should have been edited to remove !1 NEW! → originalText (clean version) + const update = mainChat.updatedChatItems.find(u => u.chatItemId === 1000) + expect(update).toBeDefined() + expect(update!.msgContent.text).toBe(fmtCustomer("Hello", "QUEUE", 1)) + }) + + test("/team removes !1 NEW! (team message edited)", async () => { + await customer.sends("Hello") + // First message: chatItemId=500, teamItemId=1000 + mainChat.updatedChatItems = [] + + mainChat.setGroupMembers(GROUP_ID, []) + await customer.sends("/team") + + // Team message should have been edited to remove !1 NEW! → originalText + const update = mainChat.updatedChatItems.find(u => u.chatItemId === 1000) + expect(update).toBeDefined() + expect(update!.msgContent.text).toBe(fmtCustomer("Hello", "QUEUE", 1)) + }) + + test("/add command removes *NEW:* (team message edited)", async () => { + await customer.sends("Hello") + // First message: chatItemId=500, teamItemId=1000 + mainChat.updatedChatItems = [] + + // Team member sends /add command in team group + const ci = { + chatInfo: {type: "group", groupInfo: {groupId: TEAM_GRP_ID, groupProfile: {displayName: "SupportTeam"}}}, + chatItem: { + chatDir: { + type: "groupRcv", + groupMember: {memberId: "tm-1", groupMemberId: 30, memberContactId: 2, memberProfile: {displayName: "Bob"}}, + }, + meta: {itemId: 900}, + content: {type: "text", text: `/add ${GROUP_ID}:Alice`}, + _text: `/add ${GROUP_ID}:Alice`, + }, + } as any + await bot.onNewChatItems({chatItems: [ci]} as any) + + // Team message should have been edited to remove !1 NEW! → originalText + const update = mainChat.updatedChatItems.find(u => u.chatItemId === 1000) + expect(update).toBeDefined() + expect(update!.msgContent.text).toBe(fmtCustomer("Hello", "QUEUE", 1)) + }) + + test("customer edit of first message preserves !1 NEW! prefix and updates originalText", async () => { + await customer.sends("Original question") + // First message: chatItemId=500, teamItemId=1000 + mainChat.updatedChatItems = [] + + // Simulate edit event + await bot.onChatItemUpdated({ + chatItem: { + chatInfo: {type: "group", groupInfo: businessGroupInfo()}, + chatItem: { + chatDir: {type: "groupRcv", groupMember: {memberId: CUSTOMER_ID, groupMemberId: 10}}, + meta: {itemId: 500}, + content: {type: "text", text: "Edited question"}, + _text: "Edited question", + }, + }, + } as any) + + expect(mainChat.updatedChatItems.length).toBe(1) + expect(mainChat.updatedChatItems[0].chatItemId).toBe(1000) + // Edit should preserve !1 NEW! prefix (stored header is for #1 QUEUE) + expect(mainChat.updatedChatItems[0].msgContent.text).toBe(fmtNewCustomer("Edited question", "QUEUE", 1)) + + // originalText should be updated to the clean version + const newEntry = (bot as any).newItems.get(GROUP_ID) + expect(newEntry).toBeDefined() + expect(newEntry.originalText).toBe(fmtCustomer("Edited question", "QUEUE", 1)) + }) + + test("/grok as first message — no *NEW:* created", async () => { + mainChat.setNextGroupMemberId(60) + lastGrokMemberGId = 60 + grokApi.willRespond("Hello!") + + const p = customer.sends("/grok") + mainChat.setGroupMembers(GROUP_ID, [ + {groupMemberId: 60, memberContactId: 4, memberStatus: "connected"}, + ]) + await grokAgent.joins() + await p + + // No *NEW:* entry created + expect((bot as any).newItems.has(GROUP_ID)).toBe(false) + }) + + test("/team as first message — no *NEW:* created", async () => { + mainChat.setGroupMembers(GROUP_ID, []) + await customer.sends("/team") + + // No *NEW:* entry created + expect((bot as any).newItems.has(GROUP_ID)).toBe(false) + }) + + test("24h expiry — removeNewPrefix skips edit for old entries", async () => { + await customer.sends("Hello") + // First message: chatItemId=500, teamItemId=1000 + mainChat.updatedChatItems = [] + + // Manually age the entry to > 24h + const entry = (bot as any).newItems.get(GROUP_ID) + entry.timestamp = Date.now() - 25 * 60 * 60 * 1000 + + // Trigger removeNewPrefix via /team + mainChat.setGroupMembers(GROUP_ID, []) + await customer.sends("/team") + + // newItems should be cleared + expect((bot as any).newItems.has(GROUP_ID)).toBe(false) + // But no edit should have been made (expired) + const update = mainChat.updatedChatItems.find(u => u.chatItemId === 1000) + expect(update).toBeUndefined() + }) + + test("customer leaves — newItems cleaned up", async () => { + await customer.sends("Hello") + expect((bot as any).newItems.has(GROUP_ID)).toBe(true) + + await customer.leaves() + + expect((bot as any).newItems.has(GROUP_ID)).toBe(false) + }) + + test("persistence — restoreNewItems prunes expired entries", () => { + const now = Date.now() + const fresh = {teamItemId: 100, timestamp: now - 1000, originalText: "fresh"} + const expired = {teamItemId: 200, timestamp: now - 25 * 60 * 60 * 1000, originalText: "old"} + + bot.restoreNewItems([ + [GROUP_ID, fresh], + [300, expired], + ]) + + expect((bot as any).newItems.has(GROUP_ID)).toBe(true) + expect((bot as any).newItems.has(300)).toBe(false) + expect((bot as any).newItems.size).toBe(1) + }) + + test("multiple groups — independent tracking", async () => { + const GROUP_A = 100 + const GROUP_B = 300 + + // Group A: first customer message + const ciA = customerChatItem("Question A", null) + ciA.chatInfo.groupInfo = businessGroupInfo(GROUP_A, "Alice") + mainChat.chatItems.set(GROUP_A, [{ + chatDir: {type: "groupRcv", groupMember: {memberId: CUSTOMER_ID, groupMemberId: 10}}, + _text: "Question A", + }]) + await bot.onNewChatItems({chatItems: [ciA]} as any) + + // Group B: first customer message + const ciB = customerChatItem("Question B", null) + ciB.chatInfo.groupInfo = businessGroupInfo(GROUP_B, "Charlie") + ciB.chatItem.chatDir.groupMember.memberId = CUSTOMER_ID + mainChat.chatItems.set(GROUP_B, [{ + chatDir: {type: "groupRcv", groupMember: {memberId: CUSTOMER_ID, groupMemberId: 10}}, + _text: "Question B", + }]) + await bot.onNewChatItems({chatItems: [ciB]} as any) + + // Both groups should have newItems entries + expect((bot as any).newItems.has(GROUP_A)).toBe(true) + expect((bot as any).newItems.has(GROUP_B)).toBe(true) + + // Claim Group A via /team — only removes A's *NEW:* + mainChat.setGroupMembers(GROUP_A, []) + mainChat.updatedChatItems = [] + const teamCi = customerChatItem("/team", "team") + teamCi.chatInfo.groupInfo = businessGroupInfo(GROUP_A, "Alice") + mainChat.chatItems.get(GROUP_A)!.push({ + chatDir: {type: "groupRcv", groupMember: {memberId: CUSTOMER_ID, groupMemberId: 10}}, + _text: "/team", + _botCommand: "team", + }) + await bot.onNewChatItems({chatItems: [teamCi]} as any) + + expect((bot as any).newItems.has(GROUP_A)).toBe(false) + expect((bot as any).newItems.has(GROUP_B)).toBe(true) + }) + + test("onNewItemsChanged fires on first message", async () => { + const callback = vi.fn() + bot.onNewItemsChanged = callback + + await customer.sends("Hello") + + expect(callback).toHaveBeenCalled() + const lastCallArg = callback.mock.calls[callback.mock.calls.length - 1][0] + expect(lastCallArg.has(GROUP_ID)).toBe(true) + }) + + test("onNewItemsChanged fires on removal", async () => { + await customer.sends("Hello") + const callback = vi.fn() + bot.onNewItemsChanged = callback + + mainChat.setGroupMembers(GROUP_ID, []) + await customer.sends("/team") + + expect(callback).toHaveBeenCalled() + const lastCallArg = callback.mock.calls[callback.mock.calls.length - 1][0] + expect(lastCallArg.has(GROUP_ID)).toBe(false) + }) +}) + + +// ─── 29. Direct Message Reply ────────────────────────────────── + +describe("Direct Message Reply", () => { + + test("direct message → replies with business address redirect", async () => { + bot.businessAddress = "https://simplex.chat/contact#abc123" + + const ci = { + chatInfo: {type: "direct", contact: {contactId: 99}}, + chatItem: { + chatDir: {type: "directRcv"}, + meta: {itemId: 900}, + content: {type: "text", text: "Hello, I have a question"}, + _text: "Hello, I have a question", + }, + } as any + await bot.onNewChatItems({chatItems: [ci]} as any) + + const reply = mainChat.sent.find(m => m.chat[0] === "direct" && m.chat[1] === 99) + expect(reply).toBeDefined() + expect(reply!.text).toBe( + "I can't answer your questions on non-business address, please add me through my business address: https://simplex.chat/contact#abc123" + ) + }) + + test("direct message without business address → no reply", async () => { + bot.businessAddress = null + + const ci = { + chatInfo: {type: "direct", contact: {contactId: 99}}, + chatItem: { + chatDir: {type: "directRcv"}, + meta: {itemId: 901}, + content: {type: "text", text: "Hello"}, + _text: "Hello", + }, + } as any + await bot.onNewChatItems({chatItems: [ci]} as any) + + const reply = mainChat.sent.find(m => m.chat[0] === "direct" && m.chat[1] === 99) + expect(reply).toBeUndefined() + }) + + test("direct message does not get forwarded to team group", async () => { + bot.businessAddress = "https://simplex.chat/contact#abc123" + + const ci = { + chatInfo: {type: "direct", contact: {contactId: 99}}, + chatItem: { + chatDir: {type: "directRcv"}, + meta: {itemId: 902}, + content: {type: "text", text: "Some question"}, + _text: "Some question", + }, + } as any + await bot.onNewChatItems({chatItems: [ci]} as any) + + teamGroup.receivedNothing() + }) +}) + + +// ─── 30. /inviteall & /invitenew Commands ──────────────────────── + +function teamGroupCommand(text: string, senderContactId = 2) { + return { + chatInfo: {type: "group", groupInfo: {groupId: TEAM_GRP_ID, groupProfile: {displayName: "SupportTeam"}}}, + chatItem: { + chatDir: { + type: "groupRcv", + groupMember: {memberId: "tm-1", groupMemberId: 30, memberContactId: senderContactId, memberProfile: {displayName: "Bob"}}, + }, + meta: {itemId: nextChatItemId++}, + content: {type: "text", text}, + _text: text, + }, + } as any +} + +describe("/inviteall & /invitenew Commands", () => { + const GROUP_A = 300 + const GROUP_B = 301 + const GROUP_C = 302 + + function setGroupLastActive(groups: [number, number][]) { + bot.restoreGroupLastActive(groups) + } + + test("/inviteall invites sender to groups active within 24h", async () => { + const now = Date.now() + // Group A active 1h ago, Group B active 2h ago — both within 24h + setGroupLastActive([[GROUP_A, now - 1 * 60 * 60 * 1000], [GROUP_B, now - 2 * 60 * 60 * 1000]]) + mainChat.setGroupMembers(GROUP_A, []) + mainChat.setGroupMembers(GROUP_B, []) + + await bot.onNewChatItems({chatItems: [teamGroupCommand("/inviteall")]} as any) + + const addedA = mainChat.added.find(a => a.groupId === GROUP_A && a.contactId === 2) + const addedB = mainChat.added.find(a => a.groupId === GROUP_B && a.contactId === 2) + expect(addedA).toBeDefined() + expect(addedB).toBeDefined() + }) + + test("/inviteall skips groups with last activity older than 24h", async () => { + const now = Date.now() + // Group A active 25h ago — outside 24h window + setGroupLastActive([[GROUP_A, now - 25 * 60 * 60 * 1000]]) + mainChat.setGroupMembers(GROUP_A, []) + + await bot.onNewChatItems({chatItems: [teamGroupCommand("/inviteall")]} as any) + + const addedA = mainChat.added.find(a => a.groupId === GROUP_A) + expect(addedA).toBeUndefined() + }) + + test("/inviteall skips groups where sender is already a member", async () => { + const now = Date.now() + setGroupLastActive([[GROUP_A, now - 1 * 60 * 60 * 1000]]) + // Sender (contactId=2) already in group A + mainChat.setGroupMembers(GROUP_A, [ + {groupMemberId: 70, memberContactId: 2, memberStatus: "connected"}, + ]) + + await bot.onNewChatItems({chatItems: [teamGroupCommand("/inviteall")]} as any) + + const addedA = mainChat.added.find(a => a.groupId === GROUP_A && a.contactId === 2) + expect(addedA).toBeUndefined() + }) + + test("/inviteall sends summary to team group", async () => { + const now = Date.now() + setGroupLastActive([[GROUP_A, now - 1 * 60 * 60 * 1000]]) + mainChat.setGroupMembers(GROUP_A, []) + + await bot.onNewChatItems({chatItems: [teamGroupCommand("/inviteall")]} as any) + + const teamMsgs = mainChat.sentTo(TEAM_GRP_ID) + const summary = teamMsgs.find(m => m.includes("Invited to") && m.includes("active in 24h")) + expect(summary).toBeDefined() + }) + + test("/invitenew invites sender only to groups with no grok and no team", async () => { + const now = Date.now() + // Group A: no special members, Group B: has team, Group C: has grok + setGroupLastActive([ + [GROUP_A, now - 1 * 60 * 60 * 1000], + [GROUP_B, now - 1 * 60 * 60 * 1000], + [GROUP_C, now - 1 * 60 * 60 * 1000], + ]) + mainChat.setGroupMembers(GROUP_A, []) + mainChat.setGroupMembers(GROUP_B, [ + {groupMemberId: 71, memberContactId: 2, memberStatus: "connected"}, + ]) + mainChat.setGroupMembers(GROUP_C, [ + {groupMemberId: 72, memberContactId: 4, memberStatus: "connected"}, + ]) + + await bot.onNewChatItems({chatItems: [teamGroupCommand("/invitenew")]} as any) + + const addedA = mainChat.added.find(a => a.groupId === GROUP_A && a.contactId === 2) + expect(addedA).toBeDefined() + // B and C should NOT be invited (filtered by composition, not by already-member check) + const addedB = mainChat.added.find(a => a.groupId === GROUP_B && a.contactId === 2) + const addedC = mainChat.added.find(a => a.groupId === GROUP_C && a.contactId === 2) + expect(addedB).toBeUndefined() + expect(addedC).toBeUndefined() + }) + + test("/invitenew skips groups with grok member", async () => { + const now = Date.now() + setGroupLastActive([[GROUP_A, now - 1 * 60 * 60 * 1000]]) + mainChat.setGroupMembers(GROUP_A, [ + {groupMemberId: 72, memberContactId: 4, memberStatus: "connected"}, + ]) + + await bot.onNewChatItems({chatItems: [teamGroupCommand("/invitenew")]} as any) + + const addedA = mainChat.added.find(a => a.groupId === GROUP_A) + expect(addedA).toBeUndefined() + }) + + test("/invitenew skips groups with team member", async () => { + const now = Date.now() + setGroupLastActive([[GROUP_A, now - 1 * 60 * 60 * 1000]]) + // Team member contactId=2 already in group as a member (not the sender checking membership — + // this is the composition check) + mainChat.setGroupMembers(GROUP_A, [ + {groupMemberId: 71, memberContactId: 2, memberStatus: "connected"}, + ]) + + await bot.onNewChatItems({chatItems: [teamGroupCommand("/invitenew")]} as any) + + const addedA = mainChat.added.find(a => a.groupId === GROUP_A) + expect(addedA).toBeUndefined() + }) + + test("/invitenew skips groups with last activity older than 48h", async () => { + const now = Date.now() + setGroupLastActive([[GROUP_A, now - 49 * 60 * 60 * 1000]]) + mainChat.setGroupMembers(GROUP_A, []) + + await bot.onNewChatItems({chatItems: [teamGroupCommand("/invitenew")]} as any) + + const addedA = mainChat.added.find(a => a.groupId === GROUP_A) + expect(addedA).toBeUndefined() + }) + + test("/inviteall removes !1 NEW! prefix on invited groups", async () => { + const now = Date.now() + setGroupLastActive([[GROUP_A, now - 1 * 60 * 60 * 1000]]) + mainChat.setGroupMembers(GROUP_A, []) + + // First, create a NEW item for GROUP_A by simulating first customer message + mainChat.setChatItems(GROUP_A, [{chatDir: {type: "groupSnd"}, _text: "Welcome!"}]) + const ci = customerChatItem("Help me", null) + ci.chatInfo.groupInfo = businessGroupInfo(GROUP_A, "TestUser") + mainChat.chatItems.get(GROUP_A)!.push({ + chatDir: {type: "groupRcv", groupMember: {memberId: CUSTOMER_ID, groupMemberId: 10}}, + _text: "Help me", + }) + await bot.onNewChatItems({chatItems: [ci]} as any) + + // Verify !1 NEW! prefix was set + const newMsgs = mainChat.sentTo(TEAM_GRP_ID).filter(m => m.startsWith("!1 NEW!")) + expect(newMsgs.length).toBe(1) + + mainChat.updatedChatItems = [] + await bot.onNewChatItems({chatItems: [teamGroupCommand("/inviteall")]} as any) + + // NEW prefix should have been removed (apiUpdateChatItem called) + expect(mainChat.updatedChatItems.length).toBeGreaterThan(0) + const update = mainChat.updatedChatItems.find(u => u.chatId === TEAM_GRP_ID) + expect(update).toBeDefined() + expect(update!.msgContent.text).not.toContain("!1 NEW!") + }) + + test("groupLastActive updated on every customer text message", async () => { + const callback = vi.fn() + bot.onGroupLastActiveChanged = callback + + await customer.sends("Hello") + expect(callback).toHaveBeenCalledTimes(1) + + await customer.sends("Follow up") + expect(callback).toHaveBeenCalledTimes(2) + }) + + test("groupLastActive NOT updated on non-text events", async () => { + const callback = vi.fn() + bot.onGroupLastActiveChanged = callback + + await customer.sendsNonText() + + expect(callback).not.toHaveBeenCalled() + }) + + test("groupLastActive NOT updated on command-only messages (/team)", async () => { + // Reach teamQueue first so /team doesn't trigger welcome flow + await reachTeamQueue("Hello") + const callback = vi.fn() + bot.onGroupLastActiveChanged = callback + + // /team command should not count as customer text activity + mainChat.setGroupMembers(GROUP_ID, []) + await customer.sends("/team") + + expect(callback).not.toHaveBeenCalled() + }) + + test("groupLastActive cleaned up on customer leave", async () => { + const callback = vi.fn() + bot.onGroupLastActiveChanged = callback + + await customer.sends("Hello") + expect(callback).toHaveBeenCalledTimes(1) + + await customer.leaves() + // Called again on leave (deletion) + expect(callback).toHaveBeenCalledTimes(2) + }) + + test("restoreGroupLastActive prunes entries older than 48h", async () => { + const now = Date.now() + const entries: [number, number][] = [ + [GROUP_A, now - 1 * 60 * 60 * 1000], // 1h ago — kept + [GROUP_B, now - 49 * 60 * 60 * 1000], // 49h ago — pruned + [GROUP_C, now - 47 * 60 * 60 * 1000], // 47h ago — kept + ] + + const freshBot = new SupportBot(mainChat as any, grokChat as any, grokApi as any, config as any) + freshBot.restoreGroupLastActive(entries) + + // Verify via /inviteall (24h window): only GROUP_A qualifies + mainChat.setGroupMembers(GROUP_A, []) + mainChat.setGroupMembers(GROUP_B, []) + mainChat.setGroupMembers(GROUP_C, []) + mainChat.added = [] + + await freshBot.onNewChatItems({chatItems: [teamGroupCommand("/inviteall")]} as any) + + // GROUP_A (1h ago) → within 24h → invited + const addedA = mainChat.added.find(a => a.groupId === GROUP_A) + expect(addedA).toBeDefined() + // GROUP_B (49h ago) → pruned at restore → not invited + const addedB = mainChat.added.find(a => a.groupId === GROUP_B) + expect(addedB).toBeUndefined() + // GROUP_C (47h ago) → restored but outside 24h → not invited by inviteall + const addedC = mainChat.added.find(a => a.groupId === GROUP_C) + expect(addedC).toBeUndefined() + }) +}) + + +// ─── 31. Welcome Flow Deduplication ──────────────────────────── + +describe("Welcome Flow Deduplication", () => { + + test("teamQueueMessage not re-sent when chat history overflows past 20 items", async () => { + // First message → welcome flow: teamQueueMessage sent + await customer.sends("Hello") + customer.received(TEAM_QUEUE_24H) + + // Simulate long Grok conversation: clear chat items so "forwarded to the team" + // is no longer in history (as if it scrolled past the 20-item window) + mainChat.chatItems.set(GROUP_ID, []) + mainChat.sent = [] + + // Next customer message should NOT trigger teamQueueMessage again + await customer.sends("Follow-up question") + + // Message forwarded to team (normal), but NO teamQueueMessage re-sent + teamGroup.received(fmtCustomer("Follow-up question", "QUEUE", 2)) + const teamQueueMsgs = mainChat.sentTo(GROUP_ID).filter(m => m.includes("forwarded to the team")) + expect(teamQueueMsgs.length).toBe(0) + }) + + test("welcomeCompleted cache cleared on customer leave — new customer gets welcome", async () => { + // First customer triggers welcome + await customer.sends("Hello") + customer.received(TEAM_QUEUE_24H) + + // Customer leaves → cache cleared + await customer.leaves() + + // Clear sent history for clean assertions + mainChat.sent = [] + mainChat.chatItems.set(GROUP_ID, []) + + // New customer in same group → welcome flow should trigger again + await customer.sends("New question") + customer.received(TEAM_QUEUE_24H) + }) + + test("second message in same session never re-sends teamQueueMessage", async () => { + await customer.sends("First question") + mainChat.sent = [] + + await customer.sends("Second question") + + // Only the forwarded message, no teamQueueMessage + const customerMsgs = mainChat.sentTo(GROUP_ID) + expect(customerMsgs.filter(m => m.includes("forwarded to the team")).length).toBe(0) + }) +}) + + +// ─── 32. A1: Reply-to-last Threading ────────────────────────────── + +describe("A1: Reply-to-last Threading", () => { + + test("first customer message in new group has no inReplyTo (no prior team item)", async () => { + await customer.sends("Hello") + + const fwdMsg = mainChat.sent.find(m => + m.chat[1] === TEAM_GRP_ID && m.text.includes("Hello")) + expect(fwdMsg).toBeDefined() + expect(fwdMsg!.inReplyTo).toBeUndefined() + }) + + test("second customer message auto-threads to last team item", async () => { + await reachTeamQueue("Hello") + // Hello's teamItemId = 1000 + mainChat.sent = [] + + await customer.sends("Follow-up") + + const fwdMsg = mainChat.sent.find(m => + m.chat[1] === TEAM_GRP_ID && m.text.includes("Follow-up")) + expect(fwdMsg).toBeDefined() + // A1: threads to 1000 (last team item for this group) + expect(fwdMsg!.inReplyTo).toBe(1000) + }) + + test("third message threads to the second message's team item, not the first", async () => { + await reachTeamQueue("Hello") + // Hello teamItemId=1000. After reachTeamQueue: nextItemId=1003 (1001=queue msg, 1002=/add) + await customer.sends("Second msg") + // Second msg teamItemId = 1003 + mainChat.sent = [] + + await customer.sends("Third msg") + + const fwdMsg = mainChat.sent.find(m => + m.chat[1] === TEAM_GRP_ID && m.text.includes("Third msg")) + expect(fwdMsg).toBeDefined() + // A1: threads to 1003 (last team item after second message) + expect(fwdMsg!.inReplyTo).toBe(1003) + }) + + test("explicit reply-to takes precedence over auto-threading", async () => { + await reachTeamQueue("Hello") + // Hello chatItemId=500 → teamItemId=1000. nextItemId=1003. + await customer.sends("Second msg") + // Second chatItemId=501 → teamItemId=1003 (lastTeamItemByGroup=1003) + mainChat.sent = [] + + // Reply to the original "Hello" (chatItemId=500 → teamItemId=1000) + await customer.sendsReplyTo("Reply to hello", 500) + + const fwdMsg = mainChat.sent.find(m => + m.chat[1] === TEAM_GRP_ID && m.text.includes("Reply to hello")) + expect(fwdMsg).toBeDefined() + // Explicit reply-to (1000) takes precedence over auto-thread (1003) + expect(fwdMsg!.inReplyTo).toBe(1000) + }) + + test("team member message also updates lastTeamItemByGroup", async () => { + await reachTeamPending() + // Hello teamItemId=1000. /team didn't forward. + await teamMember.sends("I'll help") + // Team member's teamItemId = 1004 + mainChat.sent = [] + + await customer.sends("Thanks!") + + const fwdMsg = mainChat.sent.find(m => + m.chat[1] === TEAM_GRP_ID && m.text.includes("Thanks!")) + expect(fwdMsg).toBeDefined() + // A1: threads to 1004 (team member's forwarded item) + expect(fwdMsg!.inReplyTo).toBe(1004) + }) + + test("grok response also updates lastTeamItemByGroup", async () => { + await reachGrokMode("Grok answer") + // Hello teamItemId=1000. After reachTeamQueue: nextItemId=1003. Grok activated msg=1003. + // activateGrok: Grok response forwarded → teamItemId=1004 + mainChat.chatItems.get(GROUP_ID)!.push({ + chatDir: {type: "groupRcv", groupMember: {memberId: "grok-1", groupMemberId: 60, memberContactId: 4}}, + _text: "Grok answer", + }) + grokApi.willRespond("More answer") + mainChat.sent = [] + + await customer.sends("Follow-up") + + const custFwd = mainChat.sent.find(m => + m.chat[1] === TEAM_GRP_ID && m.text.includes("Follow-up")) + expect(custFwd).toBeDefined() + // Customer follow-up should thread to grok response's team item (1004) + expect(custFwd!.inReplyTo).toBe(1004) + }) + + test("customer leave clears lastTeamItemByGroup for that group", async () => { + await reachTeamQueue("Hello") + expect((bot as any).lastTeamItemByGroup.has(GROUP_ID)).toBe(true) + + await customer.leaves() + + expect((bot as any).lastTeamItemByGroup.has(GROUP_ID)).toBe(false) + }) + + test("customer leave clears forwardedItems for that group", async () => { + await reachTeamQueue("Hello") + // After reachTeamQueue, forwardedItems has entry for "100:500" (Hello chatItemId=500) + expect((bot as any).forwardedItems.size).toBeGreaterThan(0) + const hasGroupEntry = [...(bot as any).forwardedItems.keys()].some((k: string) => k.startsWith(`${GROUP_ID}:`)) + expect(hasGroupEntry).toBe(true) + + await customer.leaves() + + const hasGroupEntryAfter = [...(bot as any).forwardedItems.keys()].some((k: string) => k.startsWith(`${GROUP_ID}:`)) + expect(hasGroupEntryAfter).toBe(false) + }) +}) + + +// ─── 33. A6: Non-Text Content Indicators ────────────────────────── + +describe("A6: Non-Text Content Indicators", () => { + + test("image message → _[image]_ indicator in team forward", async () => { + // First message to get past welcome + await reachTeamQueue("Hello") + mainChat.sent = [] + + // Send image with caption + const ci = { + chatInfo: {type: "group", groupInfo: businessGroupInfo()}, + chatItem: { + chatDir: {type: "groupRcv", groupMember: {memberId: CUSTOMER_ID, groupMemberId: 10}}, + meta: {itemId: nextChatItemId++}, + content: {type: "rcvMsgContent", msgContent: {type: "image", text: "check this"}}, + _text: "check this", + }, + } as any + mainChat.chatItems.get(GROUP_ID)!.push({ + chatDir: {type: "groupRcv", groupMember: {memberId: CUSTOMER_ID, groupMemberId: 10}}, + _text: "check this", + }) + await bot.onNewChatItems({chatItems: [ci]} as any) + + const teamMsgs = mainChat.sentTo(TEAM_GRP_ID) + const fwd = teamMsgs.find(m => m.includes("_[image]_") && m.includes("check this")) + expect(fwd).toBeDefined() + }) + + test("file message without caption → _[file]_ only", async () => { + await reachTeamQueue("Hello") + mainChat.sent = [] + + const ci = { + chatInfo: {type: "group", groupInfo: businessGroupInfo()}, + chatItem: { + chatDir: {type: "groupRcv", groupMember: {memberId: CUSTOMER_ID, groupMemberId: 10}}, + meta: {itemId: nextChatItemId++}, + content: {type: "rcvMsgContent", msgContent: {type: "file", text: ""}}, + _text: null, + }, + } as any + mainChat.chatItems.get(GROUP_ID)!.push({ + chatDir: {type: "groupRcv", groupMember: {memberId: CUSTOMER_ID, groupMemberId: 10}}, + _text: null, + }) + await bot.onNewChatItems({chatItems: [ci]} as any) + + const teamMsgs = mainChat.sentTo(TEAM_GRP_ID) + const fwd = teamMsgs.find(m => m.includes("_[file]_")) + expect(fwd).toBeDefined() + }) + + test("voice message → _[voice]_ indicator", async () => { + await reachTeamQueue("Hello") + mainChat.sent = [] + + const ci = { + chatInfo: {type: "group", groupInfo: businessGroupInfo()}, + chatItem: { + chatDir: {type: "groupRcv", groupMember: {memberId: CUSTOMER_ID, groupMemberId: 10}}, + meta: {itemId: nextChatItemId++}, + content: {type: "rcvMsgContent", msgContent: {type: "voice", text: "", duration: 5}}, + _text: null, + }, + } as any + mainChat.chatItems.get(GROUP_ID)!.push({ + chatDir: {type: "groupRcv", groupMember: {memberId: CUSTOMER_ID, groupMemberId: 10}}, + _text: null, + }) + await bot.onNewChatItems({chatItems: [ci]} as any) + + const teamMsgs = mainChat.sentTo(TEAM_GRP_ID) + const fwd = teamMsgs.find(m => m.includes("_[voice]_")) + expect(fwd).toBeDefined() + }) + + test("video message with caption → _[video]_ caption", async () => { + await reachTeamQueue("Hello") + mainChat.sent = [] + + const ci = { + chatInfo: {type: "group", groupInfo: businessGroupInfo()}, + chatItem: { + chatDir: {type: "groupRcv", groupMember: {memberId: CUSTOMER_ID, groupMemberId: 10}}, + meta: {itemId: nextChatItemId++}, + content: {type: "rcvMsgContent", msgContent: {type: "video", text: "my screen recording"}}, + _text: "my screen recording", + }, + } as any + mainChat.chatItems.get(GROUP_ID)!.push({ + chatDir: {type: "groupRcv", groupMember: {memberId: CUSTOMER_ID, groupMemberId: 10}}, + _text: "my screen recording", + }) + await bot.onNewChatItems({chatItems: [ci]} as any) + + const teamMsgs = mainChat.sentTo(TEAM_GRP_ID) + const fwd = teamMsgs.find(m => m.includes("_[video]_") && m.includes("my screen recording")) + expect(fwd).toBeDefined() + }) + + test("regular text message has no content type indicator", async () => { + await reachTeamQueue("Hello") + mainChat.sent = [] + + await customer.sends("Just text") + + const teamMsgs = mainChat.sentTo(TEAM_GRP_ID) + const fwd = teamMsgs.find(m => m.includes("Just text")) + expect(fwd).toBeDefined() + expect(fwd).not.toContain("_[") + }) +}) + + +// ─── 34. D1: /pending Command ───────────────────────────────────── + +describe("D1: /pending Command", () => { + + test("/pending with no active groups → 'No pending conversations.'", async () => { + mainChat.sent = [] + + await bot.onNewChatItems({chatItems: [teamGroupCommand("/pending")]} as any) + + const teamMsgs = mainChat.sentTo(TEAM_GRP_ID) + expect(teamMsgs).toContain("No pending conversations.") + }) + + test("/pending with customer message (no grok/team reply) → listed as pending", async () => { + await customer.sends("Help me") + mainChat.sent = [] + + await bot.onNewChatItems({chatItems: [teamGroupCommand("/pending")]} as any) + + const teamMsgs = mainChat.sentTo(TEAM_GRP_ID) + const pendingMsg = teamMsgs.find(m => m.includes("*Pending")) + expect(pendingMsg).toBeDefined() + expect(pendingMsg).toContain(`${GROUP_ID}:Alice`) + expect(pendingMsg).toContain("QUEUE") + }) + + test("/pending: grok response makes group not pending", async () => { + await reachGrokMode("Grok answer") + // After Grok answer, last event is from grok → not pending + mainChat.sent = [] + + await bot.onNewChatItems({chatItems: [teamGroupCommand("/pending")]} as any) + + const teamMsgs = mainChat.sentTo(TEAM_GRP_ID) + expect(teamMsgs).toContain("No pending conversations.") + }) + + test("/pending: team member response makes group not pending", async () => { + await reachTeamLocked() + // After team member msg, last event is from team → not pending + mainChat.sent = [] + + await bot.onNewChatItems({chatItems: [teamGroupCommand("/pending")]} as any) + + const teamMsgs = mainChat.sentTo(TEAM_GRP_ID) + expect(teamMsgs).toContain("No pending conversations.") + }) + + test("/pending: customer message after grok → pending again", async () => { + await reachGrokMode("Grok answer") + // Grok answered → not pending + // Customer sends follow-up in grok mode + mainChat.chatItems.get(GROUP_ID)!.push({ + chatDir: {type: "groupRcv", groupMember: {memberId: "grok-1", groupMemberId: 60, memberContactId: 4}}, + _text: "Grok answer", + }) + grokApi.willRespond("Follow-up answer") + await customer.sends("More questions") + // Customer message updates pending to "customer" → but then Grok responds, updating to "grok" + // So after this, last event is from grok (the follow-up answer) + mainChat.sent = [] + + await bot.onNewChatItems({chatItems: [teamGroupCommand("/pending")]} as any) + + const teamMsgs = mainChat.sentTo(TEAM_GRP_ID) + // Grok responded last, so not pending + expect(teamMsgs).toContain("No pending conversations.") + }) + + test("/pending: customer reaction while last message is from team → not pending", async () => { + await reachTeamLocked() + // Team member sent last message → not pending + // Now customer reacts + await bot.onChatItemReaction({ + added: true, + reaction: { + chatInfo: {type: "group", groupInfo: businessGroupInfo()}, + chatReaction: { + chatDir: {type: "groupRcv", groupMember: {memberId: CUSTOMER_ID, groupMemberId: 10}}, + chatItem: {meta: {itemId: 500}}, + sentAt: new Date().toISOString(), + reaction: {type: "emoji", emoji: "👍"}, + }, + }, + } as any) + mainChat.sent = [] + + await bot.onNewChatItems({chatItems: [teamGroupCommand("/pending")]} as any) + + const teamMsgs = mainChat.sentTo(TEAM_GRP_ID) + // Customer reaction, but last message was from team → not pending + expect(teamMsgs).toContain("No pending conversations.") + }) + + test("/pending: team reaction makes group not pending", async () => { + await customer.sends("Need help") + // Customer msg → pending + // Team member reacts + await bot.onChatItemReaction({ + added: true, + reaction: { + chatInfo: {type: "group", groupInfo: businessGroupInfo()}, + chatReaction: { + chatDir: {type: "groupRcv", groupMember: {memberId: "team-member-1", groupMemberId: 50, memberContactId: 2}}, + chatItem: {meta: {itemId: 500}}, + sentAt: new Date().toISOString(), + reaction: {type: "emoji", emoji: "👍"}, + }, + }, + } as any) + mainChat.sent = [] + + await bot.onNewChatItems({chatItems: [teamGroupCommand("/pending")]} as any) + + const teamMsgs = mainChat.sentTo(TEAM_GRP_ID) + // Team reacted → not pending + expect(teamMsgs).toContain("No pending conversations.") + }) + + test("/pending: customer reaction while last message is from customer → still pending", async () => { + await customer.sends("Help me") + // Customer msg → pending (last event: customer message) + // Customer reacts (last event: customer reaction, last message: customer) + await bot.onChatItemReaction({ + added: true, + reaction: { + chatInfo: {type: "group", groupInfo: businessGroupInfo()}, + chatReaction: { + chatDir: {type: "groupRcv", groupMember: {memberId: CUSTOMER_ID, groupMemberId: 10}}, + chatItem: {meta: {itemId: 500}}, + sentAt: new Date().toISOString(), + reaction: {type: "emoji", emoji: "👍"}, + }, + }, + } as any) + mainChat.sent = [] + + await bot.onNewChatItems({chatItems: [teamGroupCommand("/pending")]} as any) + + const teamMsgs = mainChat.sentTo(TEAM_GRP_ID) + // Customer reaction AND last message was from customer → still pending + const pendingMsg = teamMsgs.find(m => m.includes("*Pending")) + expect(pendingMsg).toBeDefined() + expect(pendingMsg).toContain(`${GROUP_ID}:Alice`) + }) + + test("/pending: non-business-chat group reaction → ignored", async () => { + // Reaction in non-business group should not crash + await bot.onChatItemReaction({ + added: true, + reaction: { + chatInfo: {type: "group", groupInfo: {groupId: 999, groupProfile: {displayName: "X"}}}, + chatReaction: { + chatDir: {type: "groupRcv", groupMember: {memberId: "someone"}}, + chatItem: {meta: {itemId: 1}}, + sentAt: new Date().toISOString(), + reaction: {type: "emoji", emoji: "👍"}, + }, + }, + } as any) + // No crash = success + }) + + test("/pending: removed reaction (added=false) → ignored", async () => { + await customer.sends("Help me") + // Customer msg → pending + mainChat.sent = [] + + // Team removes reaction + await bot.onChatItemReaction({ + added: false, + reaction: { + chatInfo: {type: "group", groupInfo: businessGroupInfo()}, + chatReaction: { + chatDir: {type: "groupRcv", groupMember: {memberId: "team-member-1", groupMemberId: 50, memberContactId: 2}}, + chatItem: {meta: {itemId: 500}}, + sentAt: new Date().toISOString(), + reaction: {type: "emoji", emoji: "👍"}, + }, + }, + } as any) + + await bot.onNewChatItems({chatItems: [teamGroupCommand("/pending")]} as any) + + const teamMsgs = mainChat.sentTo(TEAM_GRP_ID) + // Removed reaction should be ignored → still pending (customer msg was last real event) + const pendingMsg = teamMsgs.find(m => m.includes("*Pending")) + expect(pendingMsg).toBeDefined() + }) + + test("/pending: group with no pending info but with lastActive → listed as pending", async () => { + // Simulate a group that has lastActive but no pendingInfo (e.g., after restart) + bot.restoreGroupLastActive([[GROUP_ID, Date.now()]]) + mainChat.sent = [] + + await bot.onNewChatItems({chatItems: [teamGroupCommand("/pending")]} as any) + + const teamMsgs = mainChat.sentTo(TEAM_GRP_ID) + const pendingMsg = teamMsgs.find(m => m.includes("*Pending")) + expect(pendingMsg).toBeDefined() + }) + + test("groupPendingInfo cleaned up on customer leave", async () => { + await customer.sends("Hello") + expect((bot as any).groupPendingInfo.has(GROUP_ID)).toBe(true) + + await customer.leaves() + + expect((bot as any).groupPendingInfo.has(GROUP_ID)).toBe(false) + }) + + test("groupMetadata cleaned up on customer leave", async () => { + await customer.sends("Hello") + expect((bot as any).groupMetadata.has(GROUP_ID)).toBe(true) + + await customer.leaves() + + expect((bot as any).groupMetadata.has(GROUP_ID)).toBe(false) + }) + + test("restoreGroupMetadata works", () => { + const meta = {firstContact: 1000000, msgCount: 5, customerName: "Test"} + bot.restoreGroupMetadata([[GROUP_ID, meta]]) + + expect((bot as any).groupMetadata.get(GROUP_ID)).toEqual(meta) + }) + + test("restoreGroupPendingInfo works", () => { + const info = {lastEventType: "message" as const, lastEventFrom: "customer" as const, lastEventTimestamp: Date.now(), lastMessageFrom: "customer" as const} + bot.restoreGroupPendingInfo([[GROUP_ID, info]]) + + expect((bot as any).groupPendingInfo.get(GROUP_ID)).toEqual(info) + }) + + test("onGroupMetadataChanged fires on customer message", async () => { + const callback = vi.fn() + bot.onGroupMetadataChanged = callback + + await customer.sends("Hello") + + expect(callback).toHaveBeenCalled() + }) + + test("onGroupPendingInfoChanged fires on customer message", async () => { + const callback = vi.fn() + bot.onGroupPendingInfoChanged = callback + + await customer.sends("Hello") + + expect(callback).toHaveBeenCalled() + }) +}) + + +// ─── 35. Welcome Flow After Command-First Interaction ────────── + +describe("Welcome Flow After Command-First Interaction", () => { + afterEach(() => vi.useRealTimers()) + + test("/grok as first command then text → no duplicate welcome", async () => { + mainChat.setNextGroupMemberId(60) + lastGrokMemberGId = 60 + grokApi.willRespond("AI answer") + const p = customer.sends("/grok") + mainChat.setGroupMembers(GROUP_ID, [ + {groupMemberId: 60, memberContactId: 4, memberStatus: "connected"}, + ]) + await grokAgent.joins() + await p + + // Now customer sends text — should NOT trigger teamQueueMessage + grokApi.willRespond("Follow-up answer") + await customer.sends("Help me with something") + + const teamMsgs = mainChat.sentTo(TEAM_GRP_ID) + const hasQueueMsg = teamMsgs.some(m => m.includes("forwarded to the team")) + expect(hasQueueMsg).toBe(false) + const hasNewMarker = teamMsgs.some(m => m.includes("!1 NEW!")) + expect(hasNewMarker).toBe(false) + }) + + test("/grok timeout as first command then text → no duplicate welcome", async () => { + vi.useFakeTimers() + mainChat.setNextGroupMemberId(60) + lastGrokMemberGId = 60 + const p = customer.sends("/grok") + await grokAgent.timesOut() + await p + vi.useRealTimers() + + // Customer sends text — welcomeCompleted stays set, no duplicate welcome + await customer.sends("Hello") + + const teamMsgs = mainChat.sentTo(TEAM_GRP_ID) + const hasQueueMsg = teamMsgs.some(m => m.includes("forwarded to the team")) + expect(hasQueueMsg).toBe(false) + const hasNewMarker = teamMsgs.some(m => m.includes("!1 NEW!")) + expect(hasNewMarker).toBe(false) + }) + + test("/team as first command then text → no duplicate welcome", async () => { + mainChat.setNextGroupMemberId(50) + lastTeamMemberGId = 50 + mainChat.setGroupMembers(GROUP_ID, []) + await customer.sends("/team") + mainChat.setGroupMembers(GROUP_ID, [ + {groupMemberId: 50, memberContactId: 2, memberStatus: "connected"}, + ]) + + await customer.sends("Can you help me?") + + const teamMsgs = mainChat.sentTo(TEAM_GRP_ID) + const hasQueueMsg = teamMsgs.some(m => m.includes("forwarded to the team")) + expect(hasQueueMsg).toBe(false) + }) + + test("/team when already activated before → sets welcomeCompleted", async () => { + mainChat.setChatItems(GROUP_ID, [ + {chatDir: {type: "groupSnd"}, _text: "A team member has been added and will reply within 24 hours."}, + ]) + mainChat.setGroupMembers(GROUP_ID, []) + await customer.sends("/team") + customer.received("A team member has already been invited to this conversation and will reply when available.") + + mainChat.setGroupMembers(GROUP_ID, [ + {groupMemberId: 50, memberContactId: 2, memberStatus: "connected"}, + ]) + + await customer.sends("Still need help") + + const teamMsgs = mainChat.sentTo(TEAM_GRP_ID) + const hasQueueMsg = teamMsgs.some(m => m.includes("forwarded to the team")) + expect(hasQueueMsg).toBe(false) + }) + + test("isFirstCustomerMessage detects grokActivatedMessage in history (restart resilience)", async () => { + // Simulate post-restart: history has grokActivatedMessage but welcomeCompleted is empty + mainChat.setChatItems(GROUP_ID, [ + {chatDir: {type: "groupSnd"}, _text: "You are now chatting with Grok. You can send questions in any language."}, + ]) + mainChat.setGroupMembers(GROUP_ID, [ + {groupMemberId: 60, memberContactId: 4, memberStatus: "connected"}, + ]) + + grokApi.willRespond("answer") + await customer.sends("Hello") + + const teamMsgs = mainChat.sentTo(TEAM_GRP_ID) + const hasQueueMsg = teamMsgs.some(m => m.includes("forwarded to the team")) + expect(hasQueueMsg).toBe(false) + const hasNewMarker = teamMsgs.some(m => m.includes("!1 NEW!")) + expect(hasNewMarker).toBe(false) + }) + + test("isFirstCustomerMessage detects teamAddedMessage in history (restart resilience)", async () => { + // Simulate post-restart: history has teamAddedMessage but welcomeCompleted is empty + mainChat.setChatItems(GROUP_ID, [ + {chatDir: {type: "groupSnd"}, _text: "A team member has been added and will reply within 24 hours."}, + ]) + mainChat.setGroupMembers(GROUP_ID, [ + {groupMemberId: 50, memberContactId: 2, memberStatus: "connected"}, + ]) + + await customer.sends("Hello") + + const teamMsgs = mainChat.sentTo(TEAM_GRP_ID) + const hasQueueMsg = teamMsgs.some(m => m.includes("forwarded to the team")) + expect(hasQueueMsg).toBe(false) + const hasNewMarker = teamMsgs.some(m => m.includes("!1 NEW!")) + expect(hasNewMarker).toBe(false) + }) +}) diff --git a/apps/simplex-support-bot/docs/simplex-context.md b/apps/simplex-support-bot/docs/simplex-context.md index 83354e26fd..9b6918317f 100644 --- a/apps/simplex-support-bot/docs/simplex-context.md +++ b/apps/simplex-support-bot/docs/simplex-context.md @@ -1,5 +1,7 @@ # SimpleX Chat — Context for AI Assistant +For any suggestions sent to this support chat, they are forwarded to simplex team and noted. + ## 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. @@ -112,8 +114,26 @@ SimpleX Chat is a private and secure messaging platform. It is the first messagi - **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. +- **How to migrate to new device?** There are no traditional accounts in SimpleX Chat, your profile(s) are stored on your device only, there are 2 ways to migrate your profile(s) to another device: +Online migration +https://simplex.chat/blog/20240323-simplex-network-privacy-non-profit-v5-6-quantum-resistant-e2e-encryption-simple-migration.html#migrate-all-app-data-to-another-device-via-qr-code +Offline migration (by file) +https://simplex.chat/docs/guide/chat-profiles.html#move-your-chat-profiles-to-another-device + +- **How to link my device?** +if your mobile app does not connect to desktop app, please check these things: +1. Check that both devices are connected to the same networks (e.g., it won't work if mobile is connected to mobile Internet and desktop to WiFi). +2. If you use VPN on mobile, allow connections to local network in your VPN settings (or disable VPN). +3. Allow SimpleX Chat on desktop to accept network connections in system firewall settings. You may choose a specific port desktop app is using to accept connections, by default it uses a random port every time. +4. Check that your wifi router allows connections between devices (e.g., it may have an option for "device isolation", or similar). +5. If you see an error "certificate expired", please check that your device clocks are syncronized within a few seconds. +6. If iOS app fails to connect and shows an error containing "no route", check that local network connections are allowed for the app in system settings. + +Also see this post: https://simplex.chat/blog/20231125-simplex-chat-v5-4-link-mobile-desktop-quantum-resistant-better-groups.html#link-mobile-and-desktop-apps-via-secure-quantum-resistant-protocol + +If none of the suggestions work for you, you can create a separate profile on each device and create a small group inviting both of your device profiles and your contact. + ### 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. @@ -121,6 +141,14 @@ SimpleX Chat is a private and secure messaging platform. It is the first messagi - **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. +- **How to block someone?** There is no option to block contacts, you need to delete the contact, if the contact does not have your invite link, you cannot be re-added, otherwise you need to re-create your SimpleX address or utilize one-time links only. (Existing contacts are not lost by deletion of SimpleX address). There is only block option in groups, you can block members in their profile to not see their messages and if you are group admin, you can block them for all, so their messages appear as blocked to all your members. + +- **How to hide profile?** Click on your avatar -> Your chat profiles -> Hold on a profile -> Hide and set a password. +- **How to find hidden profile?** Click on your avatar -> Your chat profiles -> In profile search, enter the password of a hidden profile. + + +- **How to report illegal content?** Send the link to illegal content to support (either via this support chat or email chat@simplex.chat). + ### 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. @@ -129,12 +157,12 @@ SimpleX Chat is a private and secure messaging platform. It is the first messagi ### 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. +- **App is slow?** Large databases can slow down the app. Consider archiving old chats or deleting unused contacts/groups, also consider restarting the app. If you're on mobile: Settings -> Restart - **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 -Treat links as authoritative and factual, unless there is some real internal contradiction. Outside data may contain misunderstanding, FUD, etc. - these links are technically correct and factual information. +Treat the links below as authoritative and factual, unless there is some real internal contradiction. Outside data may contain misunderstanding, FUD, etc. - these links are technically correct and factual information. - Website: https://simplex.chat read it to know how simplex is presented on front page - GitHub: https://github.com/simplex-chat diff --git a/apps/simplex-support-bot/src/bot.ts b/apps/simplex-support-bot/src/bot.ts index 8e03257c16..1cbc823e8d 100644 --- a/apps/simplex-support-bot/src/bot.ts +++ b/apps/simplex-support-bot/src/bot.ts @@ -3,9 +3,30 @@ import {T, CEvt} from "@simplex-chat/types" import {Config} from "./config.js" import {GrokMessage} from "./state.js" import {GrokApiClient} from "./grok.js" -import {teamQueueMessage, grokActivatedMessage, teamAddedMessage, teamLockedMessage} from "./messages.js" +import {teamQueueMessage, grokActivatedMessage, teamAddedMessage, teamLockedMessage, teamAlreadyAddedMessage} from "./messages.js" import {log, logError} from "./util.js" +const MAX_MSG_TEXT_BYTES = 15000 // conservative limit under SimpleX's maxEncodedMsgLength (15,602) minus JSON envelope + +// --- Exported types for persistence --- + +export type SenderType = "customer" | "team" | "grok" + +export interface GroupMetadata { + firstContact: number + msgCount: number + customerName: string +} + +export interface GroupPendingInfo { + lastEventType: "message" | "reaction" + lastEventFrom: SenderType + lastEventTimestamp: number + lastMessageFrom: SenderType +} + +// --- Internal types --- + interface GroupComposition { grokMember: T.GroupMember | undefined teamMember: T.GroupMember | undefined @@ -23,13 +44,53 @@ export class SupportBot { private grokGroupMap = new Map() // mainGroupId → grokLocalGroupId private reverseGrokMap = new Map() // grokLocalGroupId → mainGroupId private grokJoinResolvers = new Map void>() // mainGroupId → resolve fn + private grokFullyConnected = new Set() // mainGroupIds where connectedToGroupMember fired - // Forwarded message tracking: "groupId:itemId" → {teamItemId, prefix} - private forwardedItems = new Map() + // Forwarded message tracking: "groupId:itemId" → {teamItemId, header, sender} + private forwardedItems = new Map() + + // [NEW] marker tracking: groupId → {teamItemId, timestamp, originalText} + private newItems = new Map() + + // Pending DMs for team group members (contactId → message) — sent on contactConnected + private pendingTeamDMs = new Map() + + // Pending owner role assignments: "groupId:groupMemberId" — set on member connect + private pendingOwnerRole = new Set() + + // Groups where welcome flow (teamQueueMessage) was already completed + private welcomeCompleted = new Set() + + // Group activity tracking: groupId → last customer message timestamp (ms) + private groupLastActive = new Map() + + // A1: Reply-to-last threading: groupId → last teamItemId for that customer group + private lastTeamItemByGroup = new Map() + + // A4: Group metadata (firstContact, msgCount, customerName) — persisted + private groupMetadata = new Map() + + // D1: Pending tracking — persisted + private groupPendingInfo = new Map() + + // Bot's business address link (set after startup) + businessAddress: string | null = null // Callback to persist grokGroupMap changes onGrokMapChanged: ((map: ReadonlyMap) => void) | null = null + // Callback to persist newItems changes + onNewItemsChanged: ((map: ReadonlyMap) => void) | null = null + + // Callback to persist groupLastActive changes + onGroupLastActiveChanged: ((map: ReadonlyMap) => void) | null = null + + // Callback to persist groupMetadata changes + onGroupMetadataChanged: ((map: ReadonlyMap) => void) | null = null + + // Callback to persist groupPendingInfo changes + onGroupPendingInfoChanged: ((map: ReadonlyMap) => void) | null = null + constructor( private mainChat: api.ChatApi, private grokChat: api.ChatApi, @@ -37,7 +98,8 @@ export class SupportBot { private config: Config, ) {} - // Restore grokGroupMap from persisted state (call after construction, before events) + // --- Restore Methods --- + restoreGrokGroupMap(entries: [number, number][]): void { for (const [mainGroupId, grokLocalGroupId] of entries) { this.grokGroupMap.set(mainGroupId, grokLocalGroupId) @@ -46,26 +108,173 @@ export class SupportBot { log(`Restored Grok group map: ${entries.length} entries`) } + restoreNewItems(entries: [number, {teamItemId: number; timestamp: number; originalText: string}][]): void { + const now = Date.now() + const DAY_MS = 24 * 60 * 60 * 1000 + for (const [groupId, info] of entries) { + if (now - info.timestamp < DAY_MS) { + this.newItems.set(groupId, info) + } + } + log(`Restored NEW items: ${this.newItems.size} entries (pruned ${entries.length - this.newItems.size} expired)`) + } + + restoreGroupLastActive(entries: [number, number][]): void { + const now = Date.now() + const PRUNE_MS = 48 * 60 * 60 * 1000 + for (const [groupId, timestamp] of entries) { + if (now - timestamp < PRUNE_MS) { + this.groupLastActive.set(groupId, timestamp) + } + } + log(`Restored group activity: ${this.groupLastActive.size} entries (pruned ${entries.length - this.groupLastActive.size} expired)`) + } + + restoreGroupMetadata(entries: [number, GroupMetadata][]): void { + for (const [groupId, meta] of entries) { + this.groupMetadata.set(groupId, meta) + } + log(`Restored group metadata: ${entries.length} entries`) + } + + restoreGroupPendingInfo(entries: [number, GroupPendingInfo][]): void { + for (const [groupId, info] of entries) { + this.groupPendingInfo.set(groupId, info) + } + log(`Restored pending info: ${entries.length} entries`) + } + + // --- Format Helpers (A2, A3, A4, A5) --- + + private formatDuration(ms: number): string { + if (ms < 60_000) return "<1m" + if (ms < 3_600_000) return `${Math.floor(ms / 60_000)}m` + if (ms < 86_400_000) return `${Math.floor(ms / 3_600_000)}h` + return `${Math.floor(ms / 86_400_000)}d` + } + + private buildHeader( + groupId: number, + customerName: string, + state: string, + msgNum: number, + firstContactTime: number | undefined, + sender: SenderType, + senderLabel?: string, + ): string { + const parts: string[] = [] + // A5: sender identification + if (sender === "team" && senderLabel) { + parts.push(`${senderLabel} > ${groupId}:${customerName}`) + } else if (sender === "grok") { + parts.push(`Grok > ${groupId}:${customerName}`) + } else { + parts.push(`${groupId}:${customerName}`) + } + // A3: state indicator + parts.push(state) + // A4: message number + parts.push(`#${msgNum}`) + // A4: duration since first contact + if (firstContactTime !== undefined) { + const elapsed = Date.now() - firstContactTime + if (elapsed >= 60_000) { + parts.push(this.formatDuration(elapsed)) + } + } + return parts.join(" · ") + } + + // A2+A5: Build the full formatted message with color coding + private formatForwardMessage(header: string, body: string, sender: SenderType, isNew: boolean): string { + let line = "" + // A5: Color-coded prefix + if (isNew) { + line += "!1 NEW! " + } else if (sender === "team") { + line += "!2 >>! " + } else if (sender === "grok") { + line += "!5 AI! " + } + // A2: Bold header + line += `*${header}*` + // A5: Italic body for Grok responses + const formattedBody = sender === "grok" ? `_${body}_` : body + // A2: Multi-line format + return `${line}\n${formattedBody}` + } + + // A6: Extract message content type for non-text indicators + private getMsgContentType(chatItem: T.ChatItem): string | null { + const content = chatItem.content as any + if (content?.type === "rcvMsgContent" || content?.type === "sndMsgContent") { + return content.msgContent?.type ?? null + } + return null + } + + // --- State Tracking Helpers --- + + private initGroupMetadata(groupId: number, customerName: string): GroupMetadata { + let meta = this.groupMetadata.get(groupId) + if (!meta) { + meta = {firstContact: Date.now(), msgCount: 0, customerName} + this.groupMetadata.set(groupId, meta) + } else { + meta.customerName = customerName + } + this.onGroupMetadataChanged?.(this.groupMetadata) + return meta + } + + private incrementMsgCount(groupId: number): number { + const meta = this.groupMetadata.get(groupId) + if (meta) { + meta.msgCount++ + this.onGroupMetadataChanged?.(this.groupMetadata) + return meta.msgCount + } + return 1 + } + + private updatePendingInfo(groupId: number, eventType: "message" | "reaction", from: SenderType): void { + const existing = this.groupPendingInfo.get(groupId) + const info: GroupPendingInfo = { + lastEventType: eventType, + lastEventFrom: from, + lastEventTimestamp: Date.now(), + lastMessageFrom: eventType === "message" ? from : (existing?.lastMessageFrom ?? from), + } + this.groupPendingInfo.set(groupId, info) + this.onGroupPendingInfoChanged?.(this.groupPendingInfo) + } + // --- State Derivation Helpers --- private async getGroupComposition(groupId: number): Promise { const members = await this.mainChat.apiListMembers(groupId) return { grokMember: members.find(m => - m.memberContactId === this.config.grokContactId && isActiveMember(m)), + this.config.grokContactId !== null + && m.memberContactId === this.config.grokContactId && isActiveMember(m)), teamMember: members.find(m => this.config.teamMembers.some(tm => tm.id === m.memberContactId) && isActiveMember(m)), } } private async isFirstCustomerMessage(groupId: number): Promise { + if (this.welcomeCompleted.has(groupId)) return false const chat = await this.apiGetChat(groupId, 20) - // The platform sends auto-messages on connect (welcome, commands, etc.) as groupSnd. - // The bot's teamQueueMessage (sent after first customer message) uniquely contains - // "forwarded to the team" — none of the platform auto-messages do. - return !chat.chatItems.some((ci: T.ChatItem) => - ci.chatDir.type === "groupSnd" - && util.ciContentText(ci)?.includes("forwarded to the team")) + const found = chat.chatItems.some((ci: T.ChatItem) => { + if (ci.chatDir.type !== "groupSnd") return false + const text = util.ciContentText(ci) + return text?.includes("forwarded to the team") + || text?.includes("now chatting with Grok") + || text?.includes("team member has been added") + || text?.includes("team member has already been invited") + }) + if (found) this.welcomeCompleted.add(groupId) + return !found } private async getGrokHistory(groupId: number, grokMember: T.GroupMember, customerId: string): Promise { @@ -77,7 +286,7 @@ export class SupportBot { if (!text) continue if (ci.chatDir.groupMember.groupMemberId === grokMember.groupMemberId) { history.push({role: "assistant", content: text}) - } else if (ci.chatDir.groupMember.memberId === customerId) { + } else if (ci.chatDir.groupMember.memberId === customerId && !util.ciBotCommand(ci)) { history.push({role: "user", content: text}) } } @@ -95,11 +304,11 @@ export class SupportBot { .filter((t): t is string => !!t) } - private async hasTeamMemberSentMessage(groupId: number, teamMember: T.GroupMember): Promise { + private async hasTeamBeenActivatedBefore(groupId: number): Promise { const chat = await this.apiGetChat(groupId, 50) return chat.chatItems.some((ci: T.ChatItem) => - ci.chatDir.type === "groupRcv" - && ci.chatDir.groupMember.groupMemberId === teamMember.groupMemberId) + ci.chatDir.type === "groupSnd" + && util.ciContentText(ci)?.includes("A team member has been added")) } // Interim apiGetChat wrapper using sendChatCmd directly @@ -149,26 +358,35 @@ export class SupportBot { if (member.memberId === bc.customerId) { log(`Customer left group ${groupId}, cleaning up`) this.cleanupGrokMaps(groupId) + this.welcomeCompleted.delete(groupId) + if (this.newItems.delete(groupId)) { + this.onNewItemsChanged?.(this.newItems) + } + if (this.groupLastActive.delete(groupId)) { + this.onGroupLastActiveChanged?.(this.groupLastActive) + } + // Clean up new state + this.lastTeamItemByGroup.delete(groupId) + this.cleanupForwardedItems(groupId) + if (this.groupMetadata.delete(groupId)) { + this.onGroupMetadataChanged?.(this.groupMetadata) + } + if (this.groupPendingInfo.delete(groupId)) { + this.onGroupPendingInfoChanged?.(this.groupPendingInfo) + } return } // Grok left - if (member.memberContactId === this.config.grokContactId) { + if (this.config.grokContactId !== null && member.memberContactId === this.config.grokContactId) { log(`Grok left group ${groupId}`) this.cleanupGrokMaps(groupId) return } - // Team member left — check if they had engaged (teamLocked vs teamPending) + // Team member left if (this.config.teamMembers.some(tm => tm.id === member.memberContactId)) { - const engaged = await this.hasTeamMemberSentMessage(groupId, member) - if (engaged) { - log(`Engaged team member left group ${groupId}, adding replacement`) - await this.addReplacementTeamMember(groupId) - } else { - log(`Pending team member left group ${groupId}, reverting to queue`) - // No state to revert — member is already gone from DB - } + log(`Team member left group ${groupId}`) } } @@ -189,7 +407,14 @@ export class SupportBot { const text = util.ciContentText(chatItem)?.trim() if (!text) return - const fwd = `${entry.prefix}${text}` + // Rebuild the message using new format + let fwd = this.truncateText(this.formatForwardMessage(entry.header, text, entry.sender, false)) + const newEntry = this.newItems.get(groupId) + if (newEntry && newEntry.teamItemId === entry.teamItemId) { + fwd = this.truncateText(this.formatForwardMessage(entry.header, text, entry.sender, true)) + newEntry.originalText = this.truncateText(this.formatForwardMessage(entry.header, text, entry.sender, false)) + this.onNewItemsChanged?.(this.newItems) + } try { await this.mainChat.apiUpdateChatItem( T.ChatType.Group, @@ -203,19 +428,118 @@ export class SupportBot { } } - onMemberConnected(evt: CEvt.ConnectedToGroupMember): void { - log(`Member connected in group ${evt.groupInfo.groupId}: ${evt.member.memberProfile.displayName}`) + // D1: Reaction event handler + async onChatItemReaction(evt: CEvt.ChatItemReaction): Promise { + if (!evt.added) return + const chatInfo = evt.reaction.chatInfo + if (chatInfo.type !== "group") return + const groupInfo = (chatInfo as any).groupInfo + if (!groupInfo?.businessChat) return + const groupId = groupInfo.groupId + + const reactionDir = evt.reaction.chatReaction.chatDir + if (reactionDir.type === "groupSnd") return + if (reactionDir.type !== "groupRcv") return + + const sender = reactionDir.groupMember + const isCustomer = sender.memberId === groupInfo.businessChat.customerId + const isGrok = this.config.grokContactId !== null && sender.memberContactId === this.config.grokContactId + const isTeam = this.config.teamMembers.some(tm => tm.id === sender.memberContactId) + + const from: SenderType | null = isCustomer ? "customer" : isGrok ? "grok" : isTeam ? "team" : null + if (!from) return + + this.updatePendingInfo(groupId, "reaction", from) } - onMemberContactReceivedInv(evt: CEvt.NewMemberContactReceivedInv): void { + async onJoinedGroupMember(evt: CEvt.JoinedGroupMember): Promise { + log(`Member joined group ${evt.groupInfo.groupId}: ${evt.member.memberProfile.displayName}`) + if (evt.groupInfo.groupId === this.config.teamGroup.id) { + await this.sendTeamMemberDM(evt.member) + } + } + + async onMemberConnected(evt: CEvt.ConnectedToGroupMember): Promise { + const groupId = evt.groupInfo.groupId + log(`Member connected in group ${groupId}: ${evt.member.memberProfile.displayName}`) + if (groupId === this.config.teamGroup.id) { + await this.sendTeamMemberDM(evt.member, evt.memberContact) + } + // Set owner role for team members invited via /add + const key = `${groupId}:${evt.member.groupMemberId}` + if (this.pendingOwnerRole.delete(key)) { + try { + await this.mainChat.apiSetMembersRole(groupId, [evt.member.groupMemberId], T.GroupMemberRole.Owner) + log(`Set owner role for member ${evt.member.groupMemberId} in group ${groupId}`) + } catch (err) { + logError(`Failed to set owner role for member ${evt.member.groupMemberId} in group ${groupId}`, err) + } + } + } + + async onMemberContactReceivedInv(evt: CEvt.NewMemberContactReceivedInv): Promise { const {contact, groupInfo, member} = evt if (groupInfo.groupId === this.config.teamGroup.id) { log(`Accepted DM contact from team group member: ${contact.contactId}:${member.memberProfile.displayName}`) + if (!this.pendingTeamDMs.has(contact.contactId)) { + const name = member.memberProfile.displayName + const formatted = name.includes(" ") ? `'${name}'` : name + const msg = `Added you to be able to invite you to customer chats later, keep this contact. Your contact ID is ${contact.contactId}:${formatted}` + this.pendingTeamDMs.set(contact.contactId, msg) + } } else { log(`DM contact received from non-team group ${groupInfo.groupId}, member ${member.memberProfile.displayName}`) } } + async onContactConnected(evt: CEvt.ContactConnected): Promise { + const contactId = evt.contact.contactId + const pendingMsg = this.pendingTeamDMs.get(contactId) + if (pendingMsg === undefined) return + this.pendingTeamDMs.delete(contactId) + log(`Contact connected, sending pending DM to team member ${contactId}`) + try { + await this.mainChat.apiSendTextMessage([T.ChatType.Direct, contactId], pendingMsg) + } catch (err) { + logError(`Failed to send DM to new team member ${contactId}`, err) + } + } + + private async sendTeamMemberDM(member: T.GroupMember, memberContact?: T.Contact): Promise { + const name = member.memberProfile.displayName + const formatted = name.includes(" ") ? `'${name}'` : name + + const contactId = memberContact?.contactId ?? member.memberContactId + if (contactId) { + const msg = `Added you to be able to invite you to customer chats later, keep this contact. Your contact ID is ${contactId}:${formatted}` + try { + await this.mainChat.apiSendTextMessage([T.ChatType.Direct, contactId], msg) + log(`Sent DM to team member ${contactId}:${name}`) + } catch (err) { + logError(`Failed to send DM to team member ${contactId}`, err) + } + return + } + + const groupId = this.config.teamGroup.id + try { + const r = await this.mainChat.sendChatCmd( + `/_create member contact #${groupId} ${member.groupMemberId}` + ) as any + if (r.type !== "newMemberContact") { + log(`Unexpected response creating member contact: ${r.type}`) + return + } + const newContactId: number = r.contact.contactId + const msg = `Added you to be able to invite you to customer chats later, keep this contact. Your contact ID is ${newContactId}:${formatted}` + this.pendingTeamDMs.set(newContactId, msg) + await this.mainChat.sendChatCmd(`/_invite member contact @${newContactId}`) + log(`Sent DM invitation to team member ${newContactId}:${name}`) + } catch (err) { + logError(`Failed to create member contact for group member ${member.groupMemberId}`, err) + } + } + // --- Event Handler (Grok agent) --- async onGrokGroupInvitation(evt: CEvt.ReceivedGroupInvitation): Promise { @@ -234,7 +558,6 @@ export class SupportBot { return } - // Join request sent — set maps, but don't resolve waiter yet. this.grokGroupMap.set(mainGroupId, evt.groupInfo.groupId) this.reverseGrokMap.set(evt.groupInfo.groupId, mainGroupId) this.onGrokMapChanged?.(this.grokGroupMap) @@ -244,6 +567,7 @@ export class SupportBot { const grokGroupId = evt.groupInfo.groupId const mainGroupId = this.reverseGrokMap.get(grokGroupId) if (mainGroupId === undefined) return + this.grokFullyConnected.add(mainGroupId) const resolver = this.grokJoinResolvers.get(mainGroupId) if (resolver) { this.grokJoinResolvers.delete(mainGroupId) @@ -256,11 +580,28 @@ export class SupportBot { private async processChatItem(ci: T.AChatItem): Promise { const {chatInfo, chatItem} = ci + + // Direct message (not from business group) → reply with business address + if (chatInfo.type === "direct" && chatItem.chatDir.type === "directRcv") { + const contactId = (chatInfo as any).contact?.contactId + if (contactId && this.businessAddress) { + try { + await this.mainChat.apiSendTextMessage( + [T.ChatType.Direct, contactId], + `I can't answer your questions on non-business address, please add me through my business address: ${this.businessAddress}`, + ) + } catch (err) { + logError(`Failed to reply to direct message from contact ${contactId}`, err) + } + } + return + } + if (chatInfo.type !== "group") return const groupInfo = chatInfo.groupInfo const groupId = groupInfo.groupId - // Handle /add command in team group + // Handle commands in team group (/add, /inviteall, /invitenew, /pending) if (groupId === this.config.teamGroup.id) { await this.processTeamGroupMessage(chatItem) return @@ -275,6 +616,7 @@ export class SupportBot { const isCustomer = sender.memberId === groupInfo.businessChat.customerId if (!isCustomer) { + const isGrok = this.config.grokContactId !== null && sender.memberContactId === this.config.grokContactId // Team member message → forward to team group if (this.config.teamMembers.some(tm => tm.id === sender.memberContactId)) { const text = util.ciContentText(chatItem)?.trim() @@ -283,32 +625,97 @@ export class SupportBot { const teamMemberName = sender.memberProfile.displayName const contactId = sender.memberContactId const itemId = chatItem.meta?.itemId - const prefix = `${teamMemberName}:${contactId} > ${customerName}:${groupId}: ` - await this.forwardToTeam(groupId, prefix, text, itemId) + + // Initialize metadata if needed, increment count + this.initGroupMetadata(groupId, customerName) + const msgNum = this.incrementMsgCount(groupId) + const meta = this.groupMetadata.get(groupId)! + + // Get state for header + const {grokMember, teamMember} = await this.getGroupComposition(groupId) + const state = grokMember ? "GROK" : teamMember ? "TEAM" : "QUEUE" + + const senderLabel = `${contactId}:${teamMemberName}` + const header = this.buildHeader(groupId, customerName, state, msgNum, meta.firstContact, "team", senderLabel) + const teamReplyTo = this.resolveTeamReplyTo(groupId, chatItem) + await this.forwardToTeam(groupId, header, text, "team", itemId, teamReplyTo) + + // D1: Track team message + this.updatePendingInfo(groupId, "message", "team") + } + } + // Any non-customer, non-Grok member TEXT message → remove Grok if present + if (!isGrok && util.ciContentText(chatItem)?.trim()) { + const {grokMember} = await this.getGroupComposition(groupId) + if (grokMember) { + log(`Team member sent message in group ${groupId}, removing Grok`) + try { + await this.mainChat.apiRemoveMembers(groupId, [grokMember.groupMemberId]) + } catch { + // ignore — may have already left + } + this.cleanupGrokMaps(groupId) } } return } - // Customer message — derive state from group composition + // Customer message — get composition for state, then forward + dispatch const {grokMember, teamMember} = await this.getGroupComposition(groupId) + const state = grokMember ? "GROK" : teamMember ? "TEAM" : "QUEUE" - if (teamMember) { - await this.handleTeamMode(groupId, chatItem) - } else if (grokMember) { - await this.handleGrokMode(groupId, groupInfo, chatItem, grokMember) - } else { - await this.handleNoSpecialMembers(groupId, groupInfo, chatItem) + const cmd = util.ciBotCommand(chatItem) + const text = util.ciContentText(chatItem)?.trim() || null + + // A6: Detect non-text content type + const contentType = this.getMsgContentType(chatItem) + const isNonText = contentType !== null && contentType !== "text" + const body = isNonText + ? (text ? `_[${contentType}]_ ${text}` : `_[${contentType}]_`) + : text + + if (body && !cmd) { + // Track customer text/content activity + this.groupLastActive.set(groupId, Date.now()) + this.onGroupLastActiveChanged?.(this.groupLastActive) + + // A4: Initialize and increment metadata + const customerName = groupInfo.groupProfile.displayName || `group-${groupId}` + this.initGroupMetadata(groupId, customerName) + const msgNum = this.incrementMsgCount(groupId) + const meta = this.groupMetadata.get(groupId)! + + const firstMessage = await this.isFirstCustomerMessage(groupId) + const header = this.buildHeader(groupId, customerName, state, msgNum, meta.firstContact, "customer") + const teamReplyTo = this.resolveTeamReplyTo(groupId, chatItem) + await this.forwardToTeam(groupId, header, body, "customer", chatItem.meta?.itemId, teamReplyTo, firstMessage) + if (firstMessage) { + await this.sendToGroup(groupId, teamQueueMessage(this.config.timezone)) + await this.sendAddCommand(groupId, groupInfo) + this.welcomeCompleted.add(groupId) + } + + // D1: Track customer message + this.updatePendingInfo(groupId, "message", "customer") } + + // State-specific handling (commands, Grok API, etc.) + if (grokMember) { + await this.handleGrokMode(groupId, groupInfo, chatItem, text, grokMember) + } else if (teamMember) { + await this.handleTeamMode(groupId, cmd ?? null) + } else { + await this.handleNoSpecialMembers(groupId, groupInfo, cmd ?? null) + } + } // Customer message when a team member is present (teamPending or teamLocked) - private async handleTeamMode(groupId: number, chatItem: T.ChatItem): Promise { - const cmd = util.ciBotCommand(chatItem) + private async handleTeamMode(groupId: number, cmd: {keyword: string} | null): Promise { if (cmd?.keyword === "grok") { await this.sendToGroup(groupId, teamLockedMessage) } - // /team → ignore (already team). Other text → no forwarding (team sees directly). + // /team → ignore (already team). Text → already forwarded above. } // Customer message when Grok is present @@ -316,10 +723,10 @@ export class SupportBot { groupId: number, groupInfo: T.GroupInfo, chatItem: T.ChatItem, + text: string | null, grokMember: T.GroupMember, ): Promise { const cmd = util.ciBotCommand(chatItem) - const text = util.ciContentText(chatItem)?.trim() || null if (cmd?.keyword === "grok") return // already in grok mode if (cmd?.keyword === "team") { @@ -327,30 +734,27 @@ export class SupportBot { return } if (!text) return - const prefix = this.customerForwardPrefix(groupId, groupInfo) - await this.forwardToTeam(groupId, prefix, text, chatItem.meta?.itemId) - await this.forwardToGrok(groupId, groupInfo, text, grokMember) + // Text already forwarded to team in processChatItem — just send to Grok + await this.forwardToGrok(groupId, groupInfo, text, grokMember, chatItem.meta?.itemId) } // Customer message when neither Grok nor team is present (welcome or teamQueue) private async handleNoSpecialMembers( groupId: number, groupInfo: T.GroupInfo, - chatItem: T.ChatItem, + cmd: {keyword: string} | null, ): Promise { - const cmd = util.ciBotCommand(chatItem) - const text = util.ciContentText(chatItem)?.trim() || null - - // Check if this is the first customer message (welcome state) const firstMessage = await this.isFirstCustomerMessage(groupId) if (firstMessage) { - // Welcome state — first message transitions to teamQueue - if (!text) return - const prefix = this.customerForwardPrefix(groupId, groupInfo) - await this.forwardToTeam(groupId, prefix, text, chatItem.meta?.itemId) - await this.sendToGroup(groupId, teamQueueMessage(this.config.timezone)) - await this.sendAddCommand(groupId, groupInfo) + if (cmd?.keyword === "grok") { + await this.activateGrok(groupId, groupInfo) + return + } + if (cmd?.keyword === "team") { + await this.activateTeam(groupId, undefined) + return + } return } @@ -363,14 +767,12 @@ export class SupportBot { await this.activateTeam(groupId, undefined) return } - if (!text) return - const prefix = this.customerForwardPrefix(groupId, groupInfo) - await this.forwardToTeam(groupId, prefix, text, chatItem.meta?.itemId) } // --- Grok Activation --- private async activateGrok(groupId: number, groupInfo: T.GroupInfo): Promise { + await this.removeNewPrefix(groupId) if (this.config.grokContactId === null) { await this.sendToGroup(groupId, "Grok is temporarily unavailable. Please try again or click /team for a team member.") return @@ -387,25 +789,18 @@ export class SupportBot { this.pendingGrokJoins.set(member.memberId, groupId) await this.sendToGroup(groupId, grokActivatedMessage) + this.welcomeCompleted.add(groupId) - // 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 group composition hasn't changed while awaiting (e.g., user sent /team concurrently) - const {teamMember} = await this.getGroupComposition(groupId) - if (teamMember) { - log(`Team member appeared during Grok activation for group ${groupId}, aborting`) try { await this.mainChat.apiRemoveMembers(groupId, [member.groupMemberId]) } catch { - // ignore + // ignore — may have already left } this.cleanupGrokMaps(groupId) + await this.sendToGroup(groupId, "Grok is temporarily unavailable. Please try again or click /team for a team member.") return } @@ -416,25 +811,26 @@ export class SupportBot { const initialUserMsg = customerMessages.join("\n") const response = await this.grokApi.chat([], initialUserMsg) - // Re-check composition after async API call - const postApi = await this.getGroupComposition(groupId) - if (postApi.teamMember) { - log(`Team member appeared during Grok API call for group ${groupId}, aborting`) - try { - await this.mainChat.apiRemoveMembers(groupId, [member.groupMemberId]) - } catch { - // ignore - } - this.cleanupGrokMaps(groupId) - return - } - const grokLocalGId = this.grokGroupMap.get(groupId) if (grokLocalGId === undefined) { - log(`Grok map entry missing after join for group ${groupId}`) + log(`Grok map entry missing after join for group ${groupId}, Grok may have left`) + await this.sendToGroup(groupId, "Grok is temporarily unavailable. Please try again or click /team for a team member.") return } - await this.grokChat.apiSendTextMessage([T.ChatType.Group, grokLocalGId], response) + const replyTo = await this.findLastGrokReceivedItem(grokLocalGId) + await this.grokSendMessage(grokLocalGId, response, replyTo) + + // Forward Grok response to team group with new format + const customerName = groupInfo.groupProfile.displayName || `group-${groupId}` + this.initGroupMetadata(groupId, customerName) + const msgNum = this.incrementMsgCount(groupId) + const meta = this.groupMetadata.get(groupId)! + const header = this.buildHeader(groupId, customerName, "GROK", msgNum, meta.firstContact, "grok") + const teamReplyTo = this.findLastForwardedTeamItem(groupId) + await this.forwardToTeam(groupId, header, response, "grok", undefined, teamReplyTo) + + // D1: Track Grok response + this.updatePendingInfo(groupId, "message", "grok") } catch (err) { logError(`Grok API/send failed for group ${groupId}`, err) try { @@ -454,16 +850,32 @@ export class SupportBot { groupInfo: T.GroupInfo, text: string, grokMember: T.GroupMember, + customerItemId?: number, ): Promise { try { + const grokLocalGId = this.grokGroupMap.get(groupId) const customerId = groupInfo.businessChat!.customerId const history = await this.getGrokHistory(groupId, grokMember, customerId) const response = await this.grokApi.chat(history, text) - const grokLocalGId = this.grokGroupMap.get(groupId) if (grokLocalGId !== undefined) { - await this.grokChat.apiSendTextMessage([T.ChatType.Group, grokLocalGId], response) + const replyTo = await this.findLastGrokReceivedItem(grokLocalGId) + await this.grokSendMessage(grokLocalGId, response, replyTo) } + + // Forward Grok response to team group with new format + const customerName = groupInfo.groupProfile.displayName || `group-${groupId}` + this.initGroupMetadata(groupId, customerName) + const msgNum = this.incrementMsgCount(groupId) + const meta = this.groupMetadata.get(groupId)! + const header = this.buildHeader(groupId, customerName, "GROK", msgNum, meta.firstContact, "grok") + const teamReplyTo = customerItemId !== undefined + ? this.forwardedItems.get(`${groupId}:${customerItemId}`)?.teamItemId + : undefined + await this.forwardToTeam(groupId, header, response, "grok", undefined, teamReplyTo) + + // D1: Track Grok response + this.updatePendingInfo(groupId, "message", "grok") } catch (err) { logError(`Grok API error for group ${groupId}`, err) try { @@ -478,31 +890,52 @@ export class SupportBot { // --- Team Actions --- - private async forwardToTeam(groupId: number, prefix: string, text: string, sourceItemId?: number): Promise { - const fwd = `${prefix}${text}` + // A1+A2+A3+A4+A5: Forwarding with full formatting and threading + private async forwardToTeam( + groupId: number, header: string, body: string, sender: SenderType, + sourceItemId?: number, inReplyTo?: number, + isNew: boolean = false, + ): Promise { + const cleanMsg = this.truncateText(this.formatForwardMessage(header, body, sender, false)) + const fwd = isNew ? this.truncateText(this.formatForwardMessage(header, body, sender, true)) : cleanMsg + + // A1: Reply-to-last threading — use explicit reply-to if provided, else last team item for this group + const effectiveReplyTo = inReplyTo ?? this.lastTeamItemByGroup.get(groupId) + try { const result = await this.mainChat.apiSendTextMessage( [T.ChatType.Group, this.config.teamGroup.id], fwd, + effectiveReplyTo, ) - if (sourceItemId !== undefined && result && result[0]) { + if (result && result[0]) { const teamItemId = result[0].chatItem.meta.itemId - this.forwardedItems.set(`${groupId}:${sourceItemId}`, {teamItemId, prefix}) + + // A1: Update threading tracker + this.lastTeamItemByGroup.set(groupId, teamItemId) + + // Edit tracking (only when sourceItemId provided) + if (sourceItemId !== undefined) { + this.forwardedItems.set(`${groupId}:${sourceItemId}`, {teamItemId, header, sender}) + } + + // [NEW] marker tracking + if (isNew) { + this.newItems.set(groupId, {teamItemId, timestamp: Date.now(), originalText: cleanMsg}) + this.onNewItemsChanged?.(this.newItems) + } } } catch (err) { logError(`Failed to forward to team for group ${groupId}`, err) } } - private async activateTeam(groupId: number, grokMember: T.GroupMember | undefined): Promise { - // Remove Grok immediately if present - if (grokMember) { - try { - await this.mainChat.apiRemoveMembers(groupId, [grokMember.groupMemberId]) - } catch { - // ignore — may have already left - } - this.cleanupGrokMaps(groupId) + private async activateTeam(groupId: number, _grokMember: T.GroupMember | undefined): Promise { + await this.removeNewPrefix(groupId) + if (await this.hasTeamBeenActivatedBefore(groupId)) { + await this.sendToGroup(groupId, teamAlreadyAddedMessage) + this.welcomeCompleted.add(groupId) + return } if (this.config.teamMembers.length === 0) { logError(`No team members configured, cannot add team member to group ${groupId}`, new Error("no team members")) @@ -517,15 +950,28 @@ export class SupportBot { return } await this.sendToGroup(groupId, teamAddedMessage(this.config.timezone)) + this.welcomeCompleted.add(groupId) } catch (err) { logError(`Failed to add team member to group ${groupId}`, err) await this.sendToGroup(groupId, "Sorry, there was an error adding a team member. Please try again.") } } - private customerForwardPrefix(groupId: number, groupInfo: T.GroupInfo): string { - const name = groupInfo.groupProfile.displayName || `group-${groupId}` - return `${name}:${groupId}: ` + private async removeNewPrefix(groupId: number): Promise { + const entry = this.newItems.get(groupId) + if (!entry) return + this.newItems.delete(groupId) + this.onNewItemsChanged?.(this.newItems) + + if (Date.now() - entry.timestamp >= 24 * 60 * 60 * 1000) return + + try { + await this.mainChat.apiUpdateChatItem( + T.ChatType.Group, this.config.teamGroup.id, entry.teamItemId, + {type: "text", text: entry.originalText}, false) + } catch (err) { + logError(`Failed to remove [NEW] for group ${groupId}`, err) + } } // --- Team Group Commands --- @@ -534,21 +980,179 @@ export class SupportBot { if (chatItem.chatDir.type !== "groupRcv") return const text = util.ciContentText(chatItem)?.trim() if (!text) return - const match = text.match(/^\/add\s+(\d+):/) - if (!match) return - - const targetGroupId = parseInt(match[1]) const senderContactId = chatItem.chatDir.groupMember.memberContactId if (!senderContactId) return + const addMatch = text.match(/^\/add\s+(\d+):/) + if (addMatch) { + await this.handleAddCommand(parseInt(addMatch[1]), senderContactId) + return + } + if (text === "/inviteall") { + await this.handleInviteAll(senderContactId) + return + } + if (text === "/invitenew") { + await this.handleInviteNew(senderContactId) + return + } + // D1: /pending command + if (text === "/pending") { + await this.handlePending() + return + } + } + + private async handleAddCommand(targetGroupId: number, senderContactId: number): Promise { + await this.removeNewPrefix(targetGroupId) + try { - await this.addOrFindTeamMember(targetGroupId, senderContactId) - log(`Team member ${senderContactId} added to group ${targetGroupId} via /add command`) + const member = await this.addOrFindTeamMember(targetGroupId, senderContactId) + if (member) { + log(`Team member ${senderContactId} added to group ${targetGroupId} via /add command`) + const key = `${targetGroupId}:${member.groupMemberId}` + this.pendingOwnerRole.add(key) + try { + await this.mainChat.apiSetMembersRole(targetGroupId, [member.groupMemberId], T.GroupMemberRole.Owner) + this.pendingOwnerRole.delete(key) + } catch { + // Member not yet connected — will be set in onMemberConnected + } + } } catch (err) { logError(`Failed to add team member to group ${targetGroupId} via /add`, err) } } + private async inviteToGroups( + groupIds: number[], senderContactId: number + ): Promise<{added: number; alreadyMember: number; failed: number}> { + let added = 0, alreadyMember = 0, failed = 0 + for (const groupId of groupIds) { + try { + const members = await this.mainChat.apiListMembers(groupId) + if (members.some((m: T.GroupMember) => m.memberContactId === senderContactId)) { + alreadyMember++ + continue + } + await this.removeNewPrefix(groupId) + const member = await this.addOrFindTeamMember(groupId, senderContactId) + if (member) { + const key = `${groupId}:${member.groupMemberId}` + this.pendingOwnerRole.add(key) + try { + await this.mainChat.apiSetMembersRole(groupId, [member.groupMemberId], T.GroupMemberRole.Owner) + this.pendingOwnerRole.delete(key) + } catch { + // Member not yet connected — will be set in onMemberConnected + } + added++ + } else { + failed++ + } + } catch (err) { + logError(`Failed to invite to group ${groupId}`, err) + failed++ + } + } + return {added, alreadyMember, failed} + } + + private async handleInviteAll(senderContactId: number): Promise { + const now = Date.now() + const DAY_MS = 24 * 60 * 60 * 1000 + const groupIds: number[] = [] + for (const [groupId, timestamp] of this.groupLastActive) { + if (now - timestamp < DAY_MS) { + groupIds.push(groupId) + } + } + const result = await this.inviteToGroups(groupIds, senderContactId) + const summary = `Invited to ${result.added} group(s), already member in ${result.alreadyMember}, failed ${result.failed} (of ${groupIds.length} active in 24h)` + log(`/inviteall: ${summary}`) + await this.sendToGroup(this.config.teamGroup.id, summary) + } + + private async handleInviteNew(senderContactId: number): Promise { + const now = Date.now() + const TWO_DAYS_MS = 48 * 60 * 60 * 1000 + const candidateIds: number[] = [] + for (const [groupId, timestamp] of this.groupLastActive) { + if (now - timestamp < TWO_DAYS_MS) { + candidateIds.push(groupId) + } + } + const groupIds: number[] = [] + for (const groupId of candidateIds) { + const {grokMember, teamMember} = await this.getGroupComposition(groupId) + if (!grokMember && !teamMember) { + groupIds.push(groupId) + } + } + const result = await this.inviteToGroups(groupIds, senderContactId) + const summary = `Invited to ${result.added} group(s), already member in ${result.alreadyMember}, failed ${result.failed} (of ${candidateIds.length} active in 48h, ${groupIds.length} without team/Grok)` + log(`/invitenew: ${summary}`) + await this.sendToGroup(this.config.teamGroup.id, summary) + } + + // D1: /pending command handler + private async handlePending(): Promise { + const pending: {groupId: number; customerName: string; state: string; msgCount: number; firstContact: number}[] = [] + + for (const [groupId, _lastActive] of this.groupLastActive) { + const info = this.groupPendingInfo.get(groupId) + const meta = this.groupMetadata.get(groupId) + + // If no pending info tracked (e.g., after restart), assume pending + if (!info) { + const {grokMember, teamMember} = await this.getGroupComposition(groupId) + const state = grokMember ? "GROK" : teamMember ? "TEAM" : "QUEUE" + pending.push({ + groupId, + customerName: meta?.customerName ?? `group-${groupId}`, + state, + msgCount: meta?.msgCount ?? 0, + firstContact: meta?.firstContact ?? _lastActive, + }) + continue + } + + // Not pending if last event is from team or grok + if (info.lastEventFrom === "team" || info.lastEventFrom === "grok") continue + + // Not pending if last event is customer reaction but last message is not from customer + if (info.lastEventType === "reaction" && info.lastEventFrom === "customer" && info.lastMessageFrom !== "customer") continue + + // It's pending + const {grokMember, teamMember} = await this.getGroupComposition(groupId) + const state = grokMember ? "GROK" : teamMember ? "TEAM" : "QUEUE" + pending.push({ + groupId, + customerName: meta?.customerName ?? `group-${groupId}`, + state, + msgCount: meta?.msgCount ?? 0, + firstContact: meta?.firstContact ?? _lastActive, + }) + } + + if (pending.length === 0) { + await this.sendToGroup(this.config.teamGroup.id, "No pending conversations.") + return + } + + // Sort by firstContact ascending (longest waiting first) + const now = Date.now() + pending.sort((a, b) => a.firstContact - b.firstContact) + + let msg = `*Pending (${pending.length}):*` + for (const p of pending) { + const duration = this.formatDuration(now - p.firstContact) + msg += `\n${p.groupId}:${p.customerName} · ${p.state} · #${p.msgCount} · ${duration}` + } + + await this.sendToGroup(this.config.teamGroup.id, msg) + } + private async sendAddCommand(groupId: number, groupInfo: T.GroupInfo): Promise { const name = groupInfo.groupProfile.displayName || `group-${groupId}` const formatted = name.includes(" ") ? `'${name}'` : name @@ -558,19 +1162,9 @@ export class SupportBot { // --- Helpers --- - private async addReplacementTeamMember(groupId: number): Promise { - if (this.config.teamMembers.length === 0) return - try { - const teamContactId = this.config.teamMembers[0].id - await this.addOrFindTeamMember(groupId, teamContactId) - } catch (err) { - logError(`Failed to add replacement team member to group ${groupId}`, err) - } - } - private async addOrFindTeamMember(groupId: number, teamContactId: number): Promise { try { - return await this.mainChat.apiAddMember(groupId, teamContactId, T.GroupMemberRole.Member) + return await this.mainChat.apiAddMember(groupId, teamContactId, T.GroupMemberRole.Owner) } catch (err: any) { if (err?.chatError?.errorType?.type === "groupDuplicateMember") { log(`Team member already in group ${groupId}, looking up existing member`) @@ -596,7 +1190,7 @@ export class SupportBot { } private waitForGrokJoin(groupId: number, timeout: number): Promise { - if (this.grokGroupMap.has(groupId)) return Promise.resolve(true) + if (this.grokFullyConnected.has(groupId)) return Promise.resolve(true) return new Promise((resolve) => { const timer = setTimeout(() => { this.grokJoinResolvers.delete(groupId) @@ -609,8 +1203,66 @@ export class SupportBot { }) } + private async grokSendMessage(grokLocalGId: number, text: string, replyTo?: number): Promise { + const safeText = this.truncateText(text) + try { + await this.grokChat.apiSendTextMessage([T.ChatType.Group, grokLocalGId], safeText, replyTo) + } catch (err: any) { + if (replyTo !== undefined && err?.chatError?.type === "errorStore" && err?.chatError?.storeError?.type === "invalidQuote") { + log(`Invalid quote in Grok group ${grokLocalGId}, retrying without reply`) + await this.grokChat.apiSendTextMessage([T.ChatType.Group, grokLocalGId], safeText) + } else { + throw err + } + } + } + + private async findLastGrokReceivedItem(grokLocalGId: number): Promise { + try { + const r = await this.grokChat.sendChatCmd(`/_get chat #${grokLocalGId} count=20`) as any + if (r.type !== "apiChat") return undefined + const items = r.chat.chatItems + for (let i = items.length - 1; i >= 0; i--) { + if (items[i].chatDir.type !== "groupSnd") { + return items[i].meta?.itemId + } + } + return undefined + } catch { + return undefined + } + } + + private resolveTeamReplyTo(groupId: number, chatItem: T.ChatItem): number | undefined { + const quotedItemId = (chatItem as any).quotedItem?.itemId + if (quotedItemId === undefined) return undefined + return this.forwardedItems.get(`${groupId}:${quotedItemId}`)?.teamItemId + } + + private findLastForwardedTeamItem(groupId: number): number | undefined { + return this.lastTeamItemByGroup.get(groupId) + } + + private cleanupForwardedItems(groupId: number): void { + const prefix = `${groupId}:` + for (const key of this.forwardedItems.keys()) { + if (key.startsWith(prefix)) this.forwardedItems.delete(key) + } + } + + private truncateText(text: string, maxBytes: number = MAX_MSG_TEXT_BYTES): string { + const encoder = new TextEncoder() + if (encoder.encode(text).length <= maxBytes) return text + const suffix = "… [truncated]" + const target = maxBytes - encoder.encode(suffix).length + const decoder = new TextDecoder("utf-8", {fatal: false}) + const truncated = decoder.decode(encoder.encode(text).slice(0, target)).replace(/\uFFFD$/, "") + return truncated + suffix + } + private cleanupGrokMaps(groupId: number): void { const grokLocalGId = this.grokGroupMap.get(groupId) + this.grokFullyConnected.delete(groupId) if (grokLocalGId === undefined) return this.grokGroupMap.delete(groupId) this.reverseGrokMap.delete(grokLocalGId) diff --git a/apps/simplex-support-bot/src/config.ts b/apps/simplex-support-bot/src/config.ts index 00ea094f03..6427578fe9 100644 --- a/apps/simplex-support-bot/src/config.ts +++ b/apps/simplex-support-bot/src/config.ts @@ -34,6 +34,15 @@ function optionalArg(args: string[], flag: string, defaultValue: string): string return args[i + 1] } +function collectOptionalArgs(args: string[], flags: string[]): string[] { + const values: string[] = [] + for (const flag of flags) { + const i = args.indexOf(flag) + if (i >= 0 && i + 1 < args.length) values.push(args[i + 1]) + } + return values +} + export function parseConfig(args: string[]): Config { const grokApiKey = process.env.GROK_API_KEY if (!grokApiKey) throw new Error("Missing environment variable: GROK_API_KEY") @@ -42,8 +51,10 @@ export function parseConfig(args: string[]): Config { const grokDbPrefix = optionalArg(args, "--grok-db-prefix", "./data/grok") const teamGroupName = requiredArg(args, "--team-group") const teamGroup: IdName = {id: 0, name: teamGroupName} // id resolved at startup - const teamMembersRaw = optionalArg(args, "--team-members", "") - const teamMembers = teamMembersRaw ? teamMembersRaw.split(",").map(parseIdName) : [] + const teamMembersRaws = collectOptionalArgs(args, ["--team-members", "--team-member"]) + const teamMembers = teamMembersRaws.length > 0 + ? teamMembersRaws.flatMap(s => s.split(",")).map(parseIdName) + : [] const groupLinks = optionalArg(args, "--group-links", "") const timezone = optionalArg(args, "--timezone", "UTC") diff --git a/apps/simplex-support-bot/src/index.ts b/apps/simplex-support-bot/src/index.ts index 331b29918e..f04c20010e 100644 --- a/apps/simplex-support-bot/src/index.ts +++ b/apps/simplex-support-bot/src/index.ts @@ -1,9 +1,9 @@ import {readFileSync, writeFileSync, existsSync} from "fs" import {join} from "path" -import {bot, api} from "simplex-chat" +import {bot, api, util} from "simplex-chat" import {T} from "@simplex-chat/types" import {parseConfig} from "./config.js" -import {SupportBot} from "./bot.js" +import {SupportBot, GroupMetadata, GroupPendingInfo} from "./bot.js" import {GrokApiClient} from "./grok.js" import {welcomeMessage} from "./messages.js" import {resolveDisplayNameConflict} from "./startup.js" @@ -13,6 +13,10 @@ interface BotState { teamGroupId?: number grokContactId?: number grokGroupMap?: {[mainGroupId: string]: number} + newItems?: {[groupId: string]: {teamItemId: number; timestamp: number; originalText: string}} + groupLastActive?: {[groupId: string]: number} + groupMetadata?: {[groupId: string]: GroupMetadata} + groupPendingInfo?: {[groupId: string]: GroupPendingInfo} } function readState(path: string): BotState { @@ -72,14 +76,32 @@ async function main(): Promise { acceptingBusinessRequest: (evt) => supportBot?.onBusinessRequest(evt), newChatItems: (evt) => supportBot?.onNewChatItems(evt), chatItemUpdated: (evt) => supportBot?.onChatItemUpdated(evt), + chatItemReaction: (evt) => supportBot?.onChatItemReaction(evt), leftMember: (evt) => supportBot?.onLeftMember(evt), - connectedToGroupMember: (evt) => supportBot?.onMemberConnected(evt), - newMemberContactReceivedInv: (evt) => supportBot?.onMemberContactReceivedInv(evt), + joinedGroupMemberConnecting: (evt) => { + log(`[event] joinedGroupMemberConnecting: group=${evt.groupInfo.groupId} member=${evt.member.memberProfile.displayName} memberContactId=${evt.member.memberContactId ?? "null"}`) + }, + joinedGroupMember: (evt) => { + log(`[event] joinedGroupMember: group=${evt.groupInfo.groupId} member=${evt.member.memberProfile.displayName} memberContactId=${evt.member.memberContactId ?? "null"}`) + supportBot?.onJoinedGroupMember(evt) + }, + connectedToGroupMember: (evt) => { + log(`[event] connectedToGroupMember: group=${evt.groupInfo.groupId} member=${evt.member.memberProfile.displayName} memberContactId=${evt.member.memberContactId ?? "null"} memberContact=${evt.memberContact?.contactId ?? "null"}`) + supportBot?.onMemberConnected(evt) + }, + newMemberContactReceivedInv: (evt) => { + log(`[event] newMemberContactReceivedInv: group=${evt.groupInfo.groupId} contact=${evt.contact.contactId} member=${evt.member.memberProfile.displayName}`) + supportBot?.onMemberContactReceivedInv(evt) + }, + contactConnected: (evt) => { + log(`[event] contactConnected: contactId=${evt.contact.contactId} name=${evt.contact.profile?.displayName ?? "unknown"}`) + supportBot?.onContactConnected(evt) + }, } log("Initializing main bot...") resolveDisplayNameConflict(config.dbPrefix, "Ask SimpleX Team") - const [mainChat, mainUser, _mainAddress] = await bot.run({ + const [mainChat, mainUser, mainAddress] = await bot.run({ profile: {displayName: "Ask SimpleX Team", fullName: "", shortDescr: "Send questions about SimpleX Chat app and your suggestions", image: supportImage}, dbOpts: {dbFilePrefix: config.dbPrefix}, options: { @@ -91,7 +113,6 @@ async function main(): Promise { commands: [ {type: "command", keyword: "grok", label: "Ask Grok AI"}, {type: "command", keyword: "team", label: "Switch to team"}, - {type: "command", keyword: "add", label: "Join group"}, ], useBotProfile: true, }, @@ -173,6 +194,12 @@ async function main(): Promise { const teamGroupPreferences: T.GroupPreferences = { directMessages: {enable: T.GroupFeatureEnabled.On}, + commands: [ + {type: "command", keyword: "add", label: "Join customer chat", params: "groupId:name"}, + {type: "command", keyword: "inviteall", label: "Join all active chats (24h)"}, + {type: "command", keyword: "invitenew", label: "Join new chats (48h, no team/Grok)"}, + {type: "command", keyword: "pending", label: "Show pending conversations"}, + ], } if (config.teamGroup.id === 0) { @@ -252,6 +279,12 @@ async function main(): Promise { // Create SupportBot — event handlers now route through it supportBot = new SupportBot(mainChat, grokChat, grokApi, config) + // Set business address for direct message replies + if (mainAddress) { + supportBot.businessAddress = util.contactAddressStr(mainAddress.connLinkContact) + log(`Business address: ${supportBot.businessAddress}`) + } + // Restore Grok group map from persisted state if (state.grokGroupMap) { const entries: [number, number][] = Object.entries(state.grokGroupMap) @@ -267,6 +300,66 @@ async function main(): Promise { writeState(stateFilePath, state) } + // Restore newItems from persisted state + if (state.newItems) { + const entries: [number, {teamItemId: number; timestamp: number; originalText: string}][] = + Object.entries(state.newItems).map(([k, v]) => [Number(k), v]) + supportBot.restoreNewItems(entries) + } + + // Persist newItems on every change + supportBot.onNewItemsChanged = (map) => { + const obj: {[key: string]: {teamItemId: number; timestamp: number; originalText: string}} = {} + for (const [k, v] of map) obj[String(k)] = v + state.newItems = obj + writeState(stateFilePath, state) + } + + // Restore groupLastActive from persisted state + if (state.groupLastActive) { + const entries: [number, number][] = Object.entries(state.groupLastActive) + .map(([k, v]) => [Number(k), v]) + supportBot.restoreGroupLastActive(entries) + } + + // Persist groupLastActive on every change + supportBot.onGroupLastActiveChanged = (map) => { + const obj: {[key: string]: number} = {} + for (const [k, v] of map) obj[String(k)] = v + state.groupLastActive = obj + writeState(stateFilePath, state) + } + + // Restore groupMetadata from persisted state + if (state.groupMetadata) { + const entries: [number, GroupMetadata][] = Object.entries(state.groupMetadata) + .map(([k, v]) => [Number(k), v]) + supportBot.restoreGroupMetadata(entries) + } + + // Persist groupMetadata on every change + supportBot.onGroupMetadataChanged = (map) => { + const obj: {[key: string]: GroupMetadata} = {} + for (const [k, v] of map) obj[String(k)] = v + state.groupMetadata = obj + writeState(stateFilePath, state) + } + + // Restore groupPendingInfo from persisted state + if (state.groupPendingInfo) { + const entries: [number, GroupPendingInfo][] = Object.entries(state.groupPendingInfo) + .map(([k, v]) => [Number(k), v]) + supportBot.restoreGroupPendingInfo(entries) + } + + // Persist groupPendingInfo on every change + supportBot.onGroupPendingInfoChanged = (map) => { + const obj: {[key: string]: GroupPendingInfo} = {} + for (const [k, v] of map) obj[String(k)] = v + state.groupPendingInfo = obj + writeState(stateFilePath, state) + } + log("SupportBot initialized. Bot running.") // Subscribe Grok agent event handlers diff --git a/apps/simplex-support-bot/src/messages.ts b/apps/simplex-support-bot/src/messages.ts index 27fd6319dd..64582c1584 100644 --- a/apps/simplex-support-bot/src/messages.ts +++ b/apps/simplex-support-bot/src/messages.ts @@ -17,3 +17,5 @@ export function teamAddedMessage(timezone: string): string { } export const teamLockedMessage = "You are now in team mode. A team member will reply to your message." + +export const teamAlreadyAddedMessage = "A team member has already been invited to this conversation and will reply when available."