support-bot: Fix basic functionality

This commit is contained in:
Narasimha-sc
2026-02-13 16:35:14 +02:00
parent 0d290eb7b0
commit ce9dba8ee1
5 changed files with 633 additions and 206 deletions
+71 -7
View File
@@ -96,6 +96,15 @@ export class SupportBot {
log(`Member connected in group ${evt.groupInfo.groupId}: ${evt.member.memberProfile.displayName}`)
}
onMemberContactReceivedInv(evt: CEvt.NewMemberContactReceivedInv): void {
const {contact, groupInfo, member} = evt
if (groupInfo.groupId === this.config.teamGroup.id) {
log(`Accepted DM contact from team group member: ${contact.contactId}:${member.memberProfile.displayName}`)
} else {
log(`DM contact received from non-team group ${groupInfo.groupId}, member ${member.memberProfile.displayName}`)
}
}
// --- Event Handler (Grok agent) ---
async onGrokGroupInvitation(evt: CEvt.ReceivedGroupInvitation): Promise<void> {
@@ -114,12 +123,20 @@ export class SupportBot {
return
}
// Join succeeded — set maps and resolve waiter
// Join request sent — set maps, but don't resolve waiter yet.
// The waiter resolves when grokChat fires connectedToGroupMember (see onGrokMemberConnected).
this.grokGroupMap.set(mainGroupId, evt.groupInfo.groupId)
this.reverseGrokMap.set(evt.groupInfo.groupId, mainGroupId)
}
onGrokMemberConnected(evt: CEvt.ConnectedToGroupMember): void {
const grokGroupId = evt.groupInfo.groupId
const mainGroupId = this.reverseGrokMap.get(grokGroupId)
if (mainGroupId === undefined) return
const resolver = this.grokJoinResolvers.get(mainGroupId)
if (resolver) {
this.grokJoinResolvers.delete(mainGroupId)
log(`Grok fully connected in group: mainGroupId=${mainGroupId}, grokGroupId=${grokGroupId}`)
resolver()
}
}
@@ -132,8 +149,13 @@ export class SupportBot {
const groupInfo = chatInfo.groupInfo
if (!groupInfo.businessChat) return
const groupId = groupInfo.groupId
const state = this.conversations.get(groupId)
if (!state) return
let state = this.conversations.get(groupId)
if (!state) {
// After restart, re-initialize state for existing business chats
state = {type: "teamQueue", userMessages: []}
this.conversations.set(groupId, state)
log(`Re-initialized conversation state for group ${groupId} after restart`)
}
if (chatItem.chatDir.type === "groupSnd") return
if (chatItem.chatDir.type !== "groupRcv") return
@@ -227,7 +249,11 @@ export class SupportBot {
groupId: number,
state: {type: "teamQueue"; userMessages: string[]},
): Promise<void> {
const grokContactId = this.config.grokContact!.id
if (this.config.grokContactId === null) {
await this.sendToGroup(groupId, "Grok is temporarily unavailable. Please try again or click /team for a team member.")
return
}
const grokContactId = this.config.grokContactId
let member: T.GroupMember | undefined
try {
member = await this.mainChat.apiAddMember(groupId, grokContactId, T.GroupMemberRole.Member)
@@ -366,9 +392,24 @@ export class SupportBot {
}
this.cleanupGrokMaps(groupId)
}
if (this.config.teamMembers.length === 0) {
logError(`No team members configured, cannot add team member to group ${groupId}`, new Error("no team members"))
if (wasGrokMode) {
this.conversations.set(groupId, {type: "teamQueue", userMessages: []})
}
await this.sendToGroup(groupId, "No team members are available yet. Please try again later or click /grok.")
return
}
try {
const teamContactId = this.config.teamMembers[0].id
const member = await this.mainChat.apiAddMember(groupId, teamContactId, T.GroupMemberRole.Member)
const member = await this.addOrFindTeamMember(groupId, teamContactId)
if (!member) {
if (wasGrokMode) {
this.conversations.set(groupId, {type: "teamQueue", userMessages: []})
}
await this.sendToGroup(groupId, "Sorry, there was an error adding a team member. Please try again.")
return
}
this.conversations.set(groupId, {
type: "teamPending",
teamMemberGId: member.groupMemberId,
@@ -387,10 +428,13 @@ export class SupportBot {
// --- Helpers ---
private async addReplacementTeamMember(groupId: number): Promise<void> {
if (this.config.teamMembers.length === 0) return
try {
const teamContactId = this.config.teamMembers[0].id
const member = await this.mainChat.apiAddMember(groupId, teamContactId, T.GroupMemberRole.Member)
this.conversations.set(groupId, {type: "teamLocked", teamMemberGId: member.groupMemberId})
const member = await this.addOrFindTeamMember(groupId, teamContactId)
if (member) {
this.conversations.set(groupId, {type: "teamLocked", teamMemberGId: member.groupMemberId})
}
} catch (err) {
logError(`Failed to add replacement team member to group ${groupId}`, err)
// Stay in teamLocked with stale teamMemberGId — one-way gate must hold
@@ -398,6 +442,26 @@ export class SupportBot {
}
}
private async addOrFindTeamMember(groupId: number, teamContactId: number): Promise<T.GroupMember | null> {
try {
return await this.mainChat.apiAddMember(groupId, teamContactId, T.GroupMemberRole.Member)
} catch (err: any) {
if (err?.chatError?.errorType?.type === "groupDuplicateMember") {
// Team member already in group (e.g., from previous session) — find existing member
log(`Team member already in group ${groupId}, looking up existing member`)
const members = await this.mainChat.apiListMembers(groupId)
const existing = members.find(m => m.memberContactId === teamContactId)
if (existing) {
log(`Found existing team member: groupMemberId=${existing.groupMemberId}`)
return existing
}
logError(`Team member contact ${teamContactId} reported as duplicate but not found in group ${groupId}`, err)
return null
}
throw err
}
}
private async sendToGroup(groupId: number, text: string): Promise<void> {
try {
await this.mainChat.apiSendTextMessage([T.ChatType.Group, groupId], text)
+8 -21
View File
@@ -6,13 +6,12 @@ export interface IdName {
export interface Config {
dbPrefix: string
grokDbPrefix: string
teamGroup: IdName
teamMembers: IdName[]
grokContact: IdName | null // null during first-run
teamGroup: IdName // name from CLI, id resolved at startup from state file
teamMembers: IdName[] // optional, empty if not provided
grokContactId: number | null // resolved at startup from state file
groupLinks: string
timezone: string
grokApiKey: string
firstRun: boolean
}
export function parseIdName(s: string): IdName {
@@ -36,26 +35,15 @@ function optionalArg(args: string[], flag: string, defaultValue: string): string
}
export function parseConfig(args: string[]): Config {
const firstRun = args.includes("--first-run")
const grokApiKey = process.env.GROK_API_KEY
if (!grokApiKey) throw new Error("Missing environment variable: GROK_API_KEY")
const dbPrefix = optionalArg(args, "--db-prefix", "./data/bot")
const grokDbPrefix = optionalArg(args, "--grok-db-prefix", "./data/grok")
const teamGroup = parseIdName(requiredArg(args, "--team-group"))
const teamMembers = requiredArg(args, "--team-members").split(",").map(parseIdName)
if (teamMembers.length === 0) throw new Error("--team-members must have at least one member")
let grokContact: IdName | null = null
if (!firstRun) {
grokContact = parseIdName(requiredArg(args, "--grok-contact"))
} else {
const i = args.indexOf("--grok-contact")
if (i >= 0 && i + 1 < args.length) {
grokContact = parseIdName(args[i + 1])
}
}
const teamGroupName = requiredArg(args, "--team-group")
const teamGroup: IdName = {id: 0, name: teamGroupName} // id resolved at startup
const teamMembersRaw = optionalArg(args, "--team-members", "")
const teamMembers = teamMembersRaw ? teamMembersRaw.split(",").map(parseIdName) : []
const groupLinks = optionalArg(args, "--group-links", "")
const timezone = optionalArg(args, "--timezone", "UTC")
@@ -65,10 +53,9 @@ export function parseConfig(args: string[]): Config {
grokDbPrefix,
teamGroup,
teamMembers,
grokContact,
grokContactId: null, // resolved at startup from state file
groupLinks,
timezone,
grokApiKey,
firstRun,
}
}
+152 -84
View File
@@ -1,12 +1,27 @@
import {readFileSync} from "fs"
import {readFileSync, writeFileSync, existsSync} from "fs"
import {join} from "path"
import {bot, api} 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 {log, logError} from "./util.js"
interface BotState {
teamGroupId?: number
grokContactId?: 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))
log("Config parsed", {
@@ -14,11 +29,12 @@ async function main(): Promise<void> {
grokDbPrefix: config.grokDbPrefix,
teamGroup: config.teamGroup,
teamMembers: config.teamMembers,
grokContact: config.grokContact,
firstRun: config.firstRun,
timezone: config.timezone,
})
const stateFilePath = `${config.dbPrefix}_state.json`
const state = readState(stateFilePath)
// --- Init Grok agent (direct ChatApi) ---
log("Initializing Grok agent...")
const grokChat = await api.ChatApi.init(config.grokDbPrefix)
@@ -30,42 +46,6 @@ async function main(): Promise<void> {
log(`Grok user: ${grokUser.profile.displayName}`)
await grokChat.startChat()
// --- First-run mode: establish contact between bot and Grok agent ---
if (config.firstRun) {
log("First-run mode: establishing bot↔Grok contact...")
// We need to init the main bot first to create the invitation link
const mainChat = await api.ChatApi.init(config.dbPrefix)
let mainUser = await mainChat.apiGetActiveUser()
if (!mainUser) {
log("No main bot user, creating...")
mainUser = await mainChat.apiCreateActiveUser({displayName: "SimpleX Support", fullName: ""})
}
await mainChat.startChat()
const invLink = await mainChat.apiCreateLink(mainUser.userId)
log(`Invitation link created: ${invLink}`)
await grokChat.apiConnectActiveUser(invLink)
log("Grok agent connecting...")
const evt = await mainChat.wait("contactConnected", 60000)
if (!evt) {
console.error("Timeout waiting for Grok agent to connect (60s). Exiting.")
process.exit(1)
}
const contactId = evt.contact.contactId
const displayName = evt.contact.profile.displayName
log(`Grok contact established. ContactId=${contactId}`)
console.log(`\nGrok contact established. Use: --grok-contact ${contactId}:${displayName}\n`)
process.exit(0)
}
// --- Normal mode: validate config, init main bot ---
if (!config.grokContact) {
console.error("--grok-contact is required (unless --first-run)")
process.exit(1)
}
// SupportBot forward-reference: assigned after bot.run returns.
// Events use optional chaining so any events during init are safely skipped.
let supportBot: SupportBot | undefined
@@ -77,6 +57,7 @@ async function main(): Promise<void> {
deletedMemberUser: (evt) => supportBot?.onDeletedMemberUser(evt),
groupDeleted: (evt) => supportBot?.onGroupDeleted(evt),
connectedToGroupMember: (evt) => supportBot?.onMemberConnected(evt),
newMemberContactReceivedInv: (evt) => supportBot?.onMemberContactReceivedInv(evt),
}
log("Initializing main bot...")
@@ -99,49 +80,133 @@ async function main(): Promise<void> {
})
log(`Main bot user: ${mainUser.profile.displayName}`)
// --- Startup validation ---
log("Validating config against live data...")
// --- Auto-accept direct messages from group members ---
await mainChat.sendChatCmd(`/_set accept member contacts ${mainUser.userId} on`)
log("Auto-accept member contacts enabled")
// Validate team group
const groups = await mainChat.apiListGroups(mainUser.userId)
const teamGroup = groups.find(g => g.groupId === config.teamGroup.id)
if (!teamGroup) {
console.error(`Team group not found: ID=${config.teamGroup.id}. Available groups: ${groups.map(g => `${g.groupId}:${g.groupProfile.displayName}`).join(", ") || "(none)"}`)
process.exit(1)
}
if (teamGroup.groupProfile.displayName !== config.teamGroup.name) {
console.error(`Team group name mismatch: expected "${config.teamGroup.name}", got "${teamGroup.groupProfile.displayName}" (ID=${config.teamGroup.id})`)
process.exit(1)
}
log(`Team group validated: ${config.teamGroup.id}:${config.teamGroup.name}`)
// Validate contacts (team members + Grok)
// --- List contacts ---
const contacts = await mainChat.apiListContacts(mainUser.userId)
for (const member of config.teamMembers) {
const contact = contacts.find(c => c.contactId === member.id)
if (!contact) {
console.error(`Team member not found: ID=${member.id}. Available contacts: ${contacts.map(c => `${c.contactId}:${c.profile.displayName}`).join(", ") || "(none)"}`)
process.exit(1)
log(`Contacts (${contacts.length}):`, contacts.map(c => `${c.contactId}:${c.profile.displayName}`))
// --- Resolve Grok contact: from state file or auto-establish ---
log("Resolving Grok contact...")
if (typeof state.grokContactId === "number") {
const found = contacts.find(c => c.contactId === state.grokContactId)
if (found) {
config.grokContactId = found.contactId
log(`Grok contact resolved from state file: ID=${config.grokContactId}`)
} else {
log(`Persisted Grok contact ID=${state.grokContactId} no longer exists, will re-establish`)
}
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}`)
}
const grokContact = contacts.find(c => c.contactId === config.grokContact!.id)
if (!grokContact) {
console.error(`Grok contact not found: ID=${config.grokContact.id}. Available contacts: ${contacts.map(c => `${c.contactId}:${c.profile.displayName}`).join(", ") || "(none)"}`)
process.exit(1)
}
if (grokContact.profile.displayName !== config.grokContact.name) {
console.error(`Grok contact name mismatch: expected "${config.grokContact.name}", got "${grokContact.profile.displayName}" (ID=${config.grokContact.id})`)
process.exit(1)
}
log(`Grok contact validated: ${config.grokContact.id}:${config.grokContact.name}`)
if (config.grokContactId === null) {
log("Establishing bot↔Grok contact...")
const invLink = await mainChat.apiCreateLink(mainUser.userId)
await grokChat.apiConnectActiveUser(invLink)
log("Grok agent connecting...")
log("All config validated.")
const evt = await mainChat.wait("contactConnected", 60000)
if (!evt) {
console.error("Timeout waiting for Grok agent to connect (60s). Exiting.")
process.exit(1)
}
config.grokContactId = evt.contact.contactId
state.grokContactId = config.grokContactId
writeState(stateFilePath, state)
log(`Grok contact established: ID=${config.grokContactId} (persisted)`)
}
// --- Resolve team group: from state file or auto-create ---
log("Resolving team group...")
// Workaround: apiListGroups sends "/_groups {userId}" but the native parser
// expects "/_groups{userId}" (no space). Send the command directly.
const groupsResult = await mainChat.sendChatCmd(`/_groups${mainUser.userId}`)
if (groupsResult.type !== "groupsList") {
console.error("Failed to list groups:", groupsResult)
process.exit(1)
}
const groups = groupsResult.groups
if (typeof state.teamGroupId === "number") {
const found = groups.find(g => g.groupId === state.teamGroupId)
if (found) {
config.teamGroup.id = found.groupId
log(`Team group resolved from state file: ${config.teamGroup.id}:${found.groupProfile.displayName}`)
} else {
log(`Persisted team group ID=${state.teamGroupId} no longer exists, will create new`)
}
}
const teamGroupPreferences: T.GroupPreferences = {
directMessages: {enable: T.GroupFeatureEnabled.On},
}
if (config.teamGroup.id === 0) {
log(`Creating team group "${config.teamGroup.name}"...`)
const newGroup = await mainChat.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} (persisted)`)
} else {
// Ensure direct messages are enabled on existing team group
await mainChat.apiUpdateGroupProfile(config.teamGroup.id, {
displayName: config.teamGroup.name,
fullName: "",
groupPreferences: teamGroupPreferences,
})
}
// --- Create invite link for team group (for team members to join) ---
// Delete any stale link from a previous run (e.g., crash without graceful shutdown)
try { await mainChat.apiDeleteGroupLink(config.teamGroup.id) } catch {}
const teamGroupInviteLink = await mainChat.apiCreateGroupLink(config.teamGroup.id, T.GroupMemberRole.Member)
log(`Team group invite link created`)
console.log(`\nTeam group invite link (expires in 10 min):\n${teamGroupInviteLink}\n`)
// Schedule invite link deletion after 10 minutes
let inviteLinkDeleted = false
async function deleteInviteLink(): Promise<void> {
if (inviteLinkDeleted) return
inviteLinkDeleted = true
try {
await mainChat.apiDeleteGroupLink(config.teamGroup.id)
log("Team group invite link deleted")
} catch (err) {
logError("Failed to delete team group invite link", err)
}
}
const inviteLinkTimer = setTimeout(async () => {
log("10 minutes elapsed, deleting team group invite link...")
await deleteInviteLink()
}, 10 * 60 * 1000)
inviteLinkTimer.unref() // don't keep process alive for the timer
// --- Validate team member contacts (if provided) ---
if (config.teamMembers.length > 0) {
log("Validating team member contacts...")
for (const member of config.teamMembers) {
const contact = contacts.find(c => c.contactId === member.id)
if (!contact) {
console.error(`Team member not found: ID=${member.id}. Available contacts: ${contacts.map(c => `${c.contactId}:${c.profile.displayName}`).join(", ") || "(none)"}`)
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}`)
}
}
log("Startup complete.")
// Load Grok context docs
let docsContext = ""
@@ -161,16 +226,19 @@ async function main(): Promise<void> {
grokChat.on("receivedGroupInvitation", async (evt) => {
await supportBot?.onGrokGroupInvitation(evt)
})
grokChat.on("connectedToGroupMember", (evt) => {
supportBot?.onGrokMemberConnected(evt)
})
// Keep process alive
process.on("SIGINT", () => {
log("Received SIGINT, shutting down...")
// Graceful shutdown: delete invite link before exit
async function shutdown(signal: string): Promise<void> {
log(`Received ${signal}, shutting down...`)
clearTimeout(inviteLinkTimer)
await deleteInviteLink()
process.exit(0)
})
process.on("SIGTERM", () => {
log("Received SIGTERM, shutting down...")
process.exit(0)
})
}
process.on("SIGINT", () => shutdown("SIGINT"))
process.on("SIGTERM", () => shutdown("SIGTERM"))
}
main().catch(err => {