Files
Narasimha-sc ff5919e731 support-bot: implement stateless bot with cards, Grok, team flow, hardening
Complete rewrite of the support bot to stateless architecture:
- State derived from group composition + chat history (survives restarts)
- Card dashboard in team group with live status, preview, /join commands
- Two-profile architecture (main + Grok) with profileMutex serialization
- Grok join race condition fix via bufferedGrokInvitations
- Card preview: newest-first truncation, newline sanitization, sender prefixes
- Best-effort startup (invite link, group profile update)
- Team group preferences: directMessages, fullDelete, commands
- 122 tests across 27 suites
2026-04-10 12:33:30 +00:00

1922 lines
75 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {describe, test, expect, beforeEach, vi} from "vitest"
import {SupportBot} from "./src/bot.js"
import {CardManager} from "./src/cards.js"
import {welcomeMessage, queueMessage, grokActivatedMessage, teamLockedMessage} from "./src/messages.js"
// Silence console output during tests
vi.spyOn(console, "log").mockImplementation(() => {})
vi.spyOn(console, "error").mockImplementation(() => {})
// ─── Type stubs ───
const ChatType = {Direct: "direct" as const, Group: "group" as const, Local: "local" as const}
const GroupMemberRole = {Member: "member" as const, Owner: "owner" as const, Admin: "admin" as const}
const GroupMemberStatus = {Connected: "connected" as const, Complete: "complete" as const, Announced: "announced" as const, Left: "left" as const}
const GroupFeatureEnabled = {On: "on" as const, Off: "off" as const}
const CIDeleteMode = {Broadcast: "broadcast" as const}
// ─── Mock infrastructure ───
let nextItemId = 1000
class MockChatApi {
sent: {chat: [string, number]; text: string}[] = []
added: {groupId: number; contactId: number; role: string}[] = []
removed: {groupId: number; memberIds: number[]}[] = []
joined: number[] = []
deleted: {chatType: string; chatId: number; itemIds: number[]; mode: string}[] = []
customData = new Map<number, any>()
roleChanges: {groupId: number; memberIds: number[]; role: string}[] = []
profileUpdates: {groupId: number; profile: any}[] = []
members = new Map<number, any[]>()
chatItems = new Map<number, any[]>()
groups = new Map<number, any>()
activeUserId = 1
private _addMemberFails = false
private _addMemberError: any = null
private _deleteChatItemsFails = false
apiAddMemberWillFail(err?: any) { this._addMemberFails = true; this._addMemberError = err }
apiDeleteChatItemsWillFail() { this._deleteChatItemsFails = true }
async apiSetActiveUser(userId: number) { this.activeUserId = userId; return {userId, profile: {displayName: "test"}} }
async apiSendMessages(chatRef: any, messages: any[]) {
// Normalize chat ref: accept both [type, id] tuples and {chatType, chatId} objects
const chat: [string, number] = Array.isArray(chatRef)
? chatRef
: [chatRef.chatType, chatRef.chatId]
return messages.map(msg => {
const text = msg.msgContent?.text || ""
this.sent.push({chat, text})
const itemId = nextItemId++
return {chatItem: {meta: {itemId}, chatDir: {type: "groupSnd"}, content: {type: "sndMsgContent", msgContent: {type: "text", text}}}}
})
}
async apiSendTextMessage(chat: [string, number], text: string) {
return this.apiSendMessages(chat, [{msgContent: {type: "text", text}, mentions: {}}])
}
async apiAddMember(groupId: number, contactId: number, role: string) {
if (this._addMemberFails) {
this._addMemberFails = false
throw this._addMemberError || new Error("apiAddMember failed")
}
this.added.push({groupId, contactId, role})
const memberId = `member-${contactId}`
const groupMemberId = 5000 + contactId
return {memberId, groupMemberId, memberContactId: contactId, memberStatus: GroupMemberStatus.Connected, memberProfile: {displayName: `Contact${contactId}`}}
}
async apiRemoveMembers(groupId: number, memberIds: number[]) {
this.removed.push({groupId, memberIds})
return memberIds.map(id => ({groupMemberId: id}))
}
async apiJoinGroup(groupId: number) {
this.joined.push(groupId)
return {groupId}
}
async apiSetMembersRole(groupId: number, memberIds: number[], role: string) {
this.roleChanges.push({groupId, memberIds, role})
}
async apiListMembers(groupId: number) {
return this.members.get(groupId) || []
}
async apiGetChat(_chatType: string, chatId: number, _count: number) {
const items = this.chatItems.get(chatId) || []
const groupInfo = this.groups.get(chatId)
return {
chatInfo: {type: "group", groupInfo: groupInfo || makeGroupInfo(chatId)},
chatItems: items,
chatStats: {unreadCount: 0, unreadMentions: 0, reportsCount: 0, minUnreadItemId: 0, unreadChat: false},
}
}
async apiListGroups(_userId: number) {
return [...this.groups.values()].map(g => ({...g, customData: this.customData.get(g.groupId)}))
}
async apiSetGroupCustomData(groupId: number, data?: any) {
if (data === undefined) this.customData.delete(groupId)
else this.customData.set(groupId, data)
}
async apiDeleteChatItems(chatType: string, chatId: number, itemIds: number[], mode: string) {
if (this._deleteChatItemsFails) {
this._deleteChatItemsFails = false
throw new Error("apiDeleteChatItems failed")
}
this.deleted.push({chatType, chatId, itemIds, mode})
return []
}
async apiUpdateGroupProfile(groupId: number, profile: any) {
this.profileUpdates.push({groupId, profile})
return this.groups.get(groupId) || makeGroupInfo(groupId)
}
rawCmds: string[] = []
async sendChatCmd(cmd: string) {
this.rawCmds.push(cmd)
const createMatch = cmd.match(/^\/_create member contact #(\d+) (\d+)$/)
if (createMatch) {
const newContactId = nextItemId++
return {type: "newMemberContact", contact: {contactId: newContactId, profile: {displayName: "member"}}, groupInfo: {}, member: {}}
}
const inviteMatch = cmd.match(/^\/_invite member contact @(\d+) text (.+)$/)
if (inviteMatch) {
const contactId = parseInt(inviteMatch[1], 10)
const text = inviteMatch[2]
this.sent.push({chat: [ChatType.Direct, contactId], text})
return {type: "newMemberContactSentInv", contact: {contactId, profile: {displayName: "member"}}, groupInfo: {}, member: {}}
}
return {type: "cmdOk"}
}
sentTo(groupId: number): string[] {
return this.sent.filter(s => s.chat[0] === ChatType.Group && s.chat[1] === groupId).map(s => s.text)
}
lastSentTo(groupId: number): string | undefined {
const msgs = this.sentTo(groupId)
return msgs[msgs.length - 1]
}
sentDirect(contactId: number): string[] {
return this.sent.filter(s => s.chat[0] === ChatType.Direct && s.chat[1] === contactId).map(s => s.text)
}
}
class MockGrokApi {
calls: {history: any[]; message: string}[] = []
private _response = "Grok answer"
private _willFail = false
willRespond(text: string) { this._response = text; this._willFail = false }
willFail() { this._willFail = true }
async chat(history: any[], userMessage: string): Promise<string> {
this.calls.push({history, message: userMessage})
if (this._willFail) { this._willFail = false; throw new Error("Grok API error") }
return this._response
}
}
// ─── Factory helpers ───
const MAIN_USER_ID = 1
const GROK_USER_ID = 2
const TEAM_GROUP_ID = 50
const CUSTOMER_GROUP_ID = 100
const GROK_CONTACT_ID = 10
const TEAM_MEMBER_1_ID = 20
const TEAM_MEMBER_2_ID = 21
const GROK_LOCAL_GROUP_ID = 200
const CUSTOMER_ID = "customer-1"
// ─── Member factories ───
function makeTeamMember(contactId: number, name = `Contact${contactId}`, groupMemberId?: number) {
return {
memberId: `team-${contactId}`,
groupMemberId: groupMemberId ?? 5000 + contactId,
memberContactId: contactId,
memberStatus: GroupMemberStatus.Connected,
memberProfile: {displayName: name},
}
}
function makeGrokMember(groupMemberId = 7777) {
return {
memberId: "grok-member",
groupMemberId,
memberContactId: GROK_CONTACT_ID,
memberStatus: GroupMemberStatus.Connected,
memberProfile: {displayName: "Grok AI"},
}
}
function makeCustomerMember(status = GroupMemberStatus.Connected) {
return {
memberId: CUSTOMER_ID,
groupMemberId: 3000,
memberStatus: status,
memberProfile: {displayName: "Customer"},
}
}
function makeConfig(overrides: Partial<any> = {}) {
return {
dbPrefix: "./test-data/simplex",
teamGroup: {id: TEAM_GROUP_ID, name: "SupportTeam"},
teamMembers: [
{id: TEAM_MEMBER_1_ID, name: "Alice"},
{id: TEAM_MEMBER_2_ID, name: "Bob"},
],
groupLinks: "",
timezone: "UTC",
completeHours: 3,
cardFlushMinutes: 15,
grokApiKey: "test-key",
grokContactId: GROK_CONTACT_ID as number | null,
...overrides,
}
}
function makeGroupInfo(groupId: number, opts: Partial<any> = {}): any {
return {
groupId,
groupProfile: {displayName: opts.displayName || `Group${groupId}`, fullName: ""},
businessChat: opts.businessChat !== undefined ? opts.businessChat : {
chatType: "business",
businessId: "bot-1",
customerId: opts.customerId || CUSTOMER_ID,
},
membership: {memberId: "bot-member"},
customData: opts.customData,
chatSettings: {enableNtfs: "all", favorite: false},
fullGroupPreferences: {},
localDisplayName: `group-${groupId}`,
localAlias: "",
useRelays: false,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
chatTags: [],
groupSummary: {},
membersRequireAttention: 0,
}
}
function makeUser(userId: number) {
return {userId, profile: {displayName: userId === MAIN_USER_ID ? "Ask SimpleX Team" : "Grok AI"}}
}
function makeChatItem(opts: {
dir: "groupSnd" | "groupRcv" | "directRcv"
text?: string
memberId?: string
memberContactId?: number
memberDisplayName?: string
msgType?: string
groupId?: number
}): any {
const itemId = nextItemId++
const now = new Date().toISOString()
const msgContent = opts.msgType
? {type: opts.msgType, text: opts.text || ""}
: {type: "text", text: opts.text || ""}
let chatDir: any
if (opts.dir === "groupSnd") {
chatDir = {type: "groupSnd"}
} else if (opts.dir === "groupRcv") {
chatDir = {
type: "groupRcv",
groupMember: {
memberId: opts.memberId || CUSTOMER_ID,
groupMemberId: 3000,
memberContactId: opts.memberContactId,
memberStatus: GroupMemberStatus.Connected,
memberProfile: {displayName: opts.memberDisplayName || "Customer"},
},
}
} else {
chatDir = {type: "directRcv"}
}
return {
chatDir,
meta: {itemId, itemTs: now, createdAt: now, itemText: opts.text || "", itemStatus: {type: "sndSent"}, itemEdited: false},
content: {type: opts.dir === "groupSnd" ? "sndMsgContent" : "rcvMsgContent", msgContent},
mentions: {},
reactions: [],
}
}
function makeAChatItem(chatItem: any, groupId = CUSTOMER_GROUP_ID): any {
return {
chatInfo: {type: "group", groupInfo: makeGroupInfo(groupId)},
chatItem,
}
}
function makeDirectAChatItem(chatItem: any, contactId: number): any {
return {
chatInfo: {type: "direct", contact: {contactId, profile: {displayName: "Someone"}}},
chatItem,
}
}
// ─── Shared test state ───
let chat: MockChatApi
let grokApi: MockGrokApi
let config: ReturnType<typeof makeConfig>
let bot: InstanceType<typeof SupportBot>
let cards: InstanceType<typeof CardManager>
// ─── Setup and helpers ───
function setup(configOverrides: Partial<any> = {}) {
nextItemId = 1000
chat = new MockChatApi()
grokApi = new MockGrokApi()
config = makeConfig(configOverrides)
// Register team group and customer group in mock
const teamGroupInfo = makeGroupInfo(TEAM_GROUP_ID, {businessChat: null, displayName: "SupportTeam"})
chat.groups.set(TEAM_GROUP_ID, teamGroupInfo)
chat.groups.set(CUSTOMER_GROUP_ID, makeGroupInfo(CUSTOMER_GROUP_ID))
cards = new CardManager(chat as any, config as any, MAIN_USER_ID, 999999999)
bot = new SupportBot(chat as any, grokApi as any, config as any, MAIN_USER_ID, GROK_USER_ID)
// Replace cards with our constructed one that has a long flush interval
bot.cards = cards
}
function customerMessage(text: string, groupId = CUSTOMER_GROUP_ID): any {
const ci = makeChatItem({dir: "groupRcv", text, memberId: CUSTOMER_ID})
return {
type: "newChatItems" as const,
user: makeUser(MAIN_USER_ID),
chatItems: [makeAChatItem(ci, groupId)],
}
}
function customerNonTextMessage(groupId = CUSTOMER_GROUP_ID): any {
const ci = makeChatItem({dir: "groupRcv", text: "", memberId: CUSTOMER_ID, msgType: "image"})
return {
type: "newChatItems" as const,
user: makeUser(MAIN_USER_ID),
chatItems: [makeAChatItem(ci, groupId)],
}
}
function teamMemberMessage(text: string, contactId = TEAM_MEMBER_1_ID, groupId = CUSTOMER_GROUP_ID): any {
const ci = makeChatItem({dir: "groupRcv", text, memberId: `team-${contactId}`, memberContactId: contactId, memberDisplayName: "Alice"})
return {
type: "newChatItems" as const,
user: makeUser(MAIN_USER_ID),
chatItems: [makeAChatItem(ci, groupId)],
}
}
function grokResponseMessage(text: string, groupId = CUSTOMER_GROUP_ID): any {
const ci = makeChatItem({dir: "groupRcv", text, memberId: "grok-member", memberContactId: GROK_CONTACT_ID, memberDisplayName: "Grok AI"})
return {
type: "newChatItems" as const,
user: makeUser(MAIN_USER_ID),
chatItems: [makeAChatItem(ci, groupId)],
}
}
function directMessage(text: string, contactId: number): any {
const ci = makeChatItem({dir: "directRcv", text})
return {
type: "newChatItems" as const,
user: makeUser(MAIN_USER_ID),
chatItems: [makeDirectAChatItem(ci, contactId)],
}
}
function teamGroupMessage(text: string, senderContactId = TEAM_MEMBER_1_ID): any {
const ci = makeChatItem({dir: "groupRcv", text, memberId: `team-${senderContactId}`, memberContactId: senderContactId, memberDisplayName: "Alice"})
return {
type: "newChatItems" as const,
user: makeUser(MAIN_USER_ID),
chatItems: [{chatInfo: {type: "group", groupInfo: makeGroupInfo(TEAM_GROUP_ID, {businessChat: null})}, chatItem: ci}],
}
}
// Simulate bot sending a message to the customer group (adds it to chatItems history)
function addBotMessage(text: string, groupId = CUSTOMER_GROUP_ID) {
const ci = makeChatItem({dir: "groupSnd", text})
const items = chat.chatItems.get(groupId) || []
items.push(ci)
chat.chatItems.set(groupId, items)
}
function addCustomerMessageToHistory(text: string, groupId = CUSTOMER_GROUP_ID) {
const ci = makeChatItem({dir: "groupRcv", text, memberId: CUSTOMER_ID})
const items = chat.chatItems.get(groupId) || []
items.push(ci)
chat.chatItems.set(groupId, items)
}
function addTeamMemberMessageToHistory(text: string, contactId = TEAM_MEMBER_1_ID, groupId = CUSTOMER_GROUP_ID) {
const ci = makeChatItem({dir: "groupRcv", text, memberId: `team-${contactId}`, memberContactId: contactId})
const items = chat.chatItems.get(groupId) || []
items.push(ci)
chat.chatItems.set(groupId, items)
}
function addGrokMessageToHistory(text: string, groupId = CUSTOMER_GROUP_ID) {
const ci = makeChatItem({dir: "groupRcv", text, memberId: "grok-member", memberContactId: GROK_CONTACT_ID})
const items = chat.chatItems.get(groupId) || []
items.push(ci)
chat.chatItems.set(groupId, items)
}
// State helpers — reach specific states
async function reachQueue(groupId = CUSTOMER_GROUP_ID) {
await bot.onNewChatItems(customerMessage("Hello, I need help", groupId))
// This should have sent queue message + created card
}
async function reachGrok(groupId = CUSTOMER_GROUP_ID) {
await reachQueue(groupId)
// Add the queue message to history so state derivation sees it
addBotMessage("The team can see your message", groupId)
// Send /grok command. This triggers activateGrok which needs the join flow.
// We need to simulate Grok join success.
const grokJoinPromise = simulateGrokJoinSuccess(groupId)
await bot.onNewChatItems(customerMessage("/grok", groupId))
await grokJoinPromise
}
async function simulateGrokJoinSuccess(mainGroupId = CUSTOMER_GROUP_ID) {
// Wait for apiAddMember to be called, then simulate Grok invitation + join
await new Promise(r => setTimeout(r, 10))
// Find the pending grok join via the added members
const addedGrok = chat.added.find(a => a.contactId === GROK_CONTACT_ID && a.groupId === mainGroupId)
if (!addedGrok) return
// Simulate Grok receivedGroupInvitation
const memberId = `member-${GROK_CONTACT_ID}`
await bot.onGrokGroupInvitation({
type: "receivedGroupInvitation",
user: makeUser(GROK_USER_ID),
groupInfo: {...makeGroupInfo(GROK_LOCAL_GROUP_ID), membership: {memberId}},
contact: {contactId: 99},
fromMemberRole: GroupMemberRole.Admin,
memberRole: GroupMemberRole.Member,
})
// Simulate Grok connectedToGroupMember
await bot.onGrokMemberConnected({
type: "connectedToGroupMember",
user: makeUser(GROK_USER_ID),
groupInfo: makeGroupInfo(GROK_LOCAL_GROUP_ID),
member: {memberId: "bot-in-grok-view", groupMemberId: 9999, memberContactId: undefined},
})
}
async function reachTeamPending(groupId = CUSTOMER_GROUP_ID) {
await reachQueue(groupId)
addBotMessage("The team can see your message", groupId)
await bot.onNewChatItems(customerMessage("/team", groupId))
}
async function reachTeam(groupId = CUSTOMER_GROUP_ID) {
await reachTeamPending(groupId)
addBotMessage("A team member has been added", groupId)
chat.members.set(groupId, [makeTeamMember(TEAM_MEMBER_1_ID, "Alice")])
// Team member sends a text message (triggers one-way gate)
addTeamMemberMessageToHistory("Hi, how can I help?", TEAM_MEMBER_1_ID, groupId)
await bot.onNewChatItems(teamMemberMessage("Hi, how can I help?", TEAM_MEMBER_1_ID, groupId))
}
// ─── Assertion helpers ───
function expectSentToGroup(groupId: number, substring: string) {
const msgs = chat.sentTo(groupId)
expect(msgs.some(m => m.includes(substring)),
`Expected message containing "${substring}" sent to group ${groupId}, got:\n${msgs.join("\n")}`
).toBe(true)
}
function expectNotSentToGroup(groupId: number, substring: string) {
expect(chat.sentTo(groupId).every(m => !m.includes(substring))).toBe(true)
}
function expectDmSent(contactId: number, substring: string) {
expect(chat.sentDirect(contactId).some(m => m.includes(substring))).toBe(true)
}
function expectAnySent(substring: string) {
expect(chat.sent.some(s => s.text.includes(substring))).toBe(true)
}
function expectMemberAdded(groupId: number, contactId: number) {
expect(chat.added.some(a => a.groupId === groupId && a.contactId === contactId)).toBe(true)
}
function expectCardDeleted(cardItemId: number) {
expect(chat.deleted.some(d => d.itemIds.includes(cardItemId))).toBe(true)
}
function expectRawCmd(substring: string) {
expect(chat.rawCmds.some(c => c.includes(substring))).toBe(true)
}
// ─── Event factories ───
function connectedEvent(groupId: number, member: any, memberContact?: any) {
return {
type: "connectedToGroupMember" as const,
user: makeUser(MAIN_USER_ID),
groupInfo: makeGroupInfo(groupId, groupId === TEAM_GROUP_ID ? {businessChat: null} : {}),
member,
...(memberContact !== undefined ? {memberContact} : {}),
}
}
function leftEvent(groupId: number, member: any) {
return {
type: "leftMember" as const,
user: makeUser(MAIN_USER_ID),
groupInfo: makeGroupInfo(groupId, groupId === TEAM_GROUP_ID ? {businessChat: null} : {}),
member: {...member, memberStatus: GroupMemberStatus.Left},
}
}
function updatedEvent(groupId: number, chatItem: any, userId = MAIN_USER_ID) {
return {
type: "chatItemUpdated" as const,
user: makeUser(userId),
chatItem: {
chatInfo: {type: "group", groupInfo: makeGroupInfo(groupId, groupId === TEAM_GROUP_ID ? {businessChat: null} : {})},
chatItem,
},
}
}
function reactionEvent(groupId: number, added: boolean) {
return {
type: "chatItemReaction" as const,
user: makeUser(MAIN_USER_ID),
added,
reaction: {
chatInfo: {type: "group", groupInfo: makeGroupInfo(groupId)},
chatReaction: {reaction: {type: "emoji", emoji: "👍"}},
},
}
}
function joinedEvent(groupId: number, member: any, userId = MAIN_USER_ID) {
return {
type: "joinedGroupMember" as const,
user: makeUser(userId),
groupInfo: makeGroupInfo(groupId, groupId === TEAM_GROUP_ID ? {businessChat: null} : {}),
member,
}
}
function grokViewCustomerMessage(text: string, msgType?: string) {
chat.groups.set(GROK_LOCAL_GROUP_ID, makeGroupInfo(GROK_LOCAL_GROUP_ID))
const ci = makeChatItem({dir: "groupRcv", text, memberId: CUSTOMER_ID, ...(msgType ? {msgType} : {})})
return {
type: "newChatItems" as const,
user: makeUser(GROK_USER_ID),
chatItems: [{chatInfo: {type: "group", groupInfo: makeGroupInfo(GROK_LOCAL_GROUP_ID)}, chatItem: ci}],
}
}
// ═══════════════════════════════════════════════════════════
// Tests
// ═══════════════════════════════════════════════════════════
describe("Welcome & First Message", () => {
beforeEach(() => setup())
test("first message → queue reply sent, card created in team group", async () => {
await bot.onNewChatItems(customerMessage("Hello"))
expectSentToGroup(CUSTOMER_GROUP_ID, "The team can see your message")
const teamMsgs = chat.sentTo(TEAM_GROUP_ID)
expect(teamMsgs.length).toBeGreaterThan(0)
expect(teamMsgs[teamMsgs.length - 1]).toContain("/join")
})
test("non-text first message → no queue reply, no card", async () => {
await bot.onNewChatItems(customerNonTextMessage())
expectNotSentToGroup(CUSTOMER_GROUP_ID, "The team can see your message")
expect(chat.sentTo(TEAM_GROUP_ID).length).toBe(0)
})
test("second message → no duplicate queue reply", async () => {
await bot.onNewChatItems(customerMessage("Hello"))
addBotMessage("The team can see your message")
const countBefore = chat.sentTo(CUSTOMER_GROUP_ID).filter(m => m.includes("The team can see your message")).length
await bot.onNewChatItems(customerMessage("Second message"))
const countAfter = chat.sentTo(CUSTOMER_GROUP_ID).filter(m => m.includes("The team can see your message")).length
expect(countAfter).toBe(countBefore)
})
test("unrecognized /command → treated as normal message", async () => {
await bot.onNewChatItems(customerMessage("/unknown"))
expectSentToGroup(CUSTOMER_GROUP_ID, "The team can see your message")
})
})
describe("/grok Activation", () => {
beforeEach(() => setup())
test("/grok from QUEUE → Grok invited, grokActivatedMessage sent", async () => {
await reachQueue()
addBotMessage("The team can see your message")
const joinPromise = simulateGrokJoinSuccess()
await bot.onNewChatItems(customerMessage("/grok"))
await joinPromise
await bot.flush()
expectMemberAdded(CUSTOMER_GROUP_ID, GROK_CONTACT_ID)
expectSentToGroup(CUSTOMER_GROUP_ID, "now chatting with Grok")
})
test("/grok as first message → WELCOME→GROK directly, no queue message", async () => {
const joinPromise = simulateGrokJoinSuccess()
await bot.onNewChatItems(customerMessage("/grok"))
await joinPromise
await bot.flush()
expectSentToGroup(CUSTOMER_GROUP_ID, "now chatting with Grok")
expectNotSentToGroup(CUSTOMER_GROUP_ID, "The team can see your message")
expect(chat.sentTo(TEAM_GROUP_ID).length).toBeGreaterThan(0)
})
test("/grok in TEAM → rejected with teamLockedMessage", async () => {
await reachTeam()
await bot.onNewChatItems(customerMessage("/grok"))
expectSentToGroup(CUSTOMER_GROUP_ID, "team mode")
})
test("/grok when grokContactId is null → grokUnavailableMessage", async () => {
setup({grokContactId: null})
await reachQueue()
addBotMessage("The team can see your message")
await bot.onNewChatItems(customerMessage("/grok"))
expectSentToGroup(CUSTOMER_GROUP_ID, "temporarily unavailable")
})
test("/grok as first message + Grok join fails → queue message sent as fallback", async () => {
chat.apiAddMemberWillFail()
await bot.onNewChatItems(customerMessage("/grok"))
await bot.flush()
expectSentToGroup(CUSTOMER_GROUP_ID, "temporarily unavailable")
expectSentToGroup(CUSTOMER_GROUP_ID, "The team can see your message")
})
})
describe("Grok Conversation", () => {
beforeEach(() => setup())
test("Grok per-message: reads history, calls API, sends response", async () => {
addCustomerMessageToHistory("How do I create a group?", GROK_LOCAL_GROUP_ID)
grokApi.willRespond("To create a group, tap +, then New Group.")
await bot.onGrokNewChatItems(grokViewCustomerMessage("How do I create a group?"))
expect(grokApi.calls.length).toBe(1)
expect(grokApi.calls[0].message).toBe("How do I create a group?")
expectAnySent("To create a group, tap +, then New Group.")
})
test("customer non-text in GROK → no Grok API call", async () => {
await bot.onGrokNewChatItems(grokViewCustomerMessage("", "image"))
expect(grokApi.calls.length).toBe(0)
})
test("Grok API error → error message in group, stays GROK", async () => {
grokApi.willFail()
await bot.onGrokNewChatItems(grokViewCustomerMessage("A question"))
expectAnySent("couldn't process that")
})
test("Grok ignores bot commands from customer", async () => {
await bot.onGrokNewChatItems(grokViewCustomerMessage("/team"))
expect(grokApi.calls.length).toBe(0)
})
test("Grok ignores non-customer messages", async () => {
chat.groups.set(GROK_LOCAL_GROUP_ID, makeGroupInfo(GROK_LOCAL_GROUP_ID))
const ci = makeChatItem({dir: "groupRcv", text: "Team message", memberId: "not-customer", memberContactId: TEAM_MEMBER_1_ID})
const grokEvt = {
type: "newChatItems" as const,
user: makeUser(GROK_USER_ID),
chatItems: [{chatInfo: {type: "group", groupInfo: makeGroupInfo(GROK_LOCAL_GROUP_ID)}, chatItem: ci}],
}
await bot.onGrokNewChatItems(grokEvt)
expect(grokApi.calls.length).toBe(0)
})
test("Grok ignores own messages (groupSnd)", async () => {
chat.groups.set(GROK_LOCAL_GROUP_ID, makeGroupInfo(GROK_LOCAL_GROUP_ID))
const ci = makeChatItem({dir: "groupSnd", text: "My own response"})
const grokEvt = {
type: "newChatItems" as const,
user: makeUser(GROK_USER_ID),
chatItems: [{chatInfo: {type: "group", groupInfo: makeGroupInfo(GROK_LOCAL_GROUP_ID)}, chatItem: ci}],
}
await bot.onGrokNewChatItems(grokEvt)
expect(grokApi.calls.length).toBe(0)
})
})
describe("/team Activation", () => {
beforeEach(() => setup())
test("/team from QUEUE → ALL team members added, teamAddedMessage sent", async () => {
await reachQueue()
addBotMessage("The team can see your message")
await bot.onNewChatItems(customerMessage("/team"))
expectMemberAdded(CUSTOMER_GROUP_ID, TEAM_MEMBER_1_ID)
expectMemberAdded(CUSTOMER_GROUP_ID, TEAM_MEMBER_2_ID)
expectSentToGroup(CUSTOMER_GROUP_ID, "team member has been added")
})
test("/team as first message → WELCOME→TEAM, no queue message", async () => {
await bot.onNewChatItems(customerMessage("/team"))
expectSentToGroup(CUSTOMER_GROUP_ID, "team member has been added")
expectNotSentToGroup(CUSTOMER_GROUP_ID, "The team can see your message")
})
test("/team when already activated → teamAlreadyInvitedMessage", async () => {
await reachTeamPending()
addBotMessage("A team member has been added")
chat.members.set(CUSTOMER_GROUP_ID, [makeTeamMember(TEAM_MEMBER_1_ID, "Alice")])
await bot.onNewChatItems(customerMessage("/team"))
expectSentToGroup(CUSTOMER_GROUP_ID, "already been invited")
})
test("/team with no team members → noTeamMembersMessage", async () => {
setup({teamMembers: []})
await reachQueue()
addBotMessage("The team can see your message")
await bot.onNewChatItems(customerMessage("/team"))
expectSentToGroup(CUSTOMER_GROUP_ID, "No team members are available")
})
})
describe("One-Way Gate", () => {
beforeEach(() => setup())
test("team member sends first TEXT → Grok removed if present", async () => {
await reachTeamPending()
addBotMessage("A team member has been added")
chat.members.set(CUSTOMER_GROUP_ID, [makeGrokMember(), makeTeamMember(TEAM_MEMBER_1_ID, "Alice")])
await bot.onNewChatItems(teamMemberMessage("Hi, how can I help?"))
expect(chat.removed.some(r => r.groupId === CUSTOMER_GROUP_ID && r.memberIds.includes(7777))).toBe(true)
})
test("team member non-text (no ciContentText) → Grok NOT removed", async () => {
await reachTeamPending()
addBotMessage("A team member has been added")
chat.members.set(CUSTOMER_GROUP_ID, [makeGrokMember()])
await bot.onNewChatItems(teamMemberMessage("", TEAM_MEMBER_1_ID))
expect(chat.removed.length).toBe(0)
})
test("/grok after gate → teamLockedMessage", async () => {
await reachTeam()
await bot.onNewChatItems(customerMessage("/grok"))
expectSentToGroup(CUSTOMER_GROUP_ID, "team mode")
})
test("customer text in TEAM → card update scheduled, no bot reply", async () => {
await reachTeam()
const sentBefore = chat.sentTo(CUSTOMER_GROUP_ID).length
await bot.onNewChatItems(customerMessage("Follow-up question"))
const sentAfter = chat.sentTo(CUSTOMER_GROUP_ID).length
expect(sentAfter).toBe(sentBefore)
})
test("/grok in TEAM-PENDING → invite Grok if not present", async () => {
await reachTeamPending()
addBotMessage("A team member has been added")
chat.members.set(CUSTOMER_GROUP_ID, [makeTeamMember(TEAM_MEMBER_1_ID, "Alice")])
const joinPromise = simulateGrokJoinSuccess()
await bot.onNewChatItems(customerMessage("/grok"))
await joinPromise
await bot.flush()
expectMemberAdded(CUSTOMER_GROUP_ID, GROK_CONTACT_ID)
})
})
describe("Team Member Lifecycle", () => {
beforeEach(() => setup())
test("team member connected → promoted to Owner", async () => {
await bot.onMemberConnected(connectedEvent(CUSTOMER_GROUP_ID, makeTeamMember(TEAM_MEMBER_1_ID, "Alice")))
expect(chat.roleChanges.some(r => r.groupId === CUSTOMER_GROUP_ID && r.memberIds.includes(5000 + TEAM_MEMBER_1_ID) && r.role === GroupMemberRole.Owner)).toBe(true)
})
test("customer connected → NOT promoted to Owner", async () => {
await bot.onMemberConnected(connectedEvent(CUSTOMER_GROUP_ID, makeCustomerMember()))
expect(chat.roleChanges.length).toBe(0)
})
test("Grok connected → NOT promoted to Owner", async () => {
await bot.onMemberConnected(connectedEvent(CUSTOMER_GROUP_ID, makeGrokMember()))
expect(chat.roleChanges.length).toBe(0)
})
test("all team members leave before sending → reverts to QUEUE", async () => {
await reachTeamPending()
addBotMessage("A team member has been added")
// Remove team members from the group
chat.members.set(CUSTOMER_GROUP_ID, [])
// Customer sends another message — state should derive as QUEUE (no team members)
const state = await cards.deriveState(CUSTOMER_GROUP_ID)
expect(state).toBe("QUEUE")
})
test("/team after all team members left (TEAM-PENDING, no msg sent) → re-adds members", async () => {
await reachTeamPending()
addBotMessage("A team member has been added")
chat.members.set(CUSTOMER_GROUP_ID, [])
chat.added.length = 0
await bot.onNewChatItems(customerMessage("/team"))
expectSentToGroup(CUSTOMER_GROUP_ID, "team member has been added")
expectMemberAdded(CUSTOMER_GROUP_ID, TEAM_MEMBER_1_ID)
})
test("/team after all team members left (TEAM, msg was sent) → re-adds members", async () => {
await reachTeamPending()
addBotMessage("A team member has been added")
chat.members.set(CUSTOMER_GROUP_ID, [makeTeamMember(TEAM_MEMBER_1_ID, "Alice")])
addTeamMemberMessageToHistory("Hi, how can I help?", TEAM_MEMBER_1_ID)
await bot.onNewChatItems(teamMemberMessage("Hi, how can I help?"))
// All team members leave
chat.members.set(CUSTOMER_GROUP_ID, [])
chat.added.length = 0
await bot.onNewChatItems(customerMessage("/team"))
expectSentToGroup(CUSTOMER_GROUP_ID, "team member has been added")
expectMemberAdded(CUSTOMER_GROUP_ID, TEAM_MEMBER_1_ID)
})
})
describe("Card Dashboard", () => {
beforeEach(() => setup())
test("first message creates card with customer name and /join command", async () => {
await bot.onNewChatItems(customerMessage("Hello"))
const teamMsgs = chat.sentTo(TEAM_GROUP_ID)
expect(teamMsgs.length).toBeGreaterThan(0)
const card = teamMsgs[teamMsgs.length - 1]
expect(card).toContain(`/join ${CUSTOMER_GROUP_ID}:`)
})
test("card /join uses single-quotes for names with spaces", async () => {
const groupInfo = makeGroupInfo(CUSTOMER_GROUP_ID, {displayName: "John Doe"})
chat.groups.set(CUSTOMER_GROUP_ID, groupInfo)
// Build event with correct groupInfo embedded
const ci = makeChatItem({dir: "groupRcv", text: "Hello", memberId: CUSTOMER_ID})
const evt = {
type: "newChatItems" as const,
user: makeUser(MAIN_USER_ID),
chatItems: [{chatInfo: {type: "group" as const, groupInfo}, chatItem: ci}],
}
await bot.onNewChatItems(evt)
const teamMsgs = chat.sentTo(TEAM_GROUP_ID)
expect(teamMsgs.some(m => m.includes(`/join ${CUSTOMER_GROUP_ID}:'John Doe'`))).toBe(true)
})
test("card update deletes old card then posts new one", async () => {
chat.customData.set(CUSTOMER_GROUP_ID, {cardItemId: 555})
await cards.flush()
expect(chat.deleted.length).toBe(0)
cards.scheduleUpdate(CUSTOMER_GROUP_ID)
await cards.flush()
expectCardDeleted(555)
expect(chat.sentTo(TEAM_GROUP_ID).length).toBeGreaterThan(0)
})
test("apiDeleteChatItems failure → ignored, new card posted", async () => {
chat.customData.set(CUSTOMER_GROUP_ID, {cardItemId: 555})
chat.apiDeleteChatItemsWillFail()
cards.scheduleUpdate(CUSTOMER_GROUP_ID)
await cards.flush()
// New card should still be posted despite delete failure
expect(chat.sentTo(TEAM_GROUP_ID).length).toBeGreaterThan(0)
})
test("customData stores cardItemId → survives flush cycle", async () => {
await bot.onNewChatItems(customerMessage("Hello"))
// After card creation, customData should have cardItemId
const data = chat.customData.get(CUSTOMER_GROUP_ID)
expect(data).toBeDefined()
expect(typeof data.cardItemId).toBe("number")
})
test("customer leaves → customData cleared", async () => {
await bot.onNewChatItems(customerMessage("Hello"))
chat.customData.set(CUSTOMER_GROUP_ID, {cardItemId: 999})
await bot.onLeftMember(leftEvent(CUSTOMER_GROUP_ID, makeCustomerMember()))
expect(chat.customData.has(CUSTOMER_GROUP_ID)).toBe(false)
})
})
describe("Card Debouncing", () => {
beforeEach(() => setup())
test("rapid events within flush interval → single card update on flush", async () => {
chat.customData.set(CUSTOMER_GROUP_ID, {cardItemId: 500})
cards.scheduleUpdate(CUSTOMER_GROUP_ID)
cards.scheduleUpdate(CUSTOMER_GROUP_ID)
cards.scheduleUpdate(CUSTOMER_GROUP_ID)
await cards.flush()
// Only one delete and one post
expect(chat.deleted.length).toBe(1)
// Multiple schedules → single update (2 messages per card: text + /join)
const teamMsgs = chat.sentTo(TEAM_GROUP_ID)
expect(teamMsgs.length).toBe(2)
})
test("multiple groups pending → each reposted once per flush", async () => {
const GROUP_A = 101
const GROUP_B = 102
chat.groups.set(GROUP_A, makeGroupInfo(GROUP_A))
chat.groups.set(GROUP_B, makeGroupInfo(GROUP_B))
chat.customData.set(GROUP_A, {cardItemId: 501})
chat.customData.set(GROUP_B, {cardItemId: 502})
cards.scheduleUpdate(GROUP_A)
cards.scheduleUpdate(GROUP_B)
await cards.flush()
expect(chat.deleted.length).toBe(2)
})
test("card create is immediate (not debounced)", async () => {
await bot.onNewChatItems(customerMessage("Hello"))
// Card should be posted immediately without flush
expect(chat.sentTo(TEAM_GROUP_ID).length).toBeGreaterThan(0)
})
test("flush with no pending updates → no-op", async () => {
await cards.flush()
expect(chat.deleted.length).toBe(0)
expect(chat.sentTo(TEAM_GROUP_ID).length).toBe(0)
})
})
describe("Card Format & State Derivation", () => {
beforeEach(() => setup())
test("QUEUE state derived when no Grok or team members", async () => {
addBotMessage("The team can see your message")
const state = await cards.deriveState(CUSTOMER_GROUP_ID)
expect(state).toBe("QUEUE")
})
test("WELCOME state derived for first customer message (no bot messages yet)", async () => {
const state = await cards.deriveState(CUSTOMER_GROUP_ID)
expect(state).toBe("WELCOME")
})
test("GROK state derived when Grok member present", async () => {
chat.members.set(CUSTOMER_GROUP_ID, [makeGrokMember()])
const state = await cards.deriveState(CUSTOMER_GROUP_ID)
expect(state).toBe("GROK")
})
test("TEAM-PENDING derived when team member present but no team message", async () => {
chat.members.set(CUSTOMER_GROUP_ID, [makeTeamMember(TEAM_MEMBER_1_ID, "Alice")])
const state = await cards.deriveState(CUSTOMER_GROUP_ID)
expect(state).toBe("TEAM-PENDING")
})
test("TEAM derived when team member present AND has sent a message", async () => {
chat.members.set(CUSTOMER_GROUP_ID, [makeTeamMember(TEAM_MEMBER_1_ID, "Alice")])
addTeamMemberMessageToHistory("Hi!", TEAM_MEMBER_1_ID)
const state = await cards.deriveState(CUSTOMER_GROUP_ID)
expect(state).toBe("TEAM")
})
test("message count excludes bot's own messages", async () => {
addCustomerMessageToHistory("Hello")
addBotMessage("Queue message")
addCustomerMessageToHistory("Follow-up")
const chatResult = await cards.getChat(CUSTOMER_GROUP_ID, 100)
const nonBotCount = chatResult.chatItems.filter((ci: any) => ci.chatDir.type !== "groupSnd").length
expect(nonBotCount).toBe(2)
})
})
describe("/join Command (Team Group)", () => {
beforeEach(() => setup())
test("/join groupId:name → team member added to customer group", async () => {
await bot.onNewChatItems(teamGroupMessage(`/join ${CUSTOMER_GROUP_ID}:Customer`))
expectMemberAdded(CUSTOMER_GROUP_ID, TEAM_MEMBER_1_ID)
})
test("/join validates target is business group → error if not", async () => {
const nonBizGroupId = 999
chat.groups.set(nonBizGroupId, makeGroupInfo(nonBizGroupId, {businessChat: null}))
await bot.onNewChatItems(teamGroupMessage(`/join ${nonBizGroupId}:Test`))
expectSentToGroup(TEAM_GROUP_ID, "not a business chat")
})
test("/join with non-existent groupId → error in team group", async () => {
await bot.onNewChatItems(teamGroupMessage("/join 99999:Nobody"))
expect(chat.sentTo(TEAM_GROUP_ID).some(m => m.toLowerCase().includes("error"))).toBe(true)
})
test("customer sending /join in customer group → treated as normal message", async () => {
await bot.onNewChatItems(customerMessage("/join 50:Test"))
expectSentToGroup(CUSTOMER_GROUP_ID, "The team can see your message")
})
})
describe("DM Handshake", () => {
beforeEach(() => setup())
test("team member joins team group → DM sent with contact ID", async () => {
const member = {memberId: "new-team", groupMemberId: 8000, memberContactId: 30, memberStatus: GroupMemberStatus.Connected, memberProfile: {displayName: "Charlie"}}
await bot.onMemberConnected(connectedEvent(TEAM_GROUP_ID, member, {contactId: 30, profile: {displayName: "Charlie"}}))
expectDmSent(30, "Your contact ID is 30:Charlie")
})
test("DM with spaces in name → name single-quoted", async () => {
const member = {memberId: "new-team", groupMemberId: 8001, memberContactId: 31, memberStatus: GroupMemberStatus.Connected, memberProfile: {displayName: "Charlie Brown"}}
await bot.onMemberConnected(connectedEvent(TEAM_GROUP_ID, member, {contactId: 31, profile: {displayName: "Charlie Brown"}}))
expectDmSent(31, "31:'Charlie Brown'")
})
test("pending DM delivered on contactConnected", async () => {
const invEvt = {
type: "newMemberContactReceivedInv" as const,
user: makeUser(MAIN_USER_ID),
contact: {contactId: 32, profile: {displayName: "Dave"}},
groupInfo: makeGroupInfo(TEAM_GROUP_ID, {businessChat: null}),
member: {memberId: "dave-member", groupMemberId: 8002, memberContactId: 32, memberStatus: GroupMemberStatus.Connected, memberProfile: {displayName: "Dave"}},
}
await bot.onMemberContactReceivedInv(invEvt)
await bot.onContactConnected({
type: "contactConnected" as const,
user: makeUser(MAIN_USER_ID),
contact: {contactId: 32, profile: {displayName: "Dave"}},
})
expectDmSent(32, "Your contact ID is 32:Dave")
})
test("team member with no DM contact → creates member contact and sends invitation", async () => {
const member = {memberId: "new-team-no-dm", groupMemberId: 8010, memberContactId: null, memberStatus: GroupMemberStatus.Connected, memberProfile: {displayName: "Frank"}}
await bot.onMemberConnected(connectedEvent(TEAM_GROUP_ID, member, undefined))
expectRawCmd("/_create member contact #50 8010")
expect(chat.rawCmds.some(c => c.includes("/_invite member contact @") && c.includes("Your contact ID is"))).toBe(true)
const dms = chat.sent.filter(s => s.chat[0] === ChatType.Direct)
expect(dms.some(m => m.text.includes("Your contact ID is") && m.text.includes("Frank"))).toBe(true)
})
test("joinedGroupMember in team group → creates member contact and sends invitation", async () => {
const member = {memberId: "link-joiner", groupMemberId: 8020, memberContactId: null, memberStatus: GroupMemberStatus.Connected, memberProfile: {displayName: "Grace"}}
await bot.onJoinedGroupMember(joinedEvent(TEAM_GROUP_ID, member))
expectRawCmd("/_create member contact #50 8020")
expect(chat.rawCmds.some(c => c.includes("/_invite member contact @") && c.includes("Grace"))).toBe(true)
})
test("no duplicate DM when both sendTeamMemberDM succeeds and onMemberContactReceivedInv fires", async () => {
const invEvt = {
type: "newMemberContactReceivedInv" as const,
user: makeUser(MAIN_USER_ID),
contact: {contactId: 33, profile: {displayName: "Eve"}},
groupInfo: makeGroupInfo(TEAM_GROUP_ID, {businessChat: null}),
member: {memberId: "eve-member", groupMemberId: 8003, memberContactId: 33, memberStatus: GroupMemberStatus.Connected, memberProfile: {displayName: "Eve"}},
}
await bot.onMemberContactReceivedInv(invEvt)
const eveMember = {memberId: "eve-member", groupMemberId: 8003, memberContactId: 33, memberStatus: GroupMemberStatus.Connected, memberProfile: {displayName: "Eve"}}
await bot.onMemberConnected(connectedEvent(TEAM_GROUP_ID, eveMember, {contactId: 33, profile: {displayName: "Eve"}}))
await bot.onContactConnected({
type: "contactConnected" as const,
user: makeUser(MAIN_USER_ID),
contact: {contactId: 33, profile: {displayName: "Eve"}},
})
const dms = chat.sentDirect(33)
const contactIdMsgs = dms.filter(m => m.includes("Your contact ID is 33:Eve"))
expect(contactIdMsgs.length).toBe(1)
})
})
describe("Direct Message Handling", () => {
beforeEach(() => setup())
test("regular DM → bot replies with business address link", async () => {
bot.businessAddress = "simplex:/contact#abc123"
await bot.onNewChatItems(directMessage("Hi there", 99))
expectDmSent(99, "simplex:/contact#abc123")
})
test("DM without business address set → no reply", async () => {
bot.businessAddress = null
await bot.onNewChatItems(directMessage("Hi there", 99))
expect(chat.sentDirect(99).length).toBe(0)
})
test("non-message DM event (e.g. contactConnected) → no reply", async () => {
bot.businessAddress = "simplex:/contact#abc123"
const ci = {
chatDir: {type: "directRcv"},
content: {type: "rcvDirectEvent"},
meta: {itemId: 9999, createdAt: new Date().toISOString()},
}
const evt = {
type: "newChatItems" as const,
user: makeUser(MAIN_USER_ID),
chatItems: [makeDirectAChatItem(ci, 99)],
}
await bot.onNewChatItems(evt)
expect(chat.sentDirect(99).length).toBe(0)
})
})
describe("Business Request Handler", () => {
beforeEach(() => setup())
test("acceptingBusinessRequest → enables file uploads AND visible history", async () => {
await bot.onBusinessRequest({
type: "acceptingBusinessRequest" as const,
user: makeUser(MAIN_USER_ID),
groupInfo: makeGroupInfo(CUSTOMER_GROUP_ID),
})
expect(chat.profileUpdates.some(u =>
u.groupId === CUSTOMER_GROUP_ID
&& u.profile.groupPreferences?.files?.enable === GroupFeatureEnabled.On
&& u.profile.groupPreferences?.history?.enable === GroupFeatureEnabled.On
)).toBe(true)
})
})
describe("chatItemUpdated Handler", () => {
beforeEach(() => setup())
test("chatItemUpdated in business group → card update scheduled", async () => {
await bot.onChatItemUpdated(updatedEvent(CUSTOMER_GROUP_ID, makeChatItem({dir: "groupRcv", text: "edited message", memberId: CUSTOMER_ID})))
chat.customData.set(CUSTOMER_GROUP_ID, {cardItemId: 600})
await cards.flush()
expectCardDeleted(600)
})
test("chatItemUpdated in non-business group → ignored", async () => {
await bot.onChatItemUpdated(updatedEvent(TEAM_GROUP_ID, makeChatItem({dir: "groupRcv", text: "team msg"})))
await cards.flush()
expect(chat.deleted.length).toBe(0)
})
test("chatItemUpdated from wrong user → ignored", async () => {
await bot.onChatItemUpdated(updatedEvent(CUSTOMER_GROUP_ID, makeChatItem({dir: "groupRcv", text: "edited"}), GROK_USER_ID))
await cards.flush()
expect(chat.deleted.length).toBe(0)
})
})
describe("Reactions", () => {
beforeEach(() => setup())
test("reaction in business group → card update scheduled", async () => {
await bot.onChatItemReaction(reactionEvent(CUSTOMER_GROUP_ID, true))
chat.customData.set(CUSTOMER_GROUP_ID, {cardItemId: 700})
await cards.flush()
expectCardDeleted(700)
})
test("reaction removed (added=false) → no card update", async () => {
await bot.onChatItemReaction(reactionEvent(CUSTOMER_GROUP_ID, false))
await cards.flush()
expect(chat.deleted.length).toBe(0)
})
})
describe("Customer Leave", () => {
beforeEach(() => setup())
test("customer leaves → customData cleared", async () => {
chat.customData.set(CUSTOMER_GROUP_ID, {cardItemId: 800})
await bot.onLeftMember(leftEvent(CUSTOMER_GROUP_ID, makeCustomerMember()))
expect(chat.customData.has(CUSTOMER_GROUP_ID)).toBe(false)
})
test("Grok leaves → in-memory maps cleaned", async () => {
await bot.onLeftMember(leftEvent(CUSTOMER_GROUP_ID, makeGrokMember()))
})
test("team member leaves → logged, no crash", async () => {
await bot.onLeftMember(leftEvent(CUSTOMER_GROUP_ID, makeTeamMember(TEAM_MEMBER_1_ID, "Alice")))
})
test("leftMember in non-business group → ignored", async () => {
const member = {memberId: "someone", groupMemberId: 9000, memberStatus: GroupMemberStatus.Connected, memberProfile: {displayName: "Someone"}}
await bot.onLeftMember(leftEvent(TEAM_GROUP_ID, member))
})
})
describe("Error Handling", () => {
beforeEach(() => setup())
test("apiAddMember fails (Grok invite) → grokUnavailableMessage", async () => {
await reachQueue()
addBotMessage("The team can see your message")
chat.apiAddMemberWillFail()
await bot.onNewChatItems(customerMessage("/grok"))
await bot.flush()
expectSentToGroup(CUSTOMER_GROUP_ID, "temporarily unavailable")
})
test("groupDuplicateMember on Grok invite → only inviting message, no result", async () => {
await reachQueue()
addBotMessage("The team can see your message")
chat.apiAddMemberWillFail({chatError: {errorType: {type: "groupDuplicateMember"}}})
const sentBefore = chat.sent.length
await bot.onNewChatItems(customerMessage("/grok"))
await bot.flush()
// Only the "Inviting Grok" message is sent — no activated/unavailable result
expect(chat.sent.length).toBe(sentBefore + 1)
expectSentToGroup(CUSTOMER_GROUP_ID, "Inviting Grok")
expectNotSentToGroup(CUSTOMER_GROUP_ID, "now chatting with Grok")
expectNotSentToGroup(CUSTOMER_GROUP_ID, "temporarily unavailable")
})
test("groupDuplicateMember on /team → apiListMembers fallback", async () => {
await reachQueue()
addBotMessage("The team can see your message")
// First team member add succeeds, second fails with groupDuplicateMember
let callCount = 0
const origAddMember = chat.apiAddMember.bind(chat)
chat.apiAddMember = async (groupId: number, contactId: number, role: string) => {
callCount++
if (callCount === 2) {
chat.members.set(groupId, [
{memberId: `team-${contactId}`, groupMemberId: 5000 + contactId, memberContactId: contactId, memberStatus: GroupMemberStatus.Connected, memberProfile: {displayName: `Contact${contactId}`}},
])
throw {chatError: {errorType: {type: "groupDuplicateMember"}}}
}
return origAddMember(groupId, contactId, role)
}
await bot.onNewChatItems(customerMessage("/team"))
expectSentToGroup(CUSTOMER_GROUP_ID, "team member has been added")
})
})
describe("Profile / Event Filtering", () => {
beforeEach(() => setup())
test("newChatItems from Grok profile → ignored by main handler", async () => {
const evt = {
type: "newChatItems" as const,
user: makeUser(GROK_USER_ID),
chatItems: [makeAChatItem(makeChatItem({dir: "groupRcv", text: "test"}))],
}
const sentBefore = chat.sent.length
await bot.onNewChatItems(evt)
expect(chat.sent.length).toBe(sentBefore)
})
test("Grok events from main profile → ignored by Grok handlers", async () => {
const evt = {
type: "receivedGroupInvitation" as const,
user: makeUser(MAIN_USER_ID),
groupInfo: makeGroupInfo(300),
contact: {contactId: 1},
fromMemberRole: GroupMemberRole.Admin,
memberRole: GroupMemberRole.Member,
}
await bot.onGrokGroupInvitation(evt)
expect(chat.joined.length).toBe(0)
})
test("own messages (groupSnd) → ignored", async () => {
const ci = makeChatItem({dir: "groupSnd", text: "Bot message"})
const evt = {
type: "newChatItems" as const,
user: makeUser(MAIN_USER_ID),
chatItems: [makeAChatItem(ci)],
}
const sentBefore = chat.sent.length
await bot.onNewChatItems(evt)
expect(chat.sent.length).toBe(sentBefore)
})
test("non-business group messages → ignored", async () => {
const ci = makeChatItem({dir: "groupRcv", text: "test"})
const nonBizGroup = makeGroupInfo(999, {businessChat: null})
const evt = {
type: "newChatItems" as const,
user: makeUser(MAIN_USER_ID),
chatItems: [{chatInfo: {type: "group", groupInfo: nonBizGroup}, chatItem: ci}],
}
const sentBefore = chat.sent.length
await bot.onNewChatItems(evt)
expect(chat.sent.length).toBe(sentBefore)
})
})
describe("Grok Join Flow", () => {
beforeEach(() => setup())
test("Grok receivedGroupInvitation → apiJoinGroup called", async () => {
// First need to set up a pending grok join
// Simulate the main profile side: add Grok to a group
await reachQueue()
addBotMessage("The team can see your message")
// This kicks off activateGrok which adds member and waits
const joinComplete = new Promise<void>(async (resolve) => {
// Simulate Grok invitation after a small delay
setTimeout(async () => {
const addedGrok = chat.added.find(a => a.contactId === GROK_CONTACT_ID)
if (addedGrok) {
const memberId = `member-${GROK_CONTACT_ID}`
await bot.onGrokGroupInvitation({
type: "receivedGroupInvitation",
user: makeUser(GROK_USER_ID),
groupInfo: {...makeGroupInfo(GROK_LOCAL_GROUP_ID), membership: {memberId}},
contact: {contactId: 99},
fromMemberRole: GroupMemberRole.Admin,
memberRole: GroupMemberRole.Member,
})
}
resolve()
}, 10)
})
// Don't await bot.onNewChatItems yet — let it start
const botPromise = bot.onNewChatItems(customerMessage("/grok"))
await joinComplete
// Complete the join
await bot.onGrokMemberConnected({
type: "connectedToGroupMember",
user: makeUser(GROK_USER_ID),
groupInfo: makeGroupInfo(GROK_LOCAL_GROUP_ID),
member: {memberId: "bot-in-grok-view", groupMemberId: 9999},
})
await botPromise
expect(chat.joined).toContain(GROK_LOCAL_GROUP_ID)
})
test("unmatched Grok invitation → buffered, not joined", async () => {
const evt = {
type: "receivedGroupInvitation" as const,
user: makeUser(GROK_USER_ID),
groupInfo: {...makeGroupInfo(999), membership: {memberId: "unknown-member"}},
contact: {contactId: 99},
fromMemberRole: GroupMemberRole.Admin,
memberRole: GroupMemberRole.Member,
}
await bot.onGrokGroupInvitation(evt)
expect(chat.joined.length).toBe(0)
})
test("buffered invitation drained after pendingGrokJoins set → apiJoinGroup called", async () => {
// Simulate the race: invitation arrives before pendingGrokJoins is set
const memberId = `member-${GROK_CONTACT_ID}`
const invEvt = {
type: "receivedGroupInvitation" as const,
user: makeUser(GROK_USER_ID),
groupInfo: {...makeGroupInfo(GROK_LOCAL_GROUP_ID), membership: {memberId}},
contact: {contactId: 99},
fromMemberRole: GroupMemberRole.Admin,
memberRole: GroupMemberRole.Member,
}
// Buffer the invitation (no pending join registered yet)
await bot.onGrokGroupInvitation(invEvt)
expect(chat.joined.length).toBe(0)
// Now trigger activateGrok — apiAddMember returns, pendingGrokJoins set, buffer drained
const joinComplete = new Promise<void>((resolve) => {
setTimeout(async () => {
// Grok connected after buffer drain processed the invitation
await bot.onGrokMemberConnected({
type: "connectedToGroupMember",
user: makeUser(GROK_USER_ID),
groupInfo: makeGroupInfo(GROK_LOCAL_GROUP_ID),
member: {memberId: "bot-in-grok-view", groupMemberId: 9999},
})
resolve()
}, 20)
})
await reachQueue()
addBotMessage("The team can see your message")
const botPromise = bot.onNewChatItems(customerMessage("/grok"))
await joinComplete
await botPromise
await bot.flush()
expect(chat.joined).toContain(GROK_LOCAL_GROUP_ID)
expectSentToGroup(CUSTOMER_GROUP_ID, "now chatting with Grok")
})
})
describe("Grok No-History Fallback", () => {
beforeEach(() => setup())
test("Grok joins but sees no customer messages → sends grokNoHistoryMessage", async () => {
chat.chatItems.set(GROK_LOCAL_GROUP_ID, [])
chat.groups.set(GROK_LOCAL_GROUP_ID, makeGroupInfo(GROK_LOCAL_GROUP_ID))
const grokJoinPromise = simulateGrokJoinSuccess()
await bot.onNewChatItems(customerMessage("/grok"))
await grokJoinPromise
await bot.flush()
expectAnySent("couldn't see your earlier messages")
})
})
describe("Non-customer messages trigger card update", () => {
beforeEach(() => setup())
test("Grok response in customer group → card update scheduled", async () => {
await bot.onNewChatItems(grokResponseMessage("Grok says hi"))
chat.customData.set(CUSTOMER_GROUP_ID, {cardItemId: 900})
await cards.flush()
expectCardDeleted(900)
})
test("team member message → card update scheduled", async () => {
await bot.onNewChatItems(teamMemberMessage("Team says hi"))
chat.customData.set(CUSTOMER_GROUP_ID, {cardItemId: 901})
await cards.flush()
expectCardDeleted(901)
})
})
describe("End-to-End Flows", () => {
beforeEach(() => setup())
test("WELCOME → QUEUE → /team → TEAM-PENDING → team msg → TEAM", async () => {
await bot.onNewChatItems(customerMessage("Help me"))
expectSentToGroup(CUSTOMER_GROUP_ID, "The team can see your message")
addBotMessage("The team can see your message")
await bot.onNewChatItems(customerMessage("/team"))
expectSentToGroup(CUSTOMER_GROUP_ID, "team member has been added")
expectMemberAdded(CUSTOMER_GROUP_ID, TEAM_MEMBER_1_ID)
addBotMessage("A team member has been added")
chat.members.set(CUSTOMER_GROUP_ID, [makeTeamMember(TEAM_MEMBER_1_ID, "Alice")])
const pendingState = await cards.deriveState(CUSTOMER_GROUP_ID)
expect(pendingState).toBe("TEAM-PENDING")
addTeamMemberMessageToHistory("I'll help you", TEAM_MEMBER_1_ID)
await bot.onNewChatItems(teamMemberMessage("I'll help you"))
const teamState = await cards.deriveState(CUSTOMER_GROUP_ID)
expect(teamState).toBe("TEAM")
})
test("WELCOME → /grok first msg → GROK", async () => {
const joinPromise = simulateGrokJoinSuccess()
await bot.onNewChatItems(customerMessage("/grok"))
await joinPromise
await bot.flush()
expectSentToGroup(CUSTOMER_GROUP_ID, "now chatting with Grok")
expectNotSentToGroup(CUSTOMER_GROUP_ID, "The team can see your message")
expect(chat.sentTo(TEAM_GROUP_ID).length).toBeGreaterThan(0)
})
test("multiple concurrent conversations are independent", async () => {
const GROUP_A = 101
const GROUP_B = 102
chat.groups.set(GROUP_A, makeGroupInfo(GROUP_A, {customerId: "cust-a"}))
chat.groups.set(GROUP_B, makeGroupInfo(GROUP_B, {customerId: "cust-b"}))
const ciA = makeChatItem({dir: "groupRcv", text: "Help A", memberId: "cust-a"})
await bot.onNewChatItems({
type: "newChatItems",
user: makeUser(MAIN_USER_ID),
chatItems: [{chatInfo: {type: "group", groupInfo: makeGroupInfo(GROUP_A, {customerId: "cust-a"})}, chatItem: ciA}],
})
const ciB = makeChatItem({dir: "groupRcv", text: "Help B", memberId: "cust-b"})
await bot.onNewChatItems({
type: "newChatItems",
user: makeUser(MAIN_USER_ID),
chatItems: [{chatInfo: {type: "group", groupInfo: makeGroupInfo(GROUP_B, {customerId: "cust-b"})}, chatItem: ciB}],
})
expectSentToGroup(GROUP_A, "The team can see your message")
expectSentToGroup(GROUP_B, "The team can see your message")
})
})
describe("Message Templates", () => {
test("welcomeMessage includes group links when provided", () => {
const msg = welcomeMessage("https://simplex.chat/group")
expect(msg).toContain("https://simplex.chat/group")
expect(msg).toContain("Join public groups")
})
test("welcomeMessage omits group links line when empty", () => {
const msg = welcomeMessage("")
expect(msg).not.toContain("Join public groups")
})
test("grokActivatedMessage mentions Grok can see earlier messages", () => {
expect(grokActivatedMessage).toContain("Grok can see your earlier messages")
})
test("teamLockedMessage mentions team mode", () => {
expect(teamLockedMessage).toContain("team mode")
})
test("queueMessage mentions hours", () => {
const msg = queueMessage("UTC")
expect(msg).toContain("hours")
})
})
describe("isFirstCustomerMessage detection", () => {
beforeEach(() => setup())
test("detects 'The team can see your message' as queue message", async () => {
addBotMessage("The team can see your message. A reply may take up to 24 hours.")
const isFirst = await cards.isFirstCustomerMessage(CUSTOMER_GROUP_ID)
expect(isFirst).toBe(false)
})
test("detects 'now chatting with Grok' as grok activation", async () => {
addBotMessage("You are now chatting with Grok.")
const isFirst = await cards.isFirstCustomerMessage(CUSTOMER_GROUP_ID)
expect(isFirst).toBe(false)
})
test("detects 'team member has been added' as team activation", async () => {
addBotMessage("A team member has been added and will reply within 24 hours.")
const isFirst = await cards.isFirstCustomerMessage(CUSTOMER_GROUP_ID)
expect(isFirst).toBe(false)
})
test("detects 'team member has already been invited'", async () => {
addBotMessage("A team member has already been invited to this conversation.")
const isFirst = await cards.isFirstCustomerMessage(CUSTOMER_GROUP_ID)
expect(isFirst).toBe(false)
})
test("returns true when no bot messages present", async () => {
const isFirst = await cards.isFirstCustomerMessage(CUSTOMER_GROUP_ID)
expect(isFirst).toBe(true)
})
test("returns true when only unrelated bot messages present", async () => {
addBotMessage("Some other message")
const isFirst = await cards.isFirstCustomerMessage(CUSTOMER_GROUP_ID)
expect(isFirst).toBe(true)
})
})
describe("Card Preview Sender Prefixes", () => {
beforeEach(() => setup())
// Helper: extract preview line from card text posted to team group
function getCardPreview(): string {
const teamMsgs = chat.sentTo(TEAM_GROUP_ID)
// Card text is the first sent message; /join command is the second
const cardText = teamMsgs[0]
if (!cardText) return ""
const lines = cardText.split("\n")
// Preview is the last line of the card
return lines[lines.length - 1] || ""
}
test("customer-only messages: first prefixed, rest not", async () => {
const gi = makeGroupInfo(CUSTOMER_GROUP_ID, {displayName: "Alice"})
chat.groups.set(CUSTOMER_GROUP_ID, gi)
addCustomerMessageToHistory("Hello")
addCustomerMessageToHistory("Need help")
await cards.createCard(CUSTOMER_GROUP_ID, gi)
const preview = getCardPreview()
expect(preview).toContain("Alice: Hello")
expect(preview).toContain("/ Need help")
// Second message must NOT have prefix (same sender)
expect(preview).not.toContain("Alice: Need help")
})
test("three consecutive customer messages: only first gets prefix", async () => {
const gi = makeGroupInfo(CUSTOMER_GROUP_ID, {displayName: "Alice"})
chat.groups.set(CUSTOMER_GROUP_ID, gi)
addCustomerMessageToHistory("First")
addCustomerMessageToHistory("Second")
addCustomerMessageToHistory("Third")
await cards.createCard(CUSTOMER_GROUP_ID, gi)
const preview = getCardPreview()
const prefixCount = (preview.match(/Alice:/g) || []).length
expect(prefixCount).toBe(1)
expect(preview).toContain("Alice: First")
})
test("alternating customer and Grok: each sender change triggers prefix", async () => {
const gi = makeGroupInfo(CUSTOMER_GROUP_ID, {displayName: "Alice"})
chat.groups.set(CUSTOMER_GROUP_ID, gi)
addCustomerMessageToHistory("How does encryption work?")
addGrokMessageToHistory("SimpleX uses double ratchet")
addCustomerMessageToHistory("And metadata?")
await cards.createCard(CUSTOMER_GROUP_ID, gi)
const preview = getCardPreview()
expect(preview).toContain("Alice: How does encryption work?")
expect(preview).toContain("Grok: SimpleX uses double ratchet")
expect(preview).toContain("Alice: And metadata?")
})
test("Grok identified by grokContactId, not by display name", async () => {
const gi = makeGroupInfo(CUSTOMER_GROUP_ID, {displayName: "Alice"})
chat.groups.set(CUSTOMER_GROUP_ID, gi)
// Grok message uses GROK_CONTACT_ID → labeled "Grok" regardless of memberProfile
addGrokMessageToHistory("I am Grok")
await cards.createCard(CUSTOMER_GROUP_ID, gi)
const preview = getCardPreview()
expect(preview).toContain("Grok: I am Grok")
})
test("team member messages use their memberProfile displayName", async () => {
const gi = makeGroupInfo(CUSTOMER_GROUP_ID, {displayName: "Alice"})
chat.groups.set(CUSTOMER_GROUP_ID, gi)
addCustomerMessageToHistory("Help please")
// Add team member message with explicit display name
const teamCi = makeChatItem({
dir: "groupRcv", text: "On it!",
memberId: `team-${TEAM_MEMBER_1_ID}`, memberContactId: TEAM_MEMBER_1_ID,
memberDisplayName: "Bob",
})
const items = chat.chatItems.get(CUSTOMER_GROUP_ID) || []
items.push(teamCi)
chat.chatItems.set(CUSTOMER_GROUP_ID, items)
await cards.createCard(CUSTOMER_GROUP_ID, gi)
const preview = getCardPreview()
expect(preview).toContain("Alice: Help please")
expect(preview).toContain("Bob: On it!")
})
test("bot messages (groupSnd) excluded from preview", async () => {
const gi = makeGroupInfo(CUSTOMER_GROUP_ID, {displayName: "Alice"})
chat.groups.set(CUSTOMER_GROUP_ID, gi)
addCustomerMessageToHistory("Hello")
addBotMessage("The team can see your message")
addCustomerMessageToHistory("Thanks")
await cards.createCard(CUSTOMER_GROUP_ID, gi)
const preview = getCardPreview()
expect(preview).not.toContain("The team can see your message")
// Both customer messages are from the same sender — only first prefixed
expect(preview).toContain("Alice: Hello")
expect(preview).toContain("/ Thanks")
})
test("media-only message shows type label", async () => {
const gi = makeGroupInfo(CUSTOMER_GROUP_ID, {displayName: "Alice"})
chat.groups.set(CUSTOMER_GROUP_ID, gi)
const imgCi = makeChatItem({dir: "groupRcv", text: "", memberId: CUSTOMER_ID, msgType: "image"})
const items = chat.chatItems.get(CUSTOMER_GROUP_ID) || []
items.push(imgCi)
chat.chatItems.set(CUSTOMER_GROUP_ID, items)
await cards.createCard(CUSTOMER_GROUP_ID, gi)
const preview = getCardPreview()
expect(preview).toContain("[image]")
})
test("media message with caption shows label + text", async () => {
const gi = makeGroupInfo(CUSTOMER_GROUP_ID, {displayName: "Alice"})
chat.groups.set(CUSTOMER_GROUP_ID, gi)
const imgCi = makeChatItem({dir: "groupRcv", text: "screenshot of the bug", memberId: CUSTOMER_ID, msgType: "image"})
const items = chat.chatItems.get(CUSTOMER_GROUP_ID) || []
items.push(imgCi)
chat.chatItems.set(CUSTOMER_GROUP_ID, items)
await cards.createCard(CUSTOMER_GROUP_ID, gi)
const preview = getCardPreview()
expect(preview).toContain("[image] screenshot of the bug")
})
test("long message truncated with [truncated]", async () => {
const gi = makeGroupInfo(CUSTOMER_GROUP_ID, {displayName: "Alice"})
chat.groups.set(CUSTOMER_GROUP_ID, gi)
const longMsg = "x".repeat(300)
addCustomerMessageToHistory(longMsg)
await cards.createCard(CUSTOMER_GROUP_ID, gi)
const preview = getCardPreview()
expect(preview).toContain("[truncated]")
// Truncated at ~200 chars + prefix
expect(preview.length).toBeLessThan(300)
})
test("total overflow truncates oldest messages, keeps newest", async () => {
const gi = makeGroupInfo(CUSTOMER_GROUP_ID, {displayName: "Alice"})
chat.groups.set(CUSTOMER_GROUP_ID, gi)
// Add many messages to exceed 1000 chars total
for (let i = 0; i < 20; i++) {
addCustomerMessageToHistory(`Message number ${i} with some extra padding text to fill space quickly`)
}
await cards.createCard(CUSTOMER_GROUP_ID, gi)
const preview = getCardPreview()
expect(preview).toContain("[truncated]")
// Newest messages should be present, oldest truncated
expect(preview).toContain("Message number 19")
expect(preview).not.toContain("Message number 0")
// Should not include all 20 messages
const slashCount = (preview.match(/ \/ /g) || []).length
expect(slashCount).toBeLessThan(19)
})
test("empty preview when no messages", async () => {
const gi = makeGroupInfo(CUSTOMER_GROUP_ID, {displayName: "Alice"})
chat.groups.set(CUSTOMER_GROUP_ID, gi)
await cards.createCard(CUSTOMER_GROUP_ID, gi)
const preview = getCardPreview()
expect(preview).toBe('""')
})
test("only bot messages → empty preview", async () => {
const gi = makeGroupInfo(CUSTOMER_GROUP_ID, {displayName: "Alice"})
chat.groups.set(CUSTOMER_GROUP_ID, gi)
addBotMessage("Welcome!")
addBotMessage("Queue message")
await cards.createCard(CUSTOMER_GROUP_ID, gi)
const preview = getCardPreview()
expect(preview).toBe('""')
})
test("newlines in message text → replaced with spaces", async () => {
const gi = makeGroupInfo(CUSTOMER_GROUP_ID, {displayName: "Alice"})
chat.groups.set(CUSTOMER_GROUP_ID, gi)
addCustomerMessageToHistory("line1\nline2\n\nline3")
await cards.createCard(CUSTOMER_GROUP_ID, gi)
const preview = getCardPreview()
expect(preview).not.toContain("\n")
expect(preview).toContain("line1 line2 line3")
})
test("newlines in customer display name → sanitized in card header, raw in /join", async () => {
const gi = makeGroupInfo(CUSTOMER_GROUP_ID, {displayName: "First\nLast"})
chat.groups.set(CUSTOMER_GROUP_ID, gi)
addCustomerMessageToHistory("Hello")
await cards.createCard(CUSTOMER_GROUP_ID, gi)
const teamMsgs = chat.sentTo(TEAM_GROUP_ID)
const cardText = teamMsgs[0]
// Card header should have sanitized name (no newlines)
expect(cardText).toContain("First Last")
expect(cardText.split("\n").length).toBe(3) // exactly 3 lines: header, state, preview
// /join command (second message) should use raw name
const joinCmd = teamMsgs[1]
expect(joinCmd).toContain("First\nLast")
})
})
describe("Restart Card Recovery", () => {
beforeEach(() => setup())
test("refreshAllCards refreshes groups with active cards", async () => {
const GROUP_A = 101
const GROUP_B = 102
const GROUP_NO_CARD = 103
chat.groups.set(GROUP_A, makeGroupInfo(GROUP_A))
chat.groups.set(GROUP_B, makeGroupInfo(GROUP_B))
chat.groups.set(GROUP_NO_CARD, makeGroupInfo(GROUP_NO_CARD))
chat.customData.set(GROUP_A, {cardItemId: 501, joinItemId: 502})
chat.customData.set(GROUP_B, {cardItemId: 503, joinItemId: 504})
await cards.refreshAllCards()
expectCardDeleted(501)
expectCardDeleted(503)
expect(chat.sentTo(TEAM_GROUP_ID).length).toBe(4) // 2 cards × 2 messages each
})
test("refreshAllCards with no active cards → no-op", async () => {
await cards.refreshAllCards()
expect(chat.deleted.length).toBe(0)
expect(chat.sentTo(TEAM_GROUP_ID).length).toBe(0)
})
test("refreshAllCards ignores groups without cardItemId in customData", async () => {
const GROUP_A = 101
chat.groups.set(GROUP_A, makeGroupInfo(GROUP_A))
chat.customData.set(GROUP_A, {someOtherData: true})
await cards.refreshAllCards()
expect(chat.deleted.length).toBe(0)
expect(chat.sentTo(TEAM_GROUP_ID).length).toBe(0)
})
test("refreshAllCards orders by cardItemId ascending (oldest first, newest last)", async () => {
// GROUP_C has higher cardItemId (more recent) than GROUP_A and GROUP_B
const GROUP_A = 101, GROUP_B = 102, GROUP_C = 103
chat.groups.set(GROUP_A, makeGroupInfo(GROUP_A))
chat.groups.set(GROUP_B, makeGroupInfo(GROUP_B))
chat.groups.set(GROUP_C, makeGroupInfo(GROUP_C))
chat.customData.set(GROUP_C, {cardItemId: 900}) // newest — should refresh last
chat.customData.set(GROUP_A, {cardItemId: 100}) // oldest — should refresh first
chat.customData.set(GROUP_B, {cardItemId: 500}) // middle
await cards.refreshAllCards()
// Verify deletion order: oldest cardItemId first, newest last
expect(chat.deleted.length).toBe(3)
expect(chat.deleted[0].itemIds).toEqual([100])
expect(chat.deleted[1].itemIds).toEqual([500])
expect(chat.deleted[2].itemIds).toEqual([900])
// Newest card's messages are posted last → appear at bottom of team group
const teamMsgs = chat.sentTo(TEAM_GROUP_ID)
expect(teamMsgs.length).toBe(6) // 3 cards × 2 messages each
})
test("refreshAllCards skips cards marked complete", async () => {
const GROUP_A = 101, GROUP_B = 102
chat.groups.set(GROUP_A, makeGroupInfo(GROUP_A))
chat.groups.set(GROUP_B, makeGroupInfo(GROUP_B))
chat.customData.set(GROUP_A, {cardItemId: 100, complete: true})
chat.customData.set(GROUP_B, {cardItemId: 200})
await cards.refreshAllCards()
expect(chat.deleted.length).toBe(1)
expect(chat.deleted[0].itemIds).toEqual([200])
expect(chat.deleted.some(d => d.itemIds.includes(100))).toBe(false)
})
test("refreshAllCards deletes old card before reposting", async () => {
const GROUP_A = 101
chat.groups.set(GROUP_A, makeGroupInfo(GROUP_A))
chat.customData.set(GROUP_A, {cardItemId: 501, joinItemId: 502})
await cards.refreshAllCards()
// Old card + join command should be deleted
expect(chat.deleted.length).toBe(1)
expect(chat.deleted[0].itemIds).toEqual([501, 502])
// New card posted
expect(chat.sentTo(TEAM_GROUP_ID).length).toBe(2)
})
test("refreshAllCards ignores delete failure (>24h old card)", async () => {
const GROUP_A = 101
chat.groups.set(GROUP_A, makeGroupInfo(GROUP_A))
chat.customData.set(GROUP_A, {cardItemId: 501})
chat.apiDeleteChatItemsWillFail()
await cards.refreshAllCards()
// Delete failed but new card still posted
expect(chat.sentTo(TEAM_GROUP_ID).length).toBe(2)
// customData updated with new cardItemId
const newData = chat.customData.get(GROUP_A)
expect(typeof newData.cardItemId).toBe("number")
expect(newData.cardItemId).not.toBe(501) // new ID, not the old one
})
test("card flush writes complete: true for auto-completed conversations", async () => {
const GROUP_A = 101
chat.groups.set(GROUP_A, makeGroupInfo(GROUP_A))
chat.members.set(GROUP_A, [makeTeamMember(TEAM_MEMBER_1_ID, "Alice")])
// Team member message from 4 hours ago (> completeHours=3h) → auto-complete
const oldCi = makeChatItem({dir: "groupRcv", text: "Resolved!", memberId: `team-${TEAM_MEMBER_1_ID}`, memberContactId: TEAM_MEMBER_1_ID})
oldCi.meta.createdAt = new Date(Date.now() - 4 * 3600_000).toISOString()
chat.chatItems.set(GROUP_A, [oldCi])
// Create initial card data
chat.customData.set(GROUP_A, {cardItemId: 500})
cards.scheduleUpdate(GROUP_A)
await cards.flush()
const data = chat.customData.get(GROUP_A)
expect(data.complete).toBe(true)
})
test("card flush clears complete flag when conversation becomes active again", async () => {
const GROUP_A = 101
chat.groups.set(GROUP_A, makeGroupInfo(GROUP_A))
chat.members.set(GROUP_A, [makeTeamMember(TEAM_MEMBER_1_ID, "Alice")])
// Team member message from 4h ago + recent customer message → NOT complete
const teamCi = makeChatItem({dir: "groupRcv", text: "Resolved!", memberId: `team-${TEAM_MEMBER_1_ID}`, memberContactId: TEAM_MEMBER_1_ID})
teamCi.meta.createdAt = new Date(Date.now() - 4 * 3600_000).toISOString()
const custCi = makeChatItem({dir: "groupRcv", text: "Actually one more question", memberId: CUSTOMER_ID})
chat.chatItems.set(GROUP_A, [teamCi, custCi])
// Previously complete
chat.customData.set(GROUP_A, {cardItemId: 500, complete: true})
cards.scheduleUpdate(GROUP_A)
await cards.flush()
const data = chat.customData.get(GROUP_A)
expect(data.complete).toBeUndefined()
})
test("refreshAllCards continues on individual card failure", async () => {
const GROUP_A = 101, GROUP_B = 102
chat.groups.set(GROUP_A, makeGroupInfo(GROUP_A))
chat.groups.set(GROUP_B, makeGroupInfo(GROUP_B))
chat.customData.set(GROUP_A, {cardItemId: 100})
chat.customData.set(GROUP_B, {cardItemId: 200})
chat.apiDeleteChatItemsWillFail()
await cards.refreshAllCards()
expectCardDeleted(200)
})
})
describe("joinedGroupMember Event Filtering", () => {
beforeEach(() => setup())
test("joinedGroupMember in non-team group → ignored (no DM)", async () => {
const member = {memberId: "someone", groupMemberId: 9000, memberContactId: null, memberStatus: GroupMemberStatus.Connected, memberProfile: {displayName: "Someone"}}
await bot.onJoinedGroupMember(joinedEvent(CUSTOMER_GROUP_ID, member))
expect(chat.rawCmds.length).toBe(0)
expect(chat.sent.filter(s => s.chat[0] === ChatType.Direct).length).toBe(0)
})
test("joinedGroupMember from wrong user → ignored", async () => {
const member = {memberId: "someone", groupMemberId: 9001, memberContactId: null, memberStatus: GroupMemberStatus.Connected, memberProfile: {displayName: "Someone"}}
await bot.onJoinedGroupMember(joinedEvent(TEAM_GROUP_ID, member, GROK_USER_ID))
expect(chat.rawCmds.length).toBe(0)
})
})