apps: support bot code & tests

This commit is contained in:
Narasimha-sc
2026-02-11 23:48:59 +02:00
parent baa567e854
commit d590df4629
13 changed files with 5227 additions and 0 deletions

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,22 @@
{
"name": "simplex-chat-support-bot",
"version": "0.1.0",
"private": true,
"main": "dist/index.js",
"scripts": {
"build": "tsc",
"start": "node dist/index.js",
"test": "vitest run"
},
"dependencies": {
"@simplex-chat/types": "^0.3.0",
"simplex-chat": "^6.5.0-beta.4.4"
},
"devDependencies": {
"@types/node": "^25.0.5",
"typescript": "^5.9.3",
"vitest": "^2.1.9"
},
"author": "SimpleX Chat",
"license": "AGPL-3.0"
}

View File

@@ -0,0 +1,430 @@
import {api, util} from "simplex-chat"
import {T, CEvt} from "@simplex-chat/types"
import {Config} from "./config.js"
import {ConversationState, GrokMessage} from "./state.js"
import {GrokApiClient} from "./grok.js"
import {teamQueueMessage, grokActivatedMessage, teamAddedMessage, teamLockedMessage} from "./messages.js"
import {log, logError} from "./util.js"
export class SupportBot {
private conversations = new Map<number, ConversationState>()
private pendingGrokJoins = new Map<string, number>() // memberId → mainGroupId
private grokGroupMap = new Map<number, number>() // mainGroupId → grokLocalGroupId
private reverseGrokMap = new Map<number, number>() // grokLocalGroupId → mainGroupId
private grokJoinResolvers = new Map<number, () => void>() // mainGroupId → resolve fn
constructor(
private mainChat: api.ChatApi,
private grokChat: api.ChatApi,
private grokApi: GrokApiClient,
private config: Config,
) {}
// --- Event Handlers (main bot) ---
onBusinessRequest(evt: CEvt.AcceptingBusinessRequest): void {
const groupId = evt.groupInfo.groupId
log(`New business request: groupId=${groupId}`)
this.conversations.set(groupId, {type: "welcome"})
}
async onNewChatItems(evt: CEvt.NewChatItems): Promise<void> {
for (const ci of evt.chatItems) {
try {
await this.processChatItem(ci)
} catch (err) {
logError(`Error processing chat item in group`, err)
}
}
}
async onLeftMember(evt: CEvt.LeftMember): Promise<void> {
const groupId = evt.groupInfo.groupId
const state = this.conversations.get(groupId)
if (!state) return
const member = evt.member
const bc = evt.groupInfo.businessChat
if (!bc) return
// Customer left
if (member.memberId === bc.customerId) {
log(`Customer left group ${groupId}, cleaning up`)
this.conversations.delete(groupId)
this.cleanupGrokMaps(groupId)
return
}
// Team member left — teamPending: gate not yet triggered, revert to teamQueue
if (state.type === "teamPending" && member.groupMemberId === state.teamMemberGId) {
log(`Team member left group ${groupId} (teamPending), reverting to teamQueue`)
this.conversations.set(groupId, {type: "teamQueue", userMessages: []})
return
}
// Team member left — teamLocked: one-way gate triggered, stay in team mode (add another member)
if (state.type === "teamLocked" && member.groupMemberId === state.teamMemberGId) {
log(`Team member left group ${groupId} (teamLocked), adding replacement team member`)
await this.addReplacementTeamMember(groupId)
return
}
// Grok left during grokMode
if (state.type === "grokMode" && member.groupMemberId === state.grokMemberGId) {
log(`Grok left group ${groupId} during grokMode, reverting to teamQueue`)
this.conversations.set(groupId, {type: "teamQueue", userMessages: []})
this.cleanupGrokMaps(groupId)
return
}
}
onDeletedMemberUser(evt: CEvt.DeletedMemberUser): void {
const groupId = evt.groupInfo.groupId
log(`Bot removed from group ${groupId}`)
this.conversations.delete(groupId)
this.cleanupGrokMaps(groupId)
}
onGroupDeleted(evt: CEvt.GroupDeleted): void {
const groupId = evt.groupInfo.groupId
log(`Group ${groupId} deleted`)
this.conversations.delete(groupId)
this.cleanupGrokMaps(groupId)
}
onMemberConnected(evt: CEvt.ConnectedToGroupMember): void {
log(`Member connected in group ${evt.groupInfo.groupId}: ${evt.member.memberProfile.displayName}`)
}
// --- Event Handler (Grok agent) ---
async onGrokGroupInvitation(evt: CEvt.ReceivedGroupInvitation): Promise<void> {
const memberId = evt.groupInfo.membership.memberId
const mainGroupId = this.pendingGrokJoins.get(memberId)
if (mainGroupId === undefined) {
log(`Grok received unexpected group invitation (memberId=${memberId}), ignoring`)
return
}
log(`Grok joining group: mainGroupId=${mainGroupId}, grokGroupId=${evt.groupInfo.groupId}`)
this.pendingGrokJoins.delete(memberId)
try {
await this.grokChat.apiJoinGroup(evt.groupInfo.groupId)
} catch (err) {
logError(`Grok failed to join group ${evt.groupInfo.groupId}`, err)
return
}
// Join succeeded — set maps and resolve waiter
this.grokGroupMap.set(mainGroupId, evt.groupInfo.groupId)
this.reverseGrokMap.set(evt.groupInfo.groupId, mainGroupId)
const resolver = this.grokJoinResolvers.get(mainGroupId)
if (resolver) {
this.grokJoinResolvers.delete(mainGroupId)
resolver()
}
}
// --- Internal Processing ---
private async processChatItem(ci: T.AChatItem): Promise<void> {
const {chatInfo, chatItem} = ci
if (chatInfo.type !== "group") return
const groupInfo = chatInfo.groupInfo
if (!groupInfo.businessChat) return
const groupId = groupInfo.groupId
const state = this.conversations.get(groupId)
if (!state) return
if (chatItem.chatDir.type === "groupSnd") return
if (chatItem.chatDir.type !== "groupRcv") return
const sender = chatItem.chatDir.groupMember
const isCustomer = sender.memberId === groupInfo.businessChat.customerId
const isTeamMember = (state.type === "teamPending" || state.type === "teamLocked")
&& sender.groupMemberId === state.teamMemberGId
const isGrok = state.type === "grokMode"
&& state.grokMemberGId === sender.groupMemberId
if (isGrok) return
if (isCustomer) await this.onCustomerMessage(groupId, groupInfo, chatItem, state)
else if (isTeamMember) await this.onTeamMemberMessage(groupId, state)
}
private async onCustomerMessage(
groupId: number,
groupInfo: T.GroupInfo,
chatItem: T.ChatItem,
state: ConversationState,
): Promise<void> {
const cmd = util.ciBotCommand(chatItem)
const text = util.ciContentText(chatItem)?.trim() || null
switch (state.type) {
case "welcome": {
if (!text) return
await this.forwardToTeam(groupId, groupInfo, text)
await this.sendToGroup(groupId, teamQueueMessage(this.config.timezone))
this.conversations.set(groupId, {type: "teamQueue", userMessages: [text]})
break
}
case "teamQueue": {
if (cmd?.keyword === "grok") {
await this.activateGrok(groupId, state)
return
}
if (cmd?.keyword === "team") {
await this.activateTeam(groupId, state)
return
}
if (!text) return
await this.forwardToTeam(groupId, groupInfo, text)
state.userMessages.push(text)
break
}
case "grokMode": {
if (cmd?.keyword === "grok") return
if (cmd?.keyword === "team") {
await this.activateTeam(groupId, state)
return
}
if (!text) return
await this.forwardToTeam(groupId, groupInfo, text)
await this.forwardToGrok(groupId, text, state)
break
}
case "teamPending": {
if (cmd?.keyword === "grok") {
await this.sendToGroup(groupId, teamLockedMessage)
return
}
// /team → ignore (already team). Other text → no forwarding (team sees directly).
break
}
case "teamLocked": {
if (cmd?.keyword === "grok") {
await this.sendToGroup(groupId, teamLockedMessage)
return
}
// No action — team sees directly
break
}
}
}
private async onTeamMemberMessage(groupId: number, state: ConversationState): Promise<void> {
if (state.type !== "teamPending") return
log(`Team member engaged in group ${groupId}, locking to teamLocked`)
this.conversations.set(groupId, {type: "teamLocked", teamMemberGId: state.teamMemberGId})
}
// --- Grok Activation ---
private async activateGrok(
groupId: number,
state: {type: "teamQueue"; userMessages: string[]},
): Promise<void> {
const grokContactId = this.config.grokContact!.id
let member: T.GroupMember | undefined
try {
member = await this.mainChat.apiAddMember(groupId, grokContactId, T.GroupMemberRole.Member)
} catch (err) {
logError(`Failed to invite Grok to group ${groupId}`, err)
await this.sendToGroup(groupId, "Grok is temporarily unavailable. Please try again or click /team for a team member.")
return
}
this.pendingGrokJoins.set(member.memberId, groupId)
await this.sendToGroup(groupId, grokActivatedMessage)
// Wait for Grok agent to join the group
const joined = await this.waitForGrokJoin(groupId, 30000)
if (!joined) {
this.pendingGrokJoins.delete(member.memberId)
await this.sendToGroup(groupId, "Grok is temporarily unavailable. Please try again or click /team for a team member.")
return
}
// Verify state hasn't changed while awaiting (e.g., user sent /team concurrently)
const currentState = this.conversations.get(groupId)
if (!currentState || currentState.type !== "teamQueue") {
log(`State changed during Grok activation for group ${groupId} (now ${currentState?.type}), aborting`)
try {
await this.mainChat.apiRemoveMembers(groupId, [member.groupMemberId])
} catch {
// ignore
}
this.cleanupGrokMaps(groupId)
return
}
// Grok joined — call API with accumulated messages
try {
const initialUserMsg = state.userMessages.join("\n")
const response = await this.grokApi.chat([], initialUserMsg)
// Re-check state after async API call — another event may have changed it
const postApiState = this.conversations.get(groupId)
if (!postApiState || postApiState.type !== "teamQueue") {
log(`State changed during Grok API call for group ${groupId} (now ${postApiState?.type}), aborting`)
try {
await this.mainChat.apiRemoveMembers(groupId, [member.groupMemberId])
} catch {
// ignore
}
this.cleanupGrokMaps(groupId)
return
}
const history: GrokMessage[] = [
{role: "user", content: initialUserMsg},
{role: "assistant", content: response},
]
const grokLocalGId = this.grokGroupMap.get(groupId)
if (grokLocalGId === undefined) {
log(`Grok map entry missing after join for group ${groupId}`)
return
}
await this.grokChat.apiSendTextMessage([T.ChatType.Group, grokLocalGId], response)
this.conversations.set(groupId, {
type: "grokMode",
grokMemberGId: member.groupMemberId,
history,
})
} catch (err) {
logError(`Grok API/send failed for group ${groupId}`, err)
// Remove Grok since activation failed after join
try {
await this.mainChat.apiRemoveMembers(groupId, [member.groupMemberId])
} catch {
// ignore
}
this.cleanupGrokMaps(groupId)
await this.sendToGroup(groupId, "Grok is temporarily unavailable. Please try again or click /team for a team member.")
// Stay in teamQueue
}
}
// --- Grok Message Forwarding ---
private async forwardToGrok(
groupId: number,
text: string,
state: {type: "grokMode"; grokMemberGId: number; history: GrokMessage[]},
): Promise<void> {
try {
const response = await this.grokApi.chat(state.history, text)
state.history.push({role: "user", content: text})
state.history.push({role: "assistant", content: response})
const grokLocalGId = this.grokGroupMap.get(groupId)
if (grokLocalGId !== undefined) {
await this.grokChat.apiSendTextMessage([T.ChatType.Group, grokLocalGId], response)
}
} catch (err) {
logError(`Grok API error for group ${groupId}`, err)
// Per plan: revert to teamQueue on Grok API failure — remove Grok, clean up
try {
await this.mainChat.apiRemoveMembers(groupId, [state.grokMemberGId])
} catch {
// ignore — may have already left
}
this.cleanupGrokMaps(groupId)
this.conversations.set(groupId, {type: "teamQueue", userMessages: []})
await this.sendToGroup(groupId, "Grok is temporarily unavailable. Please try again or click /team for a team member.")
}
}
// --- Team Actions ---
private async forwardToTeam(groupId: number, groupInfo: T.GroupInfo, text: string): Promise<void> {
const name = groupInfo.groupProfile.displayName || `group-${groupId}`
const fwd = `[${name} #${groupId}]\n${text}`
try {
await this.mainChat.apiSendTextMessage(
[T.ChatType.Group, this.config.teamGroup.id],
fwd,
)
} catch (err) {
logError(`Failed to forward to team for group ${groupId}`, err)
}
}
private async activateTeam(groupId: number, state: ConversationState): Promise<void> {
// Remove Grok immediately if present (per spec: "When switching to team mode, Grok is removed")
const wasGrokMode = state.type === "grokMode"
if (wasGrokMode) {
try {
await this.mainChat.apiRemoveMembers(groupId, [state.grokMemberGId])
} catch {
// ignore — may have already left
}
this.cleanupGrokMaps(groupId)
}
try {
const teamContactId = this.config.teamMembers[0].id
const member = await this.mainChat.apiAddMember(groupId, teamContactId, T.GroupMemberRole.Member)
this.conversations.set(groupId, {
type: "teamPending",
teamMemberGId: member.groupMemberId,
})
await this.sendToGroup(groupId, teamAddedMessage(this.config.timezone))
} catch (err) {
logError(`Failed to add team member to group ${groupId}`, err)
// If Grok was removed, state is stale (grokMode but Grok gone) — revert to teamQueue
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.")
}
}
// --- Helpers ---
private async addReplacementTeamMember(groupId: number): Promise<void> {
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})
} catch (err) {
logError(`Failed to add replacement team member to group ${groupId}`, err)
// Stay in teamLocked with stale teamMemberGId — one-way gate must hold
// Team will see the message in team group and can join manually
}
}
private async sendToGroup(groupId: number, text: string): Promise<void> {
try {
await this.mainChat.apiSendTextMessage([T.ChatType.Group, groupId], text)
} catch (err) {
logError(`Failed to send message to group ${groupId}`, err)
}
}
private waitForGrokJoin(groupId: number, timeout: number): Promise<boolean> {
if (this.grokGroupMap.has(groupId)) return Promise.resolve(true)
return new Promise<boolean>((resolve) => {
const timer = setTimeout(() => {
this.grokJoinResolvers.delete(groupId)
resolve(false)
}, timeout)
this.grokJoinResolvers.set(groupId, () => {
clearTimeout(timer)
resolve(true)
})
})
}
private cleanupGrokMaps(groupId: number): void {
const grokLocalGId = this.grokGroupMap.get(groupId)
this.grokGroupMap.delete(groupId)
if (grokLocalGId !== undefined) {
this.reverseGrokMap.delete(grokLocalGId)
}
}
}

