diff --git a/apps/simplex-support-bot/bot.test.ts b/apps/simplex-support-bot/bot.test.ts index bb8f664703..512f13c05f 100644 --- a/apps/simplex-support-bot/bot.test.ts +++ b/apps/simplex-support-bot/bot.test.ts @@ -75,12 +75,16 @@ class MockChatApi { added: AddedMember[] = [] removed: RemovedMembers[] = [] joined: number[] = [] + members: Map = new Map() // groupId → members list private addMemberFail = false + private addMemberDuplicate = false private nextMemberGId = 50 apiAddMemberWillFail() { this.addMemberFail = true } + apiAddMemberWillDuplicate() { this.addMemberDuplicate = true } setNextGroupMemberId(id: number) { this.nextMemberGId = id } + setGroupMembers(groupId: number, members: any[]) { this.members.set(groupId, members) } async apiSendTextMessage(chat: [string, number], text: string) { this.sent.push({chat, text}) @@ -88,6 +92,12 @@ class MockChatApi { async apiAddMember(groupId: number, contactId: number, role: string) { if (this.addMemberFail) { this.addMemberFail = false; throw new Error("apiAddMember failed") } + if (this.addMemberDuplicate) { + this.addMemberDuplicate = false + const err: any = new Error("groupDuplicateMember") + err.chatError = {type: "error", errorType: {type: "groupDuplicateMember", contactName: "TeamGuy"}} + throw err + } const gid = this.nextMemberGId++ this.added.push({groupId, contactId, role}) return {groupMemberId: gid, memberId: `member-${gid}`} @@ -101,6 +111,10 @@ class MockChatApi { this.joined.push(groupId) } + async apiListMembers(groupId: number) { + return this.members.get(groupId) || [] + } + sentTo(groupId: number): string[] { return this.sent.filter(m => m.chat[1] === groupId).map(m => m.text) } @@ -112,7 +126,8 @@ class MockChatApi { reset() { this.sent = []; this.added = []; this.removed = []; this.joined = [] - this.addMemberFail = false; this.nextMemberGId = 50 + this.members.clear() + this.addMemberFail = false; this.addMemberDuplicate = false; this.nextMemberGId = 50 } } @@ -295,6 +310,11 @@ const grokAgent = { membership: {memberId}, }, } as any) + // Waiter resolves on connectedToGroupMember, not on apiJoinGroup + bot.onGrokMemberConnected({ + groupInfo: {groupId: GROK_LOCAL}, + member: {memberProfile: {displayName: "Bot"}}, + } as any) }, async timesOut() { @@ -368,15 +388,14 @@ const TEAM_ADD_ERROR = // ─── Setup ────────────────────────────────────────────────────── const config = { - teamGroup: {id: 1, name: "SupportTeam"}, - teamMembers: [{id: 2, name: "Bob"}], - grokContact: {id: 4, name: "Grok AI"}, - timezone: "America/New_York", - groupLinks: "https://simplex.chat/contact#...", - grokApiKey: "test-key", - dbPrefix: "./test-data/bot", - grokDbPrefix:"./test-data/grok", - firstRun: false, + teamGroup: {id: 1, name: "SupportTeam"}, + teamMembers: [{id: 2, name: "Bob"}], + grokContactId: 4, + timezone: "America/New_York", + groupLinks: "https://simplex.chat/contact#...", + grokApiKey: "test-key", + dbPrefix: "./test-data/bot", + grokDbPrefix: "./test-data/grok", } beforeEach(() => { @@ -1020,6 +1039,8 @@ describe("Race Conditions", () => { const grokPromise = customer.sends("/grok") await grokAgent.joins() + // Flush microtasks so activateGrok proceeds past waitForGrokJoin into grokApi.chat + await new Promise(r => setTimeout(r, 0)) // activateGrok now blocked on grokApi.chat // While API call is pending, /team changes state @@ -1131,16 +1152,17 @@ describe("Edge Cases", () => { hasNoState(999) }) - test("message in group with no conversation state → ignored", async () => { - // Group 888 never had onBusinessRequest called + test("message in business chat with no state → re-initialized to teamQueue", async () => { + // Group 888 never had onBusinessRequest called (e.g., bot restarted) const ci = customerChatItem("Hello", null) ci.chatInfo.groupInfo = businessGroupInfo(888) mainChat.sent = [] await bot.onNewChatItems({chatItems: [ci]} as any) - expect(mainChat.sent.length).toBe(0) - hasNoState(888) + // Re-initialized as teamQueue, message forwarded to team (no queue reply — already past welcome) + stateIs(888, "teamQueue") + teamGroup.received("[Alice #888]\nHello") }) test("Grok's own messages in grokMode → ignored by bot", async () => { @@ -1333,6 +1355,40 @@ describe("Edge Cases", () => { // addReplacementTeamMember failed, but one-way gate holds stateIs(GROUP_ID, "teamLocked") }) + + test("/grok with null grokContactId → unavailable message", async () => { + const nullGrokConfig = {...config, grokContactId: null} + const nullBot = new SupportBot(mainChat as any, grokChat as any, grokApi as any, nullGrokConfig as any) + nullBot.onBusinessRequest({groupInfo: businessGroupInfo()} as any) + const ci = customerChatItem("Hello", null) + await nullBot.onNewChatItems({chatItems: [ci]} as any) + mainChat.sent = [] + + const grokCi = customerChatItem("/grok", "grok") + await nullBot.onNewChatItems({chatItems: [grokCi]} as any) + + const msgs = mainChat.sentTo(GROUP_ID) + expect(msgs).toContain("Grok is temporarily unavailable. Please try again or click /team for a team member.") + const state = (nullBot as any).conversations.get(GROUP_ID) + expect(state.type).toBe("teamQueue") + }) + + test("/team with empty teamMembers → unavailable message", async () => { + const noTeamConfig = {...config, teamMembers: []} + const noTeamBot = new SupportBot(mainChat as any, grokChat as any, grokApi as any, noTeamConfig as any) + noTeamBot.onBusinessRequest({groupInfo: businessGroupInfo()} as any) + const ci = customerChatItem("Hello", null) + await noTeamBot.onNewChatItems({chatItems: [ci]} as any) + mainChat.sent = [] + + const teamCi = customerChatItem("/team", "team") + await noTeamBot.onNewChatItems({chatItems: [teamCi]} as any) + + const msgs = mainChat.sentTo(GROUP_ID) + expect(msgs).toContain("No team members are available yet. Please try again later or click /grok.") + const state = (noTeamBot as any).conversations.get(GROUP_ID) + expect(state.type).toBe("teamQueue") + }) }) @@ -1418,6 +1474,169 @@ describe("End-to-End Flows", () => { }) +// ─── 15. Restart Recovery ─────────────────────────────────────── + +describe("Restart Recovery", () => { + + test("after restart, customer message in unknown group → re-init to teamQueue, forward", async () => { + // Simulate restart: no onBusinessRequest was called for group 777 + const ci = customerChatItem("I had a question earlier", null) + ci.chatInfo.groupInfo = businessGroupInfo(777) + mainChat.sent = [] + + await bot.onNewChatItems({chatItems: [ci]} as any) + + // Re-initialized as teamQueue, message forwarded to team (no queue reply — already past welcome) + stateIs(777, "teamQueue") + teamGroup.received("[Alice #777]\nI had a question earlier") + }) + + test("after restart re-init, /grok works in re-initialized group", async () => { + // Re-init group via first message + const ci = customerChatItem("Hello", null) + ci.chatInfo.groupInfo = businessGroupInfo(777) + await bot.onNewChatItems({chatItems: [ci]} as any) + stateIs(777, "teamQueue") + + // Now /grok + mainChat.setNextGroupMemberId(80) + lastGrokMemberGId = 80 + grokApi.willRespond("Grok answer") + const grokCi = customerChatItem("/grok", "grok") + grokCi.chatInfo.groupInfo = businessGroupInfo(777) + const p = bot.onNewChatItems({chatItems: [grokCi]} as any) + // Grok joins + await new Promise(r => setTimeout(r, 0)) + const memberId = `member-${lastGrokMemberGId}` + await bot.onGrokGroupInvitation({ + groupInfo: {groupId: 201, membership: {memberId}}, + } as any) + bot.onGrokMemberConnected({ + groupInfo: {groupId: 201}, + member: {memberProfile: {displayName: "Bot"}}, + } as any) + await p + + stateIs(777, "grokMode") + }) +}) + + +// ─── 16. Grok connectedToGroupMember ─────────────────────────── + +describe("Grok connectedToGroupMember", () => { + + test("waiter not resolved by onGrokGroupInvitation alone", async () => { + mainChat.setNextGroupMemberId(60) + lastGrokMemberGId = 60 + await reachTeamQueue("Hello") + grokApi.willRespond("answer") + + const p = customer.sends("/grok") + + // Only fire invitation (no connectedToGroupMember) — waiter should NOT resolve + await new Promise(r => setTimeout(r, 0)) + const memberId = `member-${lastGrokMemberGId}` + await bot.onGrokGroupInvitation({ + groupInfo: {groupId: GROK_LOCAL, membership: {memberId}}, + } as any) + + // Maps set but waiter not resolved — state still teamQueue + expect((bot as any).grokGroupMap.has(GROUP_ID)).toBe(true) + stateIs(GROUP_ID, "teamQueue") + + // Now fire connectedToGroupMember → waiter resolves + bot.onGrokMemberConnected({ + groupInfo: {groupId: GROK_LOCAL}, + member: {memberProfile: {displayName: "Bot"}}, + } as any) + await p + + stateIs(GROUP_ID, "grokMode") + }) + + test("onGrokMemberConnected for unknown group → ignored", () => { + // Should not throw + bot.onGrokMemberConnected({ + groupInfo: {groupId: 9999}, + member: {memberProfile: {displayName: "Someone"}}, + } as any) + }) +}) + + +// ─── 17. groupDuplicateMember Handling ───────────────────────── + +describe("groupDuplicateMember Handling", () => { + + test("/team with duplicate member → finds existing, transitions to teamPending", async () => { + await reachTeamQueue("Hello") + mainChat.apiAddMemberWillDuplicate() + mainChat.setGroupMembers(GROUP_ID, [ + {groupMemberId: 42, memberContactId: 2, memberStatus: "memConnected"}, + ]) + mainChat.sent = [] + + await customer.sends("/team") + + customer.received(TEAM_ADDED_24H) + const state = (bot as any).conversations.get(GROUP_ID) + expect(state.type).toBe("teamPending") + expect(state.teamMemberGId).toBe(42) + }) + + test("/team with duplicate but member not found in list → error message", async () => { + await reachTeamQueue("Hello") + mainChat.apiAddMemberWillDuplicate() + mainChat.setGroupMembers(GROUP_ID, []) // empty — member not found + mainChat.sent = [] + + await customer.sends("/team") + + customer.received(TEAM_ADD_ERROR) + stateIs(GROUP_ID, "teamQueue") + }) + + test("replacement team member with duplicate → finds existing, stays locked", async () => { + await reachTeamLocked() + mainChat.apiAddMemberWillDuplicate() + mainChat.setGroupMembers(GROUP_ID, [ + {groupMemberId: 99, memberContactId: 2, memberStatus: "memConnected"}, + ]) + + await teamMember.leaves() + + const state = (bot as any).conversations.get(GROUP_ID) + expect(state.type).toBe("teamLocked") + expect(state.teamMemberGId).toBe(99) + }) +}) + + +// ─── 18. DM Contact Received ─────────────────────────────────── + +describe("DM Contact Received", () => { + + test("onMemberContactReceivedInv from team group → no crash", () => { + bot.onMemberContactReceivedInv({ + contact: {contactId: 10}, + groupInfo: {groupId: TEAM_GRP_ID}, + member: {memberProfile: {displayName: "TeamGuy"}}, + } as any) + // No error, logged acceptance + }) + + test("onMemberContactReceivedInv from non-team group → no crash", () => { + bot.onMemberContactReceivedInv({ + contact: {contactId: 11}, + groupInfo: {groupId: 999}, + member: {memberProfile: {displayName: "Stranger"}}, + } as any) + // No error + }) +}) + + // ═══════════════════════════════════════════════════════════════ // Coverage Matrix // ═══════════════════════════════════════════════════════════════ @@ -1449,3 +1668,7 @@ describe("End-to-End Flows", () => { // Concurrent conversations | 13.7 // History passed to GrokApiClient | 13.5 // Full E2E flows | 14.1, 14.2 +// Restart recovery (re-init teamQueue) | 15.1, 15.2 +// Grok connectedToGroupMember waiter | 16.1, 16.2 +// groupDuplicateMember handling | 17.1, 17.2, 17.3 +// DM contact received | 18.1, 18.2 diff --git a/apps/simplex-support-bot/plans/20260207-support-bot-implementation.md b/apps/simplex-support-bot/plans/20260207-support-bot-implementation.md index 38f098d980..d01b146c88 100644 --- a/apps/simplex-support-bot/plans/20260207-support-bot-implementation.md +++ b/apps/simplex-support-bot/plans/20260207-support-bot-implementation.md @@ -49,9 +49,9 @@ apps/simplex-chat-support-bot/ └── simplex-context.md # Curated SimpleX docs injected into Grok system prompt ``` -## 4. Configuration — ID:name Format +## 4. Configuration -All entity references use `ID:name` format. The bot validates each pair at startup against live data from `apiListContacts()` / `apiListGroups()`. +All runtime state (team group ID, Grok contact ID) is auto-resolved and persisted to `{dbPrefix}_state.json`. No manual IDs needed for core entities. **CLI args:** @@ -59,14 +59,10 @@ All entity references use `ID:name` format. The bot validates each pair at start |-----|----------|---------|--------|---------| | `--db-prefix` | No | `./data/bot` | path | Main bot database file prefix | | `--grok-db-prefix` | No | `./data/grok` | path | Grok agent database file prefix | -| `--team-group` | Yes | — | `ID:name` | Group for forwarding customer messages to team | -| `--team-members` | Yes | — | `ID:name,...` | Comma-separated team member contacts | -| `--grok-contact` | Yes* | — | `ID:name` | Grok agent's contactId in main bot's database | +| `--team-group` | Yes | — | `name` | Team group display name (auto-created if absent) | +| `--team-members` | No | `""` | `ID:name,...` | Comma-separated team member contacts (optional) | | `--group-links` | No | `""` | string | Public group link(s) for welcome message | | `--timezone` | No | `"UTC"` | IANA tz | For weekend detection (24h vs 48h) | -| `--first-run` | No | false | flag | Auto-establish contact between bot and Grok agent | - -*`--grok-contact` required unless `--first-run` is used. **Env vars:** `GROK_API_KEY` (required) — xAI API key. @@ -74,34 +70,41 @@ All entity references use `ID:name` format. The bot validates each pair at start interface Config { dbPrefix: string grokDbPrefix: string - teamGroup: {id: number; name: string} - teamMembers: {id: number; name: string}[] - grokContact: {id: number; name: string} | null // null during first-run + teamGroup: {id: number; name: string} // id=0 at parse time, resolved at startup + teamMembers: {id: number; name: string}[] // optional, empty if not provided + grokContactId: number | null // resolved at startup from state file groupLinks: string timezone: string grokApiKey: string - firstRun: boolean } ``` -**ID:name parsing:** -```typescript -function parseIdName(s: string): {id: number; name: string} { - const i = s.indexOf(":") - if (i < 1) throw new Error(`Invalid ID:name format: "${s}"`) - return {id: parseInt(s.slice(0, i), 10), name: s.slice(i + 1)} -} +**State file** — `{dbPrefix}_state.json`: +```json +{"teamGroupId": 123, "grokContactId": 4} ``` -**Startup validation** (exact API calls): +Both IDs are persisted to ensure the bot reconnects to the same entities across restarts, even if multiple groups share the same display name. -| What | API Call | Validation | -|------|----------|------------| -| Team group | `mainChat.apiListGroups(userId)` → find by `groupId === config.teamGroup.id` | Assert `groupProfile.displayName === config.teamGroup.name` | -| Team members | `mainChat.apiListContacts(userId)` → find each by `contactId` | Assert `profile.displayName === member.name` for each | -| Grok contact | `mainChat.apiListContacts(userId)` → find by `contactId === config.grokContact.id` | Assert `profile.displayName === config.grokContact.name` | +**Grok contact resolution** (auto-establish): +1. Read `grokContactId` from state file → validate it exists in `apiListContacts` +2. If not found: create invitation link (`apiCreateLink`), connect Grok agent (`apiConnectActiveUser`), wait for `contactConnected` (60s), persist new contact ID +3. If Grok contact is unavailable, bot runs but `/grok` returns "temporarily unavailable" -Fail-fast with descriptive error on any mismatch. +**Team group resolution** (auto-create): +1. Read `teamGroupId` from state file → validate it exists in `apiListGroups` +2. If not found: create with `apiNewGroup`, persist new group ID + +**Team group invite link lifecycle:** +1. Delete any stale link from previous run: `apiDeleteGroupLink` (best-effort) +2. Create invite link: `apiCreateGroupLink(teamGroupId, GroupMemberRole.Member)` +3. Display link on stdout for team members to join +4. Schedule deletion after 10 minutes: `apiDeleteGroupLink(teamGroupId)` +5. On shutdown (SIGINT/SIGTERM), delete link before exit (idempotent, best-effort) + +**Team member validation** (optional): +- If `--team-members` provided: validate each contact ID/name pair via `apiListContacts`, fail-fast on mismatch +- If not provided: bot runs without team members; `/team` returns "No team members are available yet" ## 5. State Machine @@ -139,16 +142,20 @@ teamLocked ──(any)──> no action (team sees directly) **Solution:** In-process maps correlated via protocol-level `memberId` (string, same across databases). ```typescript -const pendingGrokJoins = new Map() // memberId → mainGroupId -const grokGroupMap = new Map() // mainGroupId → grokLocalGroupId -const reverseGrokMap = new Map() // grokLocalGroupId → mainGroupId +const pendingGrokJoins = new Map() // memberId → mainGroupId +const grokGroupMap = new Map() // mainGroupId → grokLocalGroupId +const reverseGrokMap = new Map() // grokLocalGroupId → mainGroupId +const grokJoinResolvers = new Map void>() // mainGroupId → resolve fn ``` **Flow:** 1. Main bot: `mainChat.apiAddMember(mainGroupId, grokContactId, "member")` → response `member.memberId` 2. Store: `pendingGrokJoins.set(member.memberId, mainGroupId)` -3. Grok agent receives `receivedGroupInvitation` event → `evt.groupInfo.membership.memberId` matches → `grokChat.apiJoinGroup(evt.groupInfo.groupId)` → store bidirectional mapping -4. Send Grok response: `grokChat.apiSendTextMessage([T.ChatType.Group, grokGroupMap.get(mainGroupId)!], text)` +3. Grok agent receives `receivedGroupInvitation` event → `evt.groupInfo.membership.memberId` matches → `grokChat.apiJoinGroup(evt.groupInfo.groupId)` → store bidirectional mapping (but do NOT resolve waiter yet) +4. Grok agent receives `connectedToGroupMember` event → `reverseGrokMap` lookup → resolve waiter (Grok is now fully connected and can send messages) +5. Send Grok response: `grokChat.apiSendTextMessage([T.ChatType.Group, grokGroupMap.get(mainGroupId)!], text)` + +**Important:** `apiJoinGroup` sends the join request, but Grok is not fully connected until the `connectedToGroupMember` event fires. Sending messages before this results in "not current member" errors. **Grok agent event subscriptions:** ```typescript @@ -157,9 +164,20 @@ grokChat.on("receivedGroupInvitation", async ({groupInfo}) => { const mainGroupId = pendingGrokJoins.get(memberId) if (mainGroupId !== undefined) { pendingGrokJoins.delete(memberId) + await grokChat.apiJoinGroup(groupInfo.groupId) + // Set maps but don't resolve waiter — wait for connectedToGroupMember grokGroupMap.set(mainGroupId, groupInfo.groupId) reverseGrokMap.set(groupInfo.groupId, mainGroupId) - await grokChat.apiJoinGroup(groupInfo.groupId) + } +}) + +grokChat.on("connectedToGroupMember", ({groupInfo}) => { + const mainGroupId = reverseGrokMap.get(groupInfo.groupId) + if (mainGroupId === undefined) return + const resolver = grokJoinResolvers.get(mainGroupId) + if (resolver) { + grokJoinResolvers.delete(mainGroupId) + resolver() } }) ``` @@ -193,6 +211,7 @@ const [mainChat, mainUser, mainAddress] = await bot.run({ deletedMemberUser: (evt) => supportBot?.onDeletedMemberUser(evt), groupDeleted: (evt) => supportBot?.onGroupDeleted(evt), connectedToGroupMember: (evt) => supportBot?.onMemberConnected(evt), + newMemberContactReceivedInv: (evt) => supportBot?.onMemberContactReceivedInv(evt), }, }) ``` @@ -203,20 +222,20 @@ const grokChat = await ChatApi.init(config.grokDbPrefix) let grokUser = await grokChat.apiGetActiveUser() if (!grokUser) grokUser = await grokChat.apiCreateActiveUser({displayName: "Grok AI", fullName: ""}) await grokChat.startChat() -// Subscribe Grok event handlers (receivedGroupInvitation) +// Subscribe Grok event handlers +grokChat.on("receivedGroupInvitation", async (evt) => supportBot?.onGrokGroupInvitation(evt)) +grokChat.on("connectedToGroupMember", (evt) => supportBot?.onGrokMemberConnected(evt)) ``` -**First-run mode** (`--first-run`): -1. Both instances init and create users -2. Main bot: `mainChat.apiCreateLink(mainUser.userId)` → invitation link -3. Grok agent: `grokChat.apiConnectActiveUser(invLink)` -4. Main bot: `mainChat.wait("contactConnected", 60000)` — wait for connection -5. Print: "Grok contact established. ContactId=X. Use: --grok-contact X:GrokAI" -6. Exit (user restarts without `--first-run`) - -**Startup validation** (after init, before event loop): -1. `mainChat.apiListContacts(mainUser.userId)` → validate `--team-members` and `--grok-contact` ID:name pairs -2. `mainChat.apiListGroups(mainUser.userId)` → validate `--team-group` ID:name pair +**Startup resolution** (after init, before event loop): +1. Read `{dbPrefix}_state.json` for persisted `grokContactId` and `teamGroupId` +2. Enable auto-accept DM contacts from group members: `sendChatCmd("/_set accept member contacts ${mainUser.userId} on")` +3. `mainChat.apiListContacts(mainUser.userId)` → log contacts list, resolve Grok contact (from state or auto-establish via `apiCreateLink` + `apiConnectActiveUser` + `wait("contactConnected", 60000)`) +4. `sendChatCmd("/_groups${mainUser.userId}")` → resolve team group (from state or auto-create via `apiNewGroup` + persist) +5. Ensure direct messages enabled on team group: `apiUpdateGroupProfile(teamGroupId, {groupPreferences: {directMessages: {enable: On}}})` for existing groups; included in `apiNewGroup` for new groups +6. Delete stale invite link (best-effort), then `apiCreateGroupLink(teamGroupId, Member)` → display, schedule 10min deletion +7. If `--team-members` provided: validate each contact ID/name pair via contacts list, fail-fast on mismatch +8. On SIGINT/SIGTERM → delete invite link with `apiDeleteGroupLink`, then exit ## 8. Event Processing @@ -230,6 +249,14 @@ await grokChat.startChat() | `deletedMemberUser` | `onDeletedMemberUser` | Bot removed from group → delete state | | `groupDeleted` | `onGroupDeleted` | Delete state, delete grokGroupMap entry | | `connectedToGroupMember` | `onMemberConnected` | Log for debugging | +| `newMemberContactReceivedInv` | `onMemberContactReceivedInv` | Log DM contact from team group member (auto-accepted via `/_set accept member contacts`) | + +**Grok agent event handlers:** + +| Event | Handler | Action | +|-------|---------|--------| +| `receivedGroupInvitation` | `onGrokGroupInvitation` | Match `memberId` → `apiJoinGroup` → set bidirectional maps (waiter NOT resolved yet) | +| `connectedToGroupMember` | `onGrokMemberConnected` | Resolve `grokJoinResolvers` waiter — Grok is now fully connected and can send messages | We do NOT use `onMessage`/`onCommands` from `bot.run()` — all routing is done in the `newChatItems` event handler for full control over state-dependent command handling. @@ -241,8 +268,12 @@ for (const ci of evt.chatItems) { const groupInfo = chatInfo.groupInfo if (!groupInfo.businessChat) continue // only process business chats const groupId = groupInfo.groupId - const state = conversations.get(groupId) - if (!state) continue + let state = conversations.get(groupId) + if (!state) { + // After restart, re-initialize state for existing business chats + state = {type: "teamQueue", userMessages: []} + conversations.set(groupId, state) + } if (chatItem.chatDir.type === "groupSnd") continue // our own message if (chatItem.chatDir.type !== "groupRcv") continue @@ -310,12 +341,16 @@ async activateTeam(groupId: number, state: ConversationState): Promise { // Remove Grok immediately if present (per spec: "When switching to team mode, Grok is removed") if (state.type === "grokMode") { try { await this.mainChat.apiRemoveMembers(groupId, [state.grokMemberGId]) } catch {} - const grokLocalGId = grokGroupMap.get(groupId) - grokGroupMap.delete(groupId) - if (grokLocalGId) reverseGrokMap.delete(grokLocalGId) + this.cleanupGrokMaps(groupId) } - const teamContactId = this.config.teamMembers[0].id // round-robin or first available - const member = await this.mainChat.apiAddMember(groupId, teamContactId, "member") + if (this.config.teamMembers.length === 0) { + // No team members configured — revert to teamQueue if was grokMode + if (state.type === "grokMode") 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 + } + const teamContactId = this.config.teamMembers[0].id + const member = await this.addOrFindTeamMember(groupId, teamContactId) // handles groupDuplicateMember this.conversations.set(groupId, { type: "teamPending", teamMemberGId: member.groupMemberId, @@ -325,6 +360,19 @@ async activateTeam(groupId: number, state: ConversationState): Promise { teamAddedMessage(this.config.timezone) ) } + +// Helper: handles groupDuplicateMember error (team member already in group from previous session) +private async addOrFindTeamMember(groupId: number, teamContactId: number): Promise { + try { + return await this.mainChat.apiAddMember(groupId, teamContactId, "member") + } catch (err: any) { + if (err?.chatError?.errorType?.type === "groupDuplicateMember") { + const members = await this.mainChat.apiListMembers(groupId) + return members.find(m => m.memberContactId === teamContactId) ?? null + } + throw err + } +} ``` ## 11. Grok API Integration @@ -358,11 +406,13 @@ class GrokApiClient { **Activating Grok** (on `/grok` in teamQueue): 1. `mainChat.apiAddMember(groupId, grokContactId, "member")` → stores `pendingGrokJoins.set(member.memberId, groupId)` 2. Send bot activation message: `mainChat.apiSendTextMessage([Group, groupId], grokActivatedMsg)` -3. Wait for Grok join: poll `grokGroupMap.has(groupId)` with 30s timeout (or use `mainChat.wait("connectedToGroupMember", pred, 30000)`) -4. Build initial Grok history from `state.userMessages` -5. Call Grok API with accumulated messages -6. Send response via Grok identity: `grokChat.apiSendTextMessage([Group, grokGroupMap.get(groupId)!], response)` -7. Transition to `grokMode` with history +3. Wait for Grok join via `waitForGrokJoin(groupId, 30000)` — Promise-based waiter resolved by `onGrokMemberConnected` (fires on `grokChat.connectedToGroupMember`), times out after 30s +4. Re-check state (user may have sent `/team` concurrently — abort if state changed) +5. Build initial Grok history from `state.userMessages` +6. Call Grok API with accumulated messages +7. Re-check state again after API call (another event may have changed it) +8. Send response via Grok identity: `grokChat.apiSendTextMessage([Group, grokGroupMap.get(groupId)!], response)` +9. Transition to `grokMode` with history **Fallback:** If Grok API fails → send error message via `mainChat.apiSendTextMessage`, keep accumulated messages, stay in `teamQueue`. @@ -426,11 +476,16 @@ function isWeekend(timezone: string): boolean { | 2 | Init Grok agent | Startup | grokChat | `ChatApi.init(grokDbPrefix)` | dbFilePrefix | `ChatApi` | Exit on failure | | 3 | Get/create Grok user | Startup | grokChat | `apiGetActiveUser()` / `apiCreateActiveUser(profile)` | profile: {displayName: "Grok AI"} | `User` | Exit on failure | | 4 | Start Grok chat | Startup | grokChat | `startChat()` | — | void | Exit on failure | -| 5 | Validate team group | Startup | mainChat | `apiListGroups(userId)` | userId | `GroupInfo[]` | Exit if ID:name mismatch | -| 6 | Validate contacts | Startup | mainChat | `apiListContacts(userId)` | userId | `Contact[]` | Exit if ID:name mismatch | -| 7 | First-run: create link | First-run | mainChat | `apiCreateLink(userId)` | userId | `string` (invitation link) | Exit on failure | -| 8 | First-run: connect | First-run | grokChat | `apiConnectActiveUser(invLink)` | connLink | `ConnReqType` | Exit on failure | -| 9 | First-run: wait | First-run | mainChat | `wait("contactConnected", 60000)` | event, timeout | `ChatEvent \| undefined` | Exit on timeout | +| 5 | Resolve team group | Startup | mainChat | Read `{dbPrefix}_state.json` → `sendChatCmd("/_groups${userId}")` find by persisted ID, or `apiNewGroup(userId, {groupPreferences: {directMessages: {enable: On}}})` + persist | userId, groupProfile | `GroupInfo[]` / `GroupInfo` | Exit on failure | +| 5a | Ensure DM on team group | Startup (existing group) | mainChat | `apiUpdateGroupProfile(teamGroupId, {groupPreferences: {directMessages: {enable: On}}})` | groupId, groupProfile | `GroupInfo` | Exit on failure | +| 5b | Create team group invite link | Startup | mainChat | `apiDeleteGroupLink(groupId)` (best-effort) then `apiCreateGroupLink(groupId, Member)` | groupId, memberRole | `string` (invite link) | Exit on failure | +| 5c | Delete team group invite link | 10min timer or shutdown | mainChat | `apiDeleteGroupLink(groupId)` | groupId | `void` | Log error (best-effort) | +| 6 | Enable auto-accept DM contacts | Startup | mainChat | `sendChatCmd("/_set accept member contacts ${userId} on")` | userId | — | Log warning | +| 6a | List contacts | Startup | mainChat | `apiListContacts(userId)` | userId | `Contact[]` | Exit on failure | +| 6b | Validate team members | Startup (if `--team-members` provided) | mainChat | Match contacts by ID/name | contact list | — | Exit if ID:name mismatch | +| 7 | Auto-establish Grok contact | Startup (if not in state file) | mainChat | `apiCreateLink(userId)` | userId | `string` (invitation link) | Exit on failure | +| 8 | Auto-establish Grok contact | Startup (if not in state file) | grokChat | `apiConnectActiveUser(invLink)` | connLink | `ConnReqType` | Exit on failure | +| 9 | Auto-establish Grok contact | Startup (if not in state file) | mainChat | `wait("contactConnected", 60000)` | event, timeout | `ChatEvent \| undefined` | Exit on timeout | | 10 | Send msg to customer | Various | mainChat | `apiSendTextMessage([Group, groupId], text)` | chat, text | `AChatItem[]` | Log error | | 11 | Forward to team | welcome→teamQueue, teamQueue msg, grokMode msg | mainChat | `apiSendTextMessage([Group, teamGroupId], fwd)` | chat, formatted text | `AChatItem[]` | Log error | | 12 | Invite Grok to group | /grok in teamQueue | mainChat | `apiAddMember(groupId, grokContactId, "member")` | groupId, contactId, role | `GroupMember` | Send error msg, stay in teamQueue | @@ -440,6 +495,7 @@ function isWeekend(timezone: string): boolean { | 16 | Remove Grok | /team from grokMode | mainChat | `apiRemoveMembers(groupId, [grokMemberGId])` | groupId, memberIds | `GroupMember[]` | Ignore (may have left) | | 17 | Update bot profile | Startup (via bot.run) | mainChat | `apiUpdateProfile(userId, profile)` | userId, profile with peerType+commands | `UserProfileUpdateSummary` | Log warning | | 18 | Set address settings | Startup (via bot.run) | mainChat | `apiSetAddressSettings(userId, settings)` | userId, {businessAddress, autoAccept, welcomeMessage} | void | Exit on failure | +| 19 | List group members | `groupDuplicateMember` fallback | mainChat | `apiListMembers(groupId)` | groupId | `GroupMember[]` | Log error | ## 15. Error Handling @@ -456,18 +512,22 @@ function isWeekend(timezone: string): boolean { | Grok leaves during `grokMode` | Revert to `teamQueue`, delete grokGroupMap entry | | Team member leaves | Revert to `teamQueue` (accumulate messages again) | | Bot removed from group (`deletedMemberUser`) | Delete conversation state | +| Grok contact unavailable (`grokContactId === null`) | `/grok` returns "Grok is temporarily unavailable" message, stay in current state | +| No team members configured (`teamMembers.length === 0`) | `/team` returns "No team members are available yet" message; if was grokMode, revert to teamQueue | | Grok agent connection lost | Log error; Grok features unavailable until restart | | `apiSendTextMessage` fails | Log error, continue (message lost but bot stays alive) | -| Config validation fails | Print descriptive error with actual vs expected name, exit | +| Team member config validation fails | Print descriptive error with actual vs expected name, exit | +| `groupDuplicateMember` on `apiAddMember` | Catch error, call `apiListMembers` to find existing member by `memberContactId`, use existing `groupMemberId` | +| Restart: unknown business chat group | Re-initialize conversation state as `teamQueue` (no welcome reply), forward messages to team | ## 16. Implementation Sequence **Phase 1: Scaffold** - Create project: `package.json`, `tsconfig.json` -- Implement `config.ts`: CLI arg parsing, ID:name format, `Config` type -- Implement `index.ts`: init both ChatApi instances, verify profiles +- Implement `config.ts`: CLI arg parsing, ID:name format (team members), `Config` type +- Implement `index.ts`: init both ChatApi instances, auto-resolve Grok contact and team group from state file, verify profiles - Implement `util.ts`: `isWeekend`, logging -- **Verify:** Both instances init, print user profiles, validate config +- **Verify:** Both instances init, print user profiles, Grok contact established, team group created **Phase 2: State machine + event loop** - Implement `state.ts`: `ConversationState` union type @@ -481,19 +541,20 @@ function isWeekend(timezone: string): boolean { **Phase 3: Grok integration** - Implement `grok.ts`: `GrokApiClient` with system prompt + docs injection - Implement Grok agent event handler (`receivedGroupInvitation` → auto-join) -- Implement `activateGrok`: add member, ID mapping, wait for join, Grok API call, send response via grokChat +- Implement `activateGrok`: null guard for `grokContactId`, add member, ID mapping, wait for join, Grok API call, send response via grokChat - Implement `forwardToGrok`: ongoing message routing in grokMode - **Verify:** `/grok` → Grok joins as separate participant → Grok responses appear from Grok profile **Phase 4: Team mode + one-way gate** -- Implement `activateTeam`: remove Grok if present, add team member +- Implement `activateTeam`: empty teamMembers guard, remove Grok if present, add team member - Implement `onTeamMemberMessage`: detect team msg → lock state - Implement `/grok` rejection in `teamPending` and `teamLocked` - **Verify:** Full flow: teamQueue → /grok → grokMode → /team → Grok removed + teamPending → /grok rejected → team msg → teamLocked -**Phase 5: Polish + first-run** -- Implement `--first-run` auto-contact establishment +**Phase 5: Polish + edge cases** - Handle edge cases: customer leave, group delete, Grok timeout, member leave +- Team group invite link lifecycle: create on startup, delete after 10min or on shutdown +- Graceful shutdown (SIGINT/SIGTERM) - Write `docs/simplex-context.md` for Grok prompt injection - End-to-end test all flows @@ -515,24 +576,32 @@ Any edit restarts the review cycle. Batch changes within a round. ## 18. Verification -**First-run setup:** +**Startup** (all auto-resolution happens automatically): ```bash cd apps/simplex-chat-support-bot npm install -npx ts-node src/index.ts --first-run --db-prefix ./data/bot --grok-db-prefix ./data/grok -# → Prints: "Grok contact established. ContactId=X. Use: --grok-contact X:GrokAI" -``` - -**Normal run:** -```bash -npx ts-node src/index.ts \ - --team-group 1:SupportTeam \ - --team-members 2:Alice,3:Bob \ - --grok-contact 4:GrokAI \ +GROK_API_KEY=xai-... npx ts-node src/index.ts \ + --team-group SupportTeam \ --timezone America/New_York \ --group-links "https://simplex.chat/contact#..." ``` +On first startup, the bot auto-establishes the Grok contact and creates the team group, persisting both IDs to `{dbPrefix}_state.json`. It prints: +``` +Team group invite link (expires in 10 min): +https://simplex.chat/contact#... +``` + +Team members scan/click the link to join the team group. After 10 minutes, the link is deleted. On subsequent startups, the existing Grok contact and team group are resolved by persisted ID (not by name — safe even with duplicate group names) and a fresh team group invite link is created. + +**With optional team members** (for pre-validated contacts): +```bash +GROK_API_KEY=xai-... npx ts-node src/index.ts \ + --team-group SupportTeam \ + --team-members 2:Alice,3:Bob \ + --timezone America/New_York +``` + **Test scenarios:** 1. Connect from SimpleX client to bot's business address → verify welcome message 2. Send question → verify forwarded to team group with `[CustomerName #groupId]` prefix, queue reply received @@ -544,6 +613,22 @@ npx ts-node src/index.ts \ 8. Test weekend: set timezone to weekend timezone → verify "48 hours" in messages 9. Customer disconnects → verify state cleanup 10. Grok API failure → verify error message, graceful fallback to teamQueue +11. Team group auto-creation: start with a new group name → verify group created, ID persisted to state file, team group invite link displayed +12. Team group invite link deletion: wait 10 minutes → verify link deleted; kill bot → verify link deleted on shutdown +13. Team group persistence: restart bot → verify same group ID used from state file (not a new group) +14. Team group recovery: delete persisted group externally → restart bot → verify new group created and state file updated +15. Grok contact auto-establish: first startup with empty state file → verify Grok contact created and persisted +16. Grok contact persistence: restart bot → verify same Grok contact ID used from state file +17. Grok contact recovery: delete persisted contact externally → restart bot → verify new contact established and state file updated +18. No team members: start without `--team-members` → send `/team` → verify "No team members are available yet" message +19. Null grokContactId: if Grok contact unavailable → send `/grok` → verify "Grok is temporarily unavailable" message +20. Restart recovery: customer message in unknown group → re-init to teamQueue, forward to team (no queue reply) +21. Restart recovery: after re-init, `/grok` works in re-initialized group +22. Grok join waiter: `onGrokGroupInvitation` alone does NOT resolve waiter — `onGrokMemberConnected` required +23. groupDuplicateMember: `/team` when team member already in group → `apiListMembers` lookup, transition to teamPending +24. groupDuplicateMember: member not found in list → error message, stay in current state +25. DM contact received: `newMemberContactReceivedInv` from team group → logged, no crash +26. Direct messages enabled on team group (via `groupPreferences`) for both new and existing groups ### Critical Reference Files diff --git a/apps/simplex-support-bot/src/bot.ts b/apps/simplex-support-bot/src/bot.ts index 9843907a7a..bf510893f7 100644 --- a/apps/simplex-support-bot/src/bot.ts +++ b/apps/simplex-support-bot/src/bot.ts @@ -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 { @@ -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 { - 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 { + 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 { + 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 { try { await this.mainChat.apiSendTextMessage([T.ChatType.Group, groupId], text) diff --git a/apps/simplex-support-bot/src/config.ts b/apps/simplex-support-bot/src/config.ts index 4036886eac..00ea094f03 100644 --- a/apps/simplex-support-bot/src/config.ts +++ b/apps/simplex-support-bot/src/config.ts @@ -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, } } diff --git a/apps/simplex-support-bot/src/index.ts b/apps/simplex-support-bot/src/index.ts index 8f35bebb9e..ac437b6895 100644 --- a/apps/simplex-support-bot/src/index.ts +++ b/apps/simplex-support-bot/src/index.ts @@ -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 { const config = parseConfig(process.argv.slice(2)) log("Config parsed", { @@ -14,11 +29,12 @@ async function main(): Promise { 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 { 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 { 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 { }) 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 { + 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 { 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 { + 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 => {