mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-08-27 22:34:51 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user