Files
simplex-chat/apps/simplex-support-bot/src/index.ts
T
sh fefdea8ed0 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
2026-05-06 08:54:36 +01:00

370 lines
24 KiB
TypeScript

import {readFileSync, writeFileSync, existsSync} from "fs"
import {api, bot, util} from "simplex-chat"
import {T} from "@simplex-chat/types"
import {parseConfig} from "./config.js"
import {SupportBot} from "./bot.js"
import {GrokApiClient} from "./grok.js"
import {welcomeMessage} from "./messages.js"
import {profileMutex, log, logError, getGroupInfo, getContact} from "./util.js"
interface BotState {
teamGroupId?: number
grokContactId?: number
grokUserId?: number
}
function readState(path: string): BotState {
if (!existsSync(path)) return {}
try { return JSON.parse(readFileSync(path, "utf-8")) } catch { return {} }
}
function writeState(path: string, state: BotState): void {
writeFileSync(path, JSON.stringify(state), "utf-8")
}
async function main(): Promise<void> {
const config = parseConfig(process.argv.slice(2))
// Do not log config.db.connectionString — typically contains credentials.
log("Config parsed", {
stateFile: config.stateFile,
backend: config.db.type,
teamGroup: config.teamGroup,
teamMembers: config.teamMembers,
timezone: config.timezone,
completeHours: config.completeHours,
})
const grokEnabled = config.grokApiKey !== null
if (!grokEnabled) log("No GROK_API_KEY provided, disabling Grok support")
const stateFilePath = config.stateFile
const state = readState(stateFilePath)
// Forward-reference for event handlers during init
let supportBot: SupportBot | undefined
// On restart, the active user may be Grok (if the previous run was killed
// mid-profile-switch). bot.run() uses apiGetActiveUser() and would then
// operate against the Grok userId as if it were the main user. Restore
// the main user as active before bot.run(). Grok is identified by the
// userId persisted in state.json on first resolution — comparing by
// profile name is fragile to renames.
if (state.grokUserId !== undefined) {
const preChat = await api.ChatApi.init(config.db)
try {
const activeUser = await preChat.apiGetActiveUser()
if (activeUser && activeUser.userId === state.grokUserId) {
const users = await preChat.apiListUsers()
const mainCandidates = users.filter(u => u.user.userId !== state.grokUserId)
if (mainCandidates.length === 0) {
throw new Error(
`DB has only the Grok user (userId=${state.grokUserId}); no main user to restore. ` +
`Likely a corrupted migration or partial restore.`
)
}
if (mainCandidates.length > 1) {
const names = mainCandidates.map(u => `${u.user.userId}:${u.user.profile.displayName}`).join(", ")
throw new Error(
`Ambiguous DB state: multiple non-Grok users [${names}]. ` +
`Refusing to guess which is main — remove extras manually.`
)
}
const mainUserInfo = mainCandidates[0]
await preChat.apiSetActiveUser(mainUserInfo.user.userId)
log(`Restored active user to ${mainUserInfo.user.profile.displayName} (userId=${mainUserInfo.user.userId})`)
}
} finally {
await preChat.close()
}
}
// Profile images (base64-encoded JPEG)
const supportImage = "data:image/jpg;base64,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"
const grokImage = "data:image/jpg;base64,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"
const desiredCommands: T.ChatBotCommand[] = [
...(grokEnabled ? [{type: "command" as const, keyword: "grok", label: "Ask Grok"}] : []),
{type: "command", keyword: "team", label: "Switch to team"},
]
// Step 1: Init main bot via bot.run()
log("Initializing main bot...")
const [chat, mainUser, mainAddress] = await bot.run({
profile: {displayName: "Ask SimpleX Team", fullName: "", image: supportImage},
dbOpts: config.db,
options: {
addressSettings: {
businessAddress: true,
autoAccept: true,
welcomeMessage,
},
commands: desiredCommands,
useBotProfile: true,
updateProfile: false,
},
events: {
acceptingBusinessRequest: (evt) => supportBot?.onBusinessRequest(evt),
newChatItems: (evt) => supportBot?.onNewChatItems(evt),
chatItemUpdated: (evt) => supportBot?.onChatItemUpdated(evt),
chatItemReaction: (evt) => supportBot?.onChatItemReaction(evt),
leftMember: (evt) => supportBot?.onLeftMember(evt),
joinedGroupMember: (evt) => supportBot?.onJoinedGroupMember(evt),
connectedToGroupMember: (evt) => supportBot?.onMemberConnected(evt),
newMemberContactReceivedInv: (evt) => supportBot?.onMemberContactReceivedInv(evt),
contactConnected: (evt) => supportBot?.onContactConnected(evt),
contactSndReady: (evt) => supportBot?.onContactSndReady(evt),
},
})
log(`Main bot user: ${mainUser.profile.displayName} (userId=${mainUser.userId})`)
// Step 2: Resolve Grok profile from same ChatApi instance.
// Identify Grok strictly by the persisted userId in state.json. If no ID
// is persisted, this is a first-time run — create the user and persist.
let grokUser: T.User | null = null
if (grokEnabled) {
log("Resolving Grok profile...")
if (state.grokUserId !== undefined) {
const users = await chat.apiListUsers()
grokUser = users.find(u => u.user.userId === state.grokUserId)?.user ?? null
if (!grokUser) {
throw new Error(
`Persisted Grok userId=${state.grokUserId} not found in DB. ` +
`Either restore the user or delete state.json to re-create Grok.`
)
}
} else {
log("Creating Grok profile...")
grokUser = await chat.apiCreateActiveUser({displayName: "Grok", fullName: "", image: grokImage})
// apiCreateActiveUser sets Grok as active — switch back to main
await chat.apiSetActiveUser(mainUser.userId)
state.grokUserId = grokUser.userId
writeState(stateFilePath, state)
log(`Persisted Grok userId=${grokUser.userId}`)
}
// Refresh Grok's profile if it has drifted from the canonical values.
const grokProfile: T.Profile = {displayName: "Grok", fullName: "", image: grokImage}
const currentProfile = util.fromLocalProfile(grokUser.profile)
if (currentProfile.image !== grokProfile.image || currentProfile.displayName !== grokProfile.displayName || currentProfile.fullName !== grokProfile.fullName) {
log("Grok profile changed, updating...")
await chat.apiSetActiveUser(grokUser.userId)
const summary = await chat.apiUpdateProfile(grokUser.userId, grokProfile)
await chat.apiSetActiveUser(mainUser.userId)
if (summary) {
log(`Grok profile updated: ${summary.updateSuccesses} contact(s) updated, ${summary.updateFailures} failed`)
} else {
log("Unexpected: Grok profile did not change")
}
}
log(`Grok profile: ${grokUser.profile.displayName} (userId=${grokUser.userId})`)
}
// Step 3: Read state file
// Step 4: Enable auto-accept DM contacts
await chat.apiSetAutoAcceptMemberContacts(mainUser.userId, true)
log("Auto-accept member contacts enabled")
// 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 = await getContact(chat, state.grokContactId)
if (found) {
config.grokContactId = found.contactId
log(`Grok contact from state: ID=${config.grokContactId}`)
} else {
log(`Persisted Grok contact ID=${state.grokContactId} not found`)
}
}
if (grokEnabled) {
if (config.grokContactId === null) {
log("Establishing bot↔Grok contact...")
const invLink = await chat.apiCreateLink(mainUser.userId)
// Switch to Grok profile to connect
await profileMutex.runExclusive(async () => {
await chat.apiSetActiveUser(grokUser!.userId)
await chat.apiConnectActiveUser(invLink)
await chat.apiSetActiveUser(mainUser.userId)
})
log("Grok connecting...")
const grokProfileName = grokUser!.profile.displayName
const evt = await chat.wait(
"contactConnected",
(e) =>
e.user.userId === mainUser.userId
&& e.contact.profile.displayName === grokProfileName,
60_000,
)
if (!evt) {
console.error(`Timeout waiting for Grok contact (60s, displayName="${grokProfileName}"). Exiting.`)
process.exit(1)
}
config.grokContactId = evt.contact.contactId
state.grokContactId = config.grokContactId
writeState(stateFilePath, state)
log(`Grok contact established: ID=${config.grokContactId}`)
}
}
// 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...")
let existingGroup: T.GroupInfo | null = null
if (typeof state.teamGroupId === "number") {
existingGroup = await getGroupInfo(chat, state.teamGroupId)
if (existingGroup) {
config.teamGroup.id = existingGroup.groupId
log(`Team group from state: ${config.teamGroup.id}:${existingGroup.groupProfile.displayName}`)
} else {
log(`Persisted team group ID=${state.teamGroupId} not found, will create`)
}
}
const teamGroupPreferences: T.GroupPreferences = {
directMessages: {enable: T.GroupFeatureEnabled.On},
fullDelete: {enable: T.GroupFeatureEnabled.On},
commands: [
{type: "command", keyword: "join", label: "Join customer chat", params: "groupId"},
],
}
if (config.teamGroup.id === 0) {
log(`Creating team group "${config.teamGroup.name}"...`)
const newGroup = await chat.apiNewGroup(mainUser.userId, {
displayName: config.teamGroup.name,
fullName: "",
groupPreferences: teamGroupPreferences,
})
config.teamGroup.id = newGroup.groupId
state.teamGroupId = config.teamGroup.id
writeState(stateFilePath, state)
log(`Team group created: ${config.teamGroup.id}:${config.teamGroup.name}`)
} else if (existingGroup) {
// Only update profile if preferences or name changed
const prefs = existingGroup.fullGroupPreferences
const needsUpdate =
existingGroup.groupProfile.displayName !== config.teamGroup.name ||
prefs.directMessages?.enable !== T.GroupFeatureEnabled.On ||
prefs.fullDelete?.enable !== T.GroupFeatureEnabled.On ||
JSON.stringify(prefs.commands) !== JSON.stringify(teamGroupPreferences.commands)
if (needsUpdate) {
await chat.apiUpdateGroupProfile(config.teamGroup.id, {
displayName: config.teamGroup.name,
fullName: "",
groupPreferences: teamGroupPreferences,
})
log("Team group profile updated")
}
}
// Step 7: Ensure direct messages enabled (done via groupPreferences above)
// Step 8: Create team group invite link (best-effort — bot works without it)
let inviteLinkCreated = false
try {
try { await chat.apiDeleteGroupLink(config.teamGroup.id) } catch {}
const teamGroupInviteLink = await chat.apiCreateGroupLink(
config.teamGroup.id, T.GroupMemberRole.Member
)
inviteLinkCreated = true
log("Team group invite link created")
console.log(`\nTeam group invite link (expires in 10 min):\n${teamGroupInviteLink}\n`)
} catch (err) {
logError("Failed to create team group invite link (SMP relay may be unreachable). Bot will continue without it.", err)
}
let inviteLinkDeleted = false
async function deleteInviteLink(): Promise<void> {
if (inviteLinkDeleted) return
inviteLinkDeleted = true
try {
await profileMutex.runExclusive(async () => {
await chat.apiSetActiveUser(mainUser.userId)
await chat.apiDeleteGroupLink(config.teamGroup.id)
})
log("Team group invite link deleted")
} catch (err) {
logError("Failed to delete invite link", err)
}
}
let inviteLinkTimer: ReturnType<typeof setTimeout> | undefined
if (inviteLinkCreated) {
inviteLinkTimer = setTimeout(async () => {
log("10 minutes elapsed, deleting invite link...")
await deleteInviteLink()
}, 10 * 60 * 1000)
inviteLinkTimer.unref()
}
// 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 = await getContact(chat, member.id)
if (!contact) {
console.error(`Team member not found: ID=${member.id}`)
process.exit(1)
}
if (contact.profile.displayName !== member.name) {
console.error(`Team member name mismatch: expected "${member.name}", got "${contact.profile.displayName}" (ID=${member.id})`)
process.exit(1)
}
log(`Team member validated: ${member.id}:${member.name}`)
}
}
// Load Grok context and build API client only if enabled
let grokApi: GrokApiClient | null = null
if (grokEnabled) {
let contextFile = ""
if (config.contextFile) {
try {
contextFile = readFileSync(config.contextFile, "utf-8")
log(`Loaded Grok context: ${contextFile.length} chars from ${config.contextFile}`)
} catch {
log(`Warning: context file not found: ${config.contextFile}`)
}
}
grokApi = new GrokApiClient(config.grokApiKey!, contextFile)
}
// Create SupportBot
supportBot = new SupportBot(chat, grokApi, config, mainUser.userId, grokUser?.userId ?? null, desiredCommands)
if (mainAddress) {
supportBot.businessAddress = util.contactAddressStr(mainAddress.connLinkContact)
log(`Business address: ${supportBot.businessAddress}`)
}
// Step 10: Register Grok event handlers (filtered by profile in handler)
if (grokEnabled) {
chat.on("receivedGroupInvitation", (evt) => supportBot?.onGrokGroupInvitation(evt))
chat.on("connectedToGroupMember", (evt) => supportBot?.onGrokMemberConnected(evt))
chat.on("newChatItems", (evt) => supportBot?.onGrokNewChatItems(evt))
}
// Step 10b: Refresh stale cards from before restart
await supportBot.cards.refreshAllCards()
log("SupportBot initialized. Bot running.")
// Step 11: Graceful shutdown
async function shutdown(signal: string): Promise<void> {
log(`Received ${signal}, shutting down...`)
clearTimeout(inviteLinkTimer)
supportBot?.cards.destroy()
await deleteInviteLink()
process.exit(0)
}
process.on("SIGINT", () => shutdown("SIGINT"))
process.on("SIGTERM", () => shutdown("SIGTERM"))
}
main().catch(err => {
logError("Fatal error", err)
process.exit(1)
})