View File

@@ -0,0 +1,74 @@
export interface IdName {
id: number
name: string
}
export interface Config {
dbPrefix: string
grokDbPrefix: string
teamGroup: IdName
teamMembers: IdName[]
grokContact: IdName | null // null during first-run
groupLinks: string
timezone: string
grokApiKey: string
firstRun: boolean
}
export function parseIdName(s: string): IdName {
const i = s.indexOf(":")
if (i < 1) throw new Error(`Invalid ID:name format: "${s}"`)
const id = parseInt(s.slice(0, i), 10)
if (isNaN(id)) throw new Error(`Invalid ID:name format (non-numeric ID): "${s}"`)
return {id, name: s.slice(i + 1)}
}
function requiredArg(args: string[], flag: string): string {
const i = args.indexOf(flag)
if (i < 0 || i + 1 >= args.length) throw new Error(`Missing required argument: ${flag}`)
return args[i + 1]
}
function optionalArg(args: string[], flag: string, defaultValue: string): string {
const i = args.indexOf(flag)
if (i < 0 || i + 1 >= args.length) return defaultValue
return args[i + 1]
}
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 groupLinks = optionalArg(args, "--group-links", "")
const timezone = optionalArg(args, "--timezone", "UTC")
return {
dbPrefix,
grokDbPrefix,
teamGroup,
teamMembers,
grokContact,
groupLinks,
timezone,
grokApiKey,
firstRun,
}
}

