mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-04-23 01:35:49 +00:00
support-bot: implement customData state
This commit is contained in:
@@ -636,6 +636,7 @@ describe("/grok Activation", () => {
|
||||
await reachQueue()
|
||||
addBotMessage("The team will reply to your message")
|
||||
await bot.onNewChatItems(customerMessage("/grok"))
|
||||
await bot.flush()
|
||||
expectSentToGroup(CUSTOMER_GROUP_ID, "temporarily unavailable")
|
||||
})
|
||||
|
||||
@@ -911,14 +912,15 @@ describe("Team Member Lifecycle", () => {
|
||||
expect(chat.roleChanges.length).toBe(0)
|
||||
})
|
||||
|
||||
test("all team members leave before sending → reverts to QUEUE", async () => {
|
||||
test("all team members leave before sending → state stays TEAM-PENDING", async () => {
|
||||
await reachTeamPending()
|
||||
addBotMessage("We will reply within 24 hours.")
|
||||
// Remove team members from the group
|
||||
chat.members.set(CUSTOMER_GROUP_ID, [])
|
||||
// Customer sends another message — state should derive as QUEUE (no team members)
|
||||
// State is authoritative and monotonic — composition changes never demote it.
|
||||
// Customer is still waiting for the team's response.
|
||||
const state = await cards.deriveState(CUSTOMER_GROUP_ID)
|
||||
expect(state).toBe("QUEUE")
|
||||
expect(state).toBe("TEAM-PENDING")
|
||||
})
|
||||
|
||||
test("/team after all team members left (TEAM-PENDING, no msg sent) → re-adds members", async () => {
|
||||
@@ -1056,32 +1058,31 @@ describe("Card Debouncing", () => {
|
||||
describe("Card Format & State Derivation", () => {
|
||||
beforeEach(() => setup())
|
||||
|
||||
test("QUEUE state derived when no Grok or team members", async () => {
|
||||
addBotMessage("The team will reply to your message")
|
||||
test("QUEUE state read from customData.state", async () => {
|
||||
chat.customData.set(CUSTOMER_GROUP_ID, {cardItemId: 1234, state: "QUEUE"})
|
||||
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 () => {
|
||||
test("WELCOME state when customData.state is absent", 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()])
|
||||
test("GROK state read from customData.state", async () => {
|
||||
chat.customData.set(CUSTOMER_GROUP_ID, {cardItemId: 1234, state: "GROK"})
|
||||
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")])
|
||||
test("TEAM-PENDING state read from customData.state", async () => {
|
||||
chat.customData.set(CUSTOMER_GROUP_ID, {cardItemId: 1234, state: "TEAM-PENDING"})
|
||||
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)
|
||||
test("TEAM state read from customData.state", async () => {
|
||||
chat.customData.set(CUSTOMER_GROUP_ID, {cardItemId: 1234, state: "TEAM"})
|
||||
const state = await cards.deriveState(CUSTOMER_GROUP_ID)
|
||||
expect(state).toBe("TEAM")
|
||||
})
|
||||
@@ -1682,42 +1683,46 @@ describe("Message Templates", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("isFirstCustomerMessage detection", () => {
|
||||
describe("State persistence in customData", () => {
|
||||
beforeEach(() => setup())
|
||||
|
||||
test("detects 'The team will reply to your message' as queue message", async () => {
|
||||
addBotMessage("The team will reply to your message within 24 hours.")
|
||||
const isFirst = await cards.isFirstCustomerMessage(CUSTOMER_GROUP_ID)
|
||||
expect(isFirst).toBe(false)
|
||||
test("first customer text writes state=QUEUE to customData", async () => {
|
||||
await bot.onNewChatItems(customerMessage("Hello"))
|
||||
expect(chat.customData.get(CUSTOMER_GROUP_ID)?.state).toBe("QUEUE")
|
||||
})
|
||||
|
||||
test("detects 'You are chatting with Grok' as grok activation", async () => {
|
||||
addBotMessage("*You are chatting with Grok* - use any language.")
|
||||
const isFirst = await cards.isFirstCustomerMessage(CUSTOMER_GROUP_ID)
|
||||
expect(isFirst).toBe(false)
|
||||
test("/team writes state=TEAM-PENDING immediately (before team accepts)", async () => {
|
||||
await reachQueue()
|
||||
addBotMessage("The team will reply to your message")
|
||||
await bot.onNewChatItems(customerMessage("/team"))
|
||||
expect(chat.customData.get(CUSTOMER_GROUP_ID)?.state).toBe("TEAM-PENDING")
|
||||
})
|
||||
|
||||
test("detects 'We will reply within' as team activation", async () => {
|
||||
test("/grok writes state=GROK when activation succeeds", async () => {
|
||||
await reachQueue()
|
||||
addBotMessage("The team will reply to your message")
|
||||
const joinPromise = simulateGrokJoinSuccess()
|
||||
await bot.onNewChatItems(customerMessage("/grok"))
|
||||
await joinPromise
|
||||
await bot.flush()
|
||||
expect(chat.customData.get(CUSTOMER_GROUP_ID)?.state).toBe("GROK")
|
||||
})
|
||||
|
||||
test("/grok from QUEUE reverts state to QUEUE if activation fails", async () => {
|
||||
await reachQueue()
|
||||
addBotMessage("The team will reply to your message")
|
||||
chat.apiAddMemberWillFail()
|
||||
await bot.onNewChatItems(customerMessage("/grok"))
|
||||
await bot.flush()
|
||||
expect(chat.customData.get(CUSTOMER_GROUP_ID)?.state).toBe("QUEUE")
|
||||
})
|
||||
|
||||
test("first team text writes state=TEAM via gate", async () => {
|
||||
await reachTeamPending()
|
||||
addBotMessage("We 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)
|
||||
chat.members.set(CUSTOMER_GROUP_ID, [makeTeamMember(TEAM_MEMBER_1_ID, "Alice")])
|
||||
await bot.onNewChatItems(teamMemberMessage("I'll help"))
|
||||
expect(chat.customData.get(CUSTOMER_GROUP_ID)?.state).toBe("TEAM")
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import {api, util} from "simplex-chat"
|
||||
import {T, CEvt} from "@simplex-chat/types"
|
||||
import {Config} from "./config.js"
|
||||
import {GrokMessage, GrokApiClient} from "./grok.js"
|
||||
import {CardManager} from "./cards.js"
|
||||
import {CardManager, ConversationState} from "./cards.js"
|
||||
import {
|
||||
queueMessage, grokInvitingMessage, grokActivatedMessage, teamAddedMessage,
|
||||
teamAlreadyInvitedMessage, teamLockedMessage, noTeamMembersMessage,
|
||||
@@ -374,18 +374,22 @@ export class SupportBot {
|
||||
const isTeam = this.config.teamMembers.some(tm => tm.id === sender.memberContactId)
|
||||
|
||||
if (isTeam && util.ciContentText(chatItem)?.trim()) {
|
||||
// Check one-way gate: first team text → remove Grok
|
||||
const {grokMember} = await this.cards.getGroupComposition(groupId)
|
||||
if (grokMember) {
|
||||
log(`One-way gate: team message in group ${groupId}, removing Grok`)
|
||||
try {
|
||||
await this.withMainProfile(() =>
|
||||
this.chat.apiRemoveMembers(groupId, [grokMember.groupMemberId])
|
||||
)
|
||||
} catch {
|
||||
// may have already left
|
||||
// One-way gate: first team text → transition to TEAM + remove Grok
|
||||
const data = await this.cards.getRawCustomData(groupId)
|
||||
if (data?.state !== "TEAM") {
|
||||
await this.cards.mergeCustomData(groupId, {state: "TEAM"})
|
||||
const {grokMember} = await this.cards.getGroupComposition(groupId)
|
||||
if (grokMember) {
|
||||
log(`One-way gate: team message in group ${groupId}, removing Grok`)
|
||||
try {
|
||||
await this.withMainProfile(() =>
|
||||
this.chat.apiRemoveMembers(groupId, [grokMember.groupMemberId])
|
||||
)
|
||||
} catch {
|
||||
// may have already left
|
||||
}
|
||||
this.cleanupGrokMaps(groupId)
|
||||
}
|
||||
this.cleanupGrokMaps(groupId)
|
||||
}
|
||||
}
|
||||
// Schedule card update for any non-customer message (team or Grok)
|
||||
@@ -403,21 +407,25 @@ export class SupportBot {
|
||||
switch (state) {
|
||||
case "WELCOME":
|
||||
if (cmd?.keyword === "grok") {
|
||||
// WELCOME → GROK (skip queue msg)
|
||||
// WELCOME → GROK (skip queue msg). Write state optimistically so the
|
||||
// card renders with GROK icon/label; activateGrok will revert via
|
||||
// setStateOnFail if activation fails.
|
||||
// Fire-and-forget: activateGrok awaits future events (waitForGrokJoin)
|
||||
// which would deadlock the sequential event loop if awaited here.
|
||||
// sendQueueOnFail=true: if Grok activation fails, send queue message as fallback
|
||||
await this.cards.mergeCustomData(groupId, {state: "GROK"})
|
||||
await this.cards.createCard(groupId, groupInfo)
|
||||
this.fireAndForget(this.activateGrok(groupId, true))
|
||||
this.fireAndForget(this.activateGrok(groupId, {sendQueueOnFail: true, setStateOnFail: "QUEUE"}))
|
||||
return
|
||||
}
|
||||
if (cmd?.keyword === "team") {
|
||||
// activateTeam writes state=TEAM-PENDING before the add loop
|
||||
await this.activateTeam(groupId)
|
||||
await this.cards.createCard(groupId, groupInfo)
|
||||
return
|
||||
}
|
||||
// First regular message → QUEUE
|
||||
if (text) {
|
||||
await this.cards.mergeCustomData(groupId, {state: "QUEUE"})
|
||||
await this.sendToGroup(groupId, queueMessage(this.config.timezone, this.grokEnabled))
|
||||
await this.cards.createCard(groupId, groupInfo)
|
||||
}
|
||||
@@ -425,7 +433,9 @@ export class SupportBot {
|
||||
|
||||
case "QUEUE":
|
||||
if (cmd?.keyword === "grok") {
|
||||
this.fireAndForget(this.activateGrok(groupId))
|
||||
// Write state optimistically; activateGrok reverts to QUEUE on failure
|
||||
await this.cards.mergeCustomData(groupId, {state: "GROK"})
|
||||
this.fireAndForget(this.activateGrok(groupId, {setStateOnFail: "QUEUE"}))
|
||||
} else if (cmd?.keyword === "team") {
|
||||
await this.activateTeam(groupId)
|
||||
}
|
||||
@@ -446,14 +456,16 @@ export class SupportBot {
|
||||
|
||||
case "TEAM-PENDING":
|
||||
if (cmd?.keyword === "grok") {
|
||||
// Invite Grok if not present
|
||||
// Invite Grok if not present; state stays TEAM-PENDING
|
||||
const {grokMember} = await this.cards.getGroupComposition(groupId)
|
||||
if (!grokMember) {
|
||||
this.fireAndForget(this.activateGrok(groupId))
|
||||
}
|
||||
// else: already present, ignore
|
||||
} else if (cmd?.keyword === "team") {
|
||||
await this.sendToGroup(groupId, teamAlreadyInvitedMessage)
|
||||
// activateTeam handles "already invited" reply (team still present)
|
||||
// or silent re-add (team has all left)
|
||||
await this.activateTeam(groupId)
|
||||
}
|
||||
this.cards.scheduleUpdate(groupId)
|
||||
break
|
||||
@@ -461,6 +473,9 @@ export class SupportBot {
|
||||
case "TEAM":
|
||||
if (cmd?.keyword === "grok") {
|
||||
await this.sendToGroup(groupId, teamLockedMessage)
|
||||
} else if (cmd?.keyword === "team") {
|
||||
// Team still present → "already invited"; team all left → silent re-add
|
||||
await this.activateTeam(groupId)
|
||||
}
|
||||
this.cards.scheduleUpdate(groupId)
|
||||
break
|
||||
@@ -541,12 +556,21 @@ export class SupportBot {
|
||||
|
||||
// --- Grok activation ---
|
||||
|
||||
private async activateGrok(groupId: number, sendQueueOnFail = false): Promise<void> {
|
||||
private async activateGrok(
|
||||
groupId: number,
|
||||
opts: {sendQueueOnFail?: boolean; setStateOnFail?: ConversationState} = {},
|
||||
): Promise<void> {
|
||||
if (!this.grokApi) return
|
||||
const grokApi = this.grokApi
|
||||
const revertStateOnFail = async () => {
|
||||
if (opts.setStateOnFail) {
|
||||
await this.cards.mergeCustomData(groupId, {state: opts.setStateOnFail})
|
||||
}
|
||||
}
|
||||
if (this.config.grokContactId === null) {
|
||||
await revertStateOnFail()
|
||||
await this.sendToGroup(groupId, grokUnavailableMessage)
|
||||
if (sendQueueOnFail) await this.sendToGroup(groupId, queueMessage(this.config.timezone, this.grokEnabled))
|
||||
if (opts.sendQueueOnFail) await this.sendToGroup(groupId, queueMessage(this.config.timezone, this.grokEnabled))
|
||||
this.cards.scheduleUpdate(groupId)
|
||||
return
|
||||
}
|
||||
@@ -566,8 +590,9 @@ export class SupportBot {
|
||||
return
|
||||
}
|
||||
logError(`Failed to invite Grok to group ${groupId}`, err)
|
||||
await revertStateOnFail()
|
||||
await this.sendToGroup(groupId, grokUnavailableMessage)
|
||||
if (sendQueueOnFail) await this.sendToGroup(groupId, queueMessage(this.config.timezone, this.grokEnabled))
|
||||
if (opts.sendQueueOnFail) await this.sendToGroup(groupId, queueMessage(this.config.timezone, this.grokEnabled))
|
||||
this.cards.scheduleUpdate(groupId)
|
||||
return
|
||||
}
|
||||
@@ -593,8 +618,9 @@ export class SupportBot {
|
||||
)
|
||||
} catch {}
|
||||
this.cleanupGrokMaps(groupId)
|
||||
await revertStateOnFail()
|
||||
await this.sendToGroup(groupId, grokUnavailableMessage)
|
||||
if (sendQueueOnFail) await this.sendToGroup(groupId, queueMessage(this.config.timezone, this.grokEnabled))
|
||||
if (opts.sendQueueOnFail) await this.sendToGroup(groupId, queueMessage(this.config.timezone, this.grokEnabled))
|
||||
this.cards.scheduleUpdate(groupId)
|
||||
return
|
||||
}
|
||||
@@ -654,35 +680,30 @@ export class SupportBot {
|
||||
return
|
||||
}
|
||||
|
||||
// Check if team was already activated before (message sent or "added" text in history)
|
||||
const hasTeamBefore = await this.cards.hasTeamMemberSentMessage(groupId)
|
||||
if (hasTeamBefore) {
|
||||
const data = await this.cards.getRawCustomData(groupId)
|
||||
const alreadyActivated = data?.state === "TEAM-PENDING" || data?.state === "TEAM"
|
||||
if (alreadyActivated) {
|
||||
const {teamMembers} = await this.cards.getGroupComposition(groupId)
|
||||
if (teamMembers.length > 0) {
|
||||
await this.sendToGroup(groupId, teamAlreadyInvitedMessage)
|
||||
return
|
||||
}
|
||||
// Team members sent messages but all have left — re-add below
|
||||
}
|
||||
|
||||
if (!hasTeamBefore) {
|
||||
// Check by scanning history for the teamAddedMessage AND verify team still present
|
||||
const chat = await this.cards.getChat(groupId, 50)
|
||||
const alreadyAdded = chat.chatItems.some((ci: T.ChatItem) =>
|
||||
ci.chatDir.type === "groupSnd"
|
||||
&& util.ciContentText(ci)?.includes("We will reply within")
|
||||
)
|
||||
if (alreadyAdded) {
|
||||
const {teamMembers} = await this.cards.getGroupComposition(groupId)
|
||||
if (teamMembers.length > 0) {
|
||||
await this.sendToGroup(groupId, teamAlreadyInvitedMessage)
|
||||
return
|
||||
// Team previously activated but all team members have since left —
|
||||
// re-add silently (no teamAddedMessage). State stays TEAM-PENDING/TEAM.
|
||||
for (const tm of this.config.teamMembers) {
|
||||
try {
|
||||
await this.addOrFindTeamMember(groupId, tm.id)
|
||||
} catch (err) {
|
||||
logError(`Failed to add team member ${tm.id} to group ${groupId}`, err)
|
||||
}
|
||||
// Team was previously added but all members left — re-add below
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Add ALL configured team members — promoted to Owner on connectedToGroupMember
|
||||
// First activation — write state BEFORE add loop so concurrent customer
|
||||
// events observing mid-flight see TEAM-PENDING rather than stale state.
|
||||
await this.cards.mergeCustomData(groupId, {state: "TEAM-PENDING"})
|
||||
|
||||
for (const tm of this.config.teamMembers) {
|
||||
try {
|
||||
await this.addOrFindTeamMember(groupId, tm.id)
|
||||
|
||||
@@ -6,13 +6,18 @@ import {profileMutex, log, logError} from "./util.js"
|
||||
// State derivation types
|
||||
export type ConversationState = "WELCOME" | "QUEUE" | "GROK" | "TEAM-PENDING" | "TEAM"
|
||||
|
||||
function isConversationState(x: unknown): x is ConversationState {
|
||||
return x === "WELCOME" || x === "QUEUE" || x === "GROK" || x === "TEAM-PENDING" || x === "TEAM"
|
||||
}
|
||||
|
||||
export interface GroupComposition {
|
||||
grokMember: T.GroupMember | undefined
|
||||
teamMembers: T.GroupMember[]
|
||||
}
|
||||
|
||||
interface CardData {
|
||||
cardItemId: number
|
||||
state?: ConversationState
|
||||
cardItemId?: number
|
||||
joinItemId?: number
|
||||
complete?: boolean
|
||||
}
|
||||
@@ -88,11 +93,9 @@ export class CardManager {
|
||||
{msgContent: {type: "text", text: joinCmd}, mentions: {}},
|
||||
])
|
||||
)
|
||||
const data: CardData = {cardItemId: items[0].chatItem.meta.itemId}
|
||||
if (items.length > 1) data.joinItemId = items[1].chatItem.meta.itemId
|
||||
await this.withMainProfile(() =>
|
||||
this.chat.apiSetGroupCustomData(groupId, data)
|
||||
)
|
||||
const patch: Partial<CardData> = {cardItemId: items[0].chatItem.meta.itemId}
|
||||
patch.joinItemId = items.length > 1 ? items[1].chatItem.meta.itemId : undefined
|
||||
await this.mergeCustomData(groupId, patch)
|
||||
}
|
||||
|
||||
async flush(): Promise<void> {
|
||||
@@ -154,36 +157,8 @@ export class CardManager {
|
||||
}
|
||||
|
||||
async deriveState(groupId: number): Promise<ConversationState> {
|
||||
const {grokMember, teamMembers} = await this.getGroupComposition(groupId)
|
||||
if (teamMembers.length > 0) {
|
||||
const hasTeamMsg = await this.hasTeamMemberSentMessage(groupId)
|
||||
return hasTeamMsg ? "TEAM" : "TEAM-PENDING"
|
||||
}
|
||||
if (grokMember) return "GROK"
|
||||
const isFirst = await this.isFirstCustomerMessage(groupId)
|
||||
return isFirst ? "WELCOME" : "QUEUE"
|
||||
}
|
||||
|
||||
async isFirstCustomerMessage(groupId: number): Promise<boolean> {
|
||||
const chat = await this.getChat(groupId, 20)
|
||||
return !chat.chatItems.some((ci: T.ChatItem) => {
|
||||
if (ci.chatDir.type !== "groupSnd") return false
|
||||
const text = util.ciContentText(ci)
|
||||
return text?.includes("The team will reply to your message")
|
||||
|| text?.includes("chatting with Grok")
|
||||
|| text?.includes("We will reply within")
|
||||
|| text?.includes("team member has already been invited")
|
||||
})
|
||||
}
|
||||
|
||||
async hasTeamMemberSentMessage(groupId: number): Promise<boolean> {
|
||||
const chat = await this.getChat(groupId, 50)
|
||||
return chat.chatItems.some((ci: T.ChatItem) => {
|
||||
if (ci.chatDir.type !== "groupRcv") return false
|
||||
const memberContactId = ci.chatDir.groupMember.memberContactId
|
||||
return this.config.teamMembers.some(tm => tm.id === memberContactId)
|
||||
&& util.ciContentText(ci)?.trim()
|
||||
})
|
||||
const data = await this.getRawCustomData(groupId)
|
||||
return data?.state ?? "WELCOME"
|
||||
}
|
||||
|
||||
async getLastCustomerMessageTime(groupId: number, customerId: string): Promise<number | undefined> {
|
||||
@@ -216,21 +191,26 @@ export class CardManager {
|
||||
|
||||
// --- Custom data ---
|
||||
|
||||
async getCustomData(groupId: number): Promise<CardData | null> {
|
||||
async getRawCustomData(groupId: number): Promise<Partial<CardData> | null> {
|
||||
const groups = await this.withMainProfile(() => this.chat.apiListGroups(this.mainUserId))
|
||||
const group = groups.find(g => g.groupId === groupId)
|
||||
if (!group?.customData) return null
|
||||
const data = group.customData as Record<string, unknown>
|
||||
if (typeof data.cardItemId === "number") {
|
||||
const result: CardData = {cardItemId: data.cardItemId}
|
||||
if (typeof data.joinItemId === "number") result.joinItemId = data.joinItemId
|
||||
return result
|
||||
}
|
||||
return null
|
||||
const result: Partial<CardData> = {}
|
||||
if (isConversationState(data.state)) result.state = data.state
|
||||
if (typeof data.cardItemId === "number") result.cardItemId = data.cardItemId
|
||||
if (typeof data.joinItemId === "number") result.joinItemId = data.joinItemId
|
||||
if (data.complete === true) result.complete = true
|
||||
return result
|
||||
}
|
||||
|
||||
async setCustomData(groupId: number, data: CardData): Promise<void> {
|
||||
await this.withMainProfile(() => this.chat.apiSetGroupCustomData(groupId, data))
|
||||
async mergeCustomData(groupId: number, patch: Partial<CardData>): Promise<void> {
|
||||
const current = (await this.getRawCustomData(groupId)) ?? {}
|
||||
const merged: Partial<CardData> = {...current, ...patch}
|
||||
for (const key of Object.keys(merged) as (keyof CardData)[]) {
|
||||
if (merged[key] === undefined) delete merged[key]
|
||||
}
|
||||
await this.withMainProfile(() => this.chat.apiSetGroupCustomData(groupId, merged))
|
||||
}
|
||||
|
||||
async clearCustomData(groupId: number): Promise<void> {
|
||||
@@ -277,12 +257,10 @@ export class CardManager {
|
||||
{msgContent: {type: "text", text: joinCmd}, mentions: {}},
|
||||
])
|
||||
)
|
||||
const data: CardData = {cardItemId: items[0].chatItem.meta.itemId}
|
||||
if (items.length > 1) data.joinItemId = items[1].chatItem.meta.itemId
|
||||
if (complete) data.complete = true
|
||||
await this.withMainProfile(() =>
|
||||
this.chat.apiSetGroupCustomData(groupId, data)
|
||||
)
|
||||
const patch: Partial<CardData> = {cardItemId: items[0].chatItem.meta.itemId}
|
||||
patch.joinItemId = items.length > 1 ? items[1].chatItem.meta.itemId : undefined
|
||||
patch.complete = complete ? true : undefined
|
||||
await this.mergeCustomData(groupId, patch)
|
||||
}
|
||||
|
||||
private async composeCard(groupId: number, groupInfo: T.GroupInfo): Promise<{text: string, joinCmd: string, complete: boolean}> {
|
||||
@@ -291,17 +269,10 @@ export class CardManager {
|
||||
const bc = groupInfo.businessChat
|
||||
const customerId = bc?.customerId
|
||||
|
||||
// State derivation
|
||||
const {grokMember, teamMembers} = await this.getGroupComposition(groupId)
|
||||
let state: ConversationState
|
||||
if (teamMembers.length > 0) {
|
||||
const hasTeamMsg = await this.hasTeamMemberSentMessage(groupId)
|
||||
state = hasTeamMsg ? "TEAM" : "TEAM-PENDING"
|
||||
} else if (grokMember) {
|
||||
state = "GROK"
|
||||
} else {
|
||||
state = "QUEUE"
|
||||
}
|
||||
// State is written into customData at event time by the bot's dispatch handlers.
|
||||
const state = await this.deriveState(groupId)
|
||||
// Composition is needed for the agent-names list only.
|
||||
const {teamMembers} = await this.getGroupComposition(groupId)
|
||||
|
||||
// Icon
|
||||
const icon = await this.computeIcon(groupId, state, customerId ?? undefined)
|
||||
|
||||
Reference in New Issue
Block a user