support bot, bots: paginate chat scan (#6935)

* bots: document APIGetChats command and CRApiChats response

* bots: regenerate API docs and TypeScript types

* simplex-chat-nodejs: add apiGetChats

* support bot: avoid OOM on large databases

apiListGroups / apiListContacts return every record in one response and
overflow V8's string allocation on large DBs. Replace list-then-find-by-id
patterns with apiGetChat(type, id, 0) lookups, and the one genuine scan
(refreshAllCards) with paginated apiGetChats, count=1000.

* support bot: update test assertions to match current message text

* bots: simplify PaginationByTime, expose only PTLast

* simplex-chat-nodejs: bump types and nodejs versions
This commit is contained in:
sh
2026-05-06 08:54:36 +01:00
committed by GitHub
parent db783d85d7
commit fefdea8ed0
22 changed files with 673 additions and 62 deletions
+2 -5
View File
@@ -8,7 +8,7 @@ import {
teamAlreadyInvitedMessage, teamLockedMessage, noTeamMembersMessage,
grokUnavailableMessage, grokErrorMessage, grokNoHistoryMessage,
} from "./messages.js"
import {profileMutex, log, logError} from "./util.js"
import {profileMutex, log, logError, getGroupInfo} from "./util.js"
// True for any non-terminal status — invited but not yet accepted, through
// connected. Used to decide whether a contact is already in the group so we
@@ -795,10 +795,7 @@ export class SupportBot {
private async handleJoinCommand(targetGroupId: number, senderContactId: number): Promise<void> {
// Validate target is a business group
const groups = await this.withMainProfile(() =>
this.chat.apiListGroups(this.mainUserId)
)
const targetGroup = groups.find(g => g.groupId === targetGroupId)
const targetGroup = await this.withMainProfile(() => getGroupInfo(this.chat, targetGroupId))
if (!targetGroup?.businessChat) {
await this.sendToGroup(this.config.teamGroup.id, `Error: group ${targetGroupId} is not a business chat`)
return
+18 -12
View File
@@ -2,7 +2,7 @@ import {T} from "@simplex-chat/types"
import {api, util} from "simplex-chat"
import {Mutex} from "async-mutex"
import {Config} from "./config.js"
import {profileMutex, log, logError} from "./util.js"
import {profileMutex, log, logError, getGroupInfo} from "./util.js"
// State derivation types
export type ConversationState = "WELCOME" | "QUEUE" | "GROK" | "TEAM-PENDING" | "TEAM"
@@ -117,8 +117,7 @@ export class CardManager {
// Dispatches to create-path when cardItemId is absent so a failed createCard retries.
private async flushOne(groupId: number): Promise<void> {
const groups = await this.withMainProfile(() => this.chat.apiListGroups(this.mainUserId))
const groupInfo = groups.find(g => g.groupId === groupId)
const groupInfo = await this.withMainProfile(() => getGroupInfo(this.chat, groupId))
if (!groupInfo) return
const data = groupInfo.customData as Record<string, unknown> | undefined
if (typeof data?.cardItemId === "number") {
@@ -129,12 +128,22 @@ export class CardManager {
}
async refreshAllCards(): Promise<void> {
const groups = await this.withMainProfile(() => this.chat.apiListGroups(this.mainUserId))
// Scan the most recently active 1000 chats. Active cards live on
// recently-active customer chats by definition — a card stays open
// while the conversation is in flight. If the bot has been offline
// long enough that an active card has fallen outside this window, the
// card refreshes lazily on the next customer message (which moves the
// chat back into the recent window).
const chats = await this.withMainProfile(() =>
this.chat.apiGetChats(this.mainUserId, {type: "last", count: 1000})
)
const activeCards: {groupId: number; cardItemId: number}[] = []
for (const group of groups) {
const customData = group.customData as Record<string, unknown> | undefined
for (const c of chats) {
if (c.chatInfo.type !== "group") continue
const groupInfo = c.chatInfo.groupInfo
const customData = groupInfo.customData as Record<string, unknown> | undefined
if (customData && typeof customData.cardItemId === "number" && !customData.complete) {
activeCards.push({groupId: group.groupId, cardItemId: customData.cardItemId})
activeCards.push({groupId: groupInfo.groupId, cardItemId: customData.cardItemId})
}
}
if (activeCards.length === 0) return
@@ -210,8 +219,7 @@ export class CardManager {
// --- Custom data ---
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)
const group = await this.withMainProfile(() => getGroupInfo(this.chat, groupId))
if (!group?.customData) return null
const data = group.customData as Record<string, unknown>
const result: Partial<CardData> = {}
@@ -247,9 +255,7 @@ export class CardManager {
// --- Internal ---
private async updateCard(groupId: number): Promise<void> {
// Read customData and groupInfo in one apiListGroups call
const groups = await this.withMainProfile(() => this.chat.apiListGroups(this.mainUserId))
const groupInfo = groups.find(g => g.groupId === groupId)
const groupInfo = await this.withMainProfile(() => getGroupInfo(this.chat, groupId))
if (!groupInfo) return
const customData = groupInfo.customData as Record<string, unknown> | undefined
+11 -14
View File
@@ -5,7 +5,7 @@ import {parseConfig} from "./config.js"
import {SupportBot} from "./bot.js"
import {GrokApiClient} from "./grok.js"
import {welcomeMessage} from "./messages.js"
import {profileMutex, log, logError} from "./util.js"
import {profileMutex, log, logError, getGroupInfo, getContact} from "./util.js"
interface BotState {
teamGroupId?: number
@@ -163,14 +163,12 @@ async function main(): Promise<void> {
await chat.apiSetAutoAcceptMemberContacts(mainUser.userId, true)
log("Auto-accept member contacts enabled")
// Step 5: List contacts, resolve Grok contact
const contacts = await chat.apiListContacts(mainUser.userId)
log(`Contacts connected: ${contacts.length || "(none)"}`)
// Step 5: Resolve Grok contact by ID. Avoid apiListContacts — it loads
// every contact in one response and OOMs the native binding on large DBs.
// Always restore grokContactId so the one-way gate can find and remove
// Grok members even when Grok API is disabled.
if (typeof state.grokContactId === "number") {
const found = contacts.find(c => c.contactId === state.grokContactId)
const found = await getContact(chat, state.grokContactId)
if (found) {
config.grokContactId = found.contactId
log(`Grok contact from state: ID=${config.grokContactId}`)
@@ -210,14 +208,13 @@ async function main(): Promise<void> {
}
}
// Step 6: Resolve team group
// Step 6: Resolve team group by ID. Avoid apiListGroups — it loads every
// group in one response and OOMs the native binding on large DBs.
log("Resolving team group...")
const groups = await chat.apiListGroups(mainUser.userId)
let existingGroup: T.GroupInfo | undefined
let existingGroup: T.GroupInfo | null = null
if (typeof state.teamGroupId === "number") {
existingGroup = groups.find(g => g.groupId === state.teamGroupId)
existingGroup = await getGroupInfo(chat, state.teamGroupId)
if (existingGroup) {
config.teamGroup.id = existingGroup.groupId
log(`Team group from state: ${config.teamGroup.id}:${existingGroup.groupProfile.displayName}`)
@@ -302,13 +299,13 @@ async function main(): Promise<void> {
inviteLinkTimer.unref()
}
// Step 9: Validate team members
// Step 9: Validate team members (lookup by ID, one round-trip per member)
if (config.teamMembers.length > 0) {
log("Validating team members...")
for (const member of config.teamMembers) {
const contact = contacts.find(c => c.contactId === member.id)
const contact = await getContact(chat, member.id)
if (!contact) {
console.error(`Team member not found: ID=${member.id}. Available: ${contacts.map(c => `${c.contactId}:${c.profile.displayName}`).join(", ") || "(none)"}`)
console.error(`Team member not found: ID=${member.id}`)
process.exit(1)
}
if (contact.profile.displayName !== member.name) {
+29
View File
@@ -1,7 +1,36 @@
import {Mutex} from "async-mutex"
import {api, core} from "simplex-chat"
import {T} from "@simplex-chat/types"
export const profileMutex = new Mutex()
export function isChatNotFound(err: unknown, kind: "group" | "contact"): boolean {
if (!(err instanceof core.ChatAPIError)) return false
if (err.chatError?.type !== "errorStore") return false
const seType = err.chatError.storeError.type
return kind === "group" ? seType === "groupNotFound" : seType === "contactNotFound"
}
export async function getGroupInfo(chat: api.ChatApi, groupId: number): Promise<T.GroupInfo | null> {
try {
const c = await chat.apiGetChat(T.ChatType.Group, groupId, 0)
return c.chatInfo.type === "group" ? c.chatInfo.groupInfo : null
} catch (err) {
if (isChatNotFound(err, "group")) return null
throw err
}
}
export async function getContact(chat: api.ChatApi, contactId: number): Promise<T.Contact | null> {
try {
const c = await chat.apiGetChat(T.ChatType.Direct, contactId, 0)
return c.chatInfo.type === "direct" ? c.chatInfo.contact : null
} catch (err) {
if (isChatNotFound(err, "contact")) return null
throw err
}
}
export function isWeekend(timezone: string): boolean {
const day = new Intl.DateTimeFormat("en-US", {timeZone: timezone, weekday: "short"}).format(new Date())
return day === "Sat" || day === "Sun"