View File

@@ -0,0 +1,45 @@
import {GrokMessage} from "./state.js"
import {log} from "./util.js"
interface GrokApiMessage {
role: "system" | "user" | "assistant"
content: string
}
interface GrokApiResponse {
choices: {message: {content: string}}[]
}
export class GrokApiClient {
constructor(private apiKey: string, private docsContext: string) {}
async chat(history: GrokMessage[], userMessage: string): Promise<string> {
const messages: GrokApiMessage[] = [
{role: "system", content: this.systemPrompt()},
...history.slice(-20),
{role: "user", content: userMessage},
]
log(`Grok API call: ${history.length} history msgs + new user msg (${userMessage.length} chars)`)
const resp = await fetch("https://api.x.ai/v1/chat/completions", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${this.apiKey}`,
},
body: JSON.stringify({model: "grok-3", messages, max_tokens: 2048}),
})
if (!resp.ok) {
const body = await resp.text()
throw new Error(`Grok API ${resp.status}: ${body}`)
}
const data = (await resp.json()) as GrokApiResponse
const content = data.choices[0]?.message?.content
if (!content) throw new Error("Grok API returned empty response")
log(`Grok API response: ${content.length} chars`)
return content
}
private systemPrompt(): string {
return `You are a privacy expert and SimpleX Chat evangelist. You know everything about SimpleX Chat apps, network, design choices, and trade-offs. Be helpful, accurate, and concise. If you don't know something, say so honestly rather than guessing. For every criticism, explain why the team made that design choice.\n\n${this.docsContext}`
}
}

View File

@@ -0,0 +1,179 @@
import {readFileSync} from "fs"
import {join} from "path"
import {bot, api} from "simplex-chat"
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"
async function main(): Promise<void> {
const config = parseConfig(process.argv.slice(2))
log("Config parsed", {
dbPrefix: config.dbPrefix,
grokDbPrefix: config.grokDbPrefix,
teamGroup: config.teamGroup,
teamMembers: config.teamMembers,
grokContact: config.grokContact,
firstRun: config.firstRun,
timezone: config.timezone,
})
// --- Init Grok agent (direct ChatApi) ---
log("Initializing Grok agent...")
const grokChat = await api.ChatApi.init(config.grokDbPrefix)
let grokUser = await grokChat.apiGetActiveUser()
if (!grokUser) {
log("No Grok user, creating...")
grokUser = await grokChat.apiCreateActiveUser({displayName: "Grok AI", fullName: ""})
}
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
const events: api.EventSubscribers = {
acceptingBusinessRequest: (evt) => supportBot?.onBusinessRequest(evt),
newChatItems: (evt) => supportBot?.onNewChatItems(evt),
leftMember: (evt) => supportBot?.onLeftMember(evt),
deletedMemberUser: (evt) => supportBot?.onDeletedMemberUser(evt),
groupDeleted: (evt) => supportBot?.onGroupDeleted(evt),
connectedToGroupMember: (evt) => supportBot?.onMemberConnected(evt),
}
log("Initializing main bot...")
const [mainChat, mainUser, _mainAddress] = await bot.run({
profile: {displayName: "SimpleX Support", fullName: ""},
dbOpts: {dbFilePrefix: config.dbPrefix},
options: {
addressSettings: {
businessAddress: true,
autoAccept: true,
welcomeMessage: welcomeMessage(config.groupLinks),
},
commands: [
{type: "command", keyword: "grok", label: "Ask Grok AI"},
{type: "command", keyword: "team", label: "Switch to team"},
],
useBotProfile: true,
},
events,
})
log(`Main bot user: ${mainUser.profile.displayName}`)
// --- Startup validation ---
log("Validating config against live data...")
// 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)
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)
}
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}`)
log("All config validated.")
// Load Grok context docs
let docsContext = ""
try {
docsContext = readFileSync(join(process.cwd(), "docs", "simplex-context.md"), "utf-8")
log(`Loaded Grok context docs: ${docsContext.length} chars`)
} catch {
log("Warning: docs/simplex-context.md not found, Grok will operate without context docs")
}
const grokApi = new GrokApiClient(config.grokApiKey, docsContext)
// Create SupportBot — event handlers now route through it
supportBot = new SupportBot(mainChat, grokChat, grokApi, config)
log("SupportBot initialized. Bot running.")
// Subscribe Grok agent event handlers
grokChat.on("receivedGroupInvitation", async (evt) => {
await supportBot?.onGrokGroupInvitation(evt)
})
// Keep process alive
process.on("SIGINT", () => {
log("Received SIGINT, shutting down...")
process.exit(0)
})
process.on("SIGTERM", () => {
log("Received SIGTERM, shutting down...")
process.exit(0)
})
}
main().catch(err => {
logError("Fatal error", err)
process.exit(1)
})

View File

@@ -0,0 +1,19 @@
import {isWeekend} from "./util.js"
export function welcomeMessage(groupLinks: string): string {
return `Hello! Feel free to ask any question about SimpleX Chat.\n*Only SimpleX Chat team has access to your messages.* This is a SimpleX Chat team bot — it is not any LLM or AI.${groupLinks ? `\n*Join public groups*: ${groupLinks}` : ""}\nPlease send questions in English, you can use translator.`
}
export function teamQueueMessage(timezone: string): string {
const hours = isWeekend(timezone) ? "48" : "24"
return `Thank you for your message, it is forwarded to the team.\nIt may take a team member up to ${hours} hours to reply.\n\nClick /grok if your question is about SimpleX apps or network, is not sensitive, and you want Grok LLM to answer it right away. *Your previous message and all subsequent messages will be forwarded to Grok* until you click /team. You can ask Grok questions in any language and it will not see your profile name.\n\nWe appreciate if you try Grok: you can learn a lot about SimpleX Chat from it. It is objective, answers the way our team would, and it saves our team time.`
}
export const grokActivatedMessage = `*You are now chatting with Grok. You can send questions in any language.* Your message(s) have been forwarded.\nSend /team at any time to switch to a human team member.`
export function teamAddedMessage(timezone: string): string {
const hours = isWeekend(timezone) ? "48" : "24"
return `A team member has been added and will reply within ${hours} hours. You can keep describing your issue — they will see the full conversation.`
}
export const teamLockedMessage = "You are now in team mode. A team member will reply to your message."

View File

@@ -0,0 +1,11 @@
export interface GrokMessage {
role: "user" | "assistant"
content: string
}
export type ConversationState =
| {type: "welcome"}
| {type: "teamQueue"; userMessages: string[]}
| {type: "grokMode"; grokMemberGId: number; history: GrokMessage[]}
| {type: "teamPending"; teamMemberGId: number}
| {type: "teamLocked"; teamMemberGId: number}

View File

@@ -0,0 +1,14 @@
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"
}
export function log(msg: string, ...args: unknown[]): void {
const ts = new Date().toISOString()
console.log(`[${ts}] ${msg}`, ...args)
}
export function logError(msg: string, err: unknown): void {
const ts = new Date().toISOString()
console.error(`[${ts}] ${msg}`, err)
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,23 @@
{
"include": ["src"],
"compilerOptions": {
"declaration": true,
"forceConsistentCasingInFileNames": true,
"lib": ["ES2022"],
"module": "Node16",
"moduleResolution": "Node16",
"noFallthroughCasesInSwitch": true,
"noImplicitAny": true,
"noImplicitReturns": true,
"noImplicitThis": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noEmitOnError": true,
"outDir": "dist",
"sourceMap": true,
"strict": true,
"strictNullChecks": true,
"target": "ES2022",
"types": ["node"]
}
}

View File

@@ -0,0 +1,10 @@
import {defineConfig} from "vitest/config"
export default defineConfig({
test: {
include: ["bot.test.ts"],
typecheck: {
include: ["bot.test.ts"],
},
},
})