diff --git a/apps/simplex-support-bot/bot.test.ts b/apps/simplex-support-bot/bot.test.ts index 223d0f79ca..418d1965b9 100644 --- a/apps/simplex-support-bot/bot.test.ts +++ b/apps/simplex-support-bot/bot.test.ts @@ -1,6 +1,8 @@ import {describe, test, expect, beforeEach, vi} from "vitest" import {SupportBot} from "./src/bot.js" import {CardManager} from "./src/cards.js" +import {parseConfig} from "./src/config.js" +import {GrokApiClient} from "./src/grok.js" import {welcomeMessage, queueMessage, grokActivatedMessage, teamLockedMessage} from "./src/messages.js" // Silence console output during tests @@ -147,12 +149,26 @@ class MockGrokApi { calls: {history: any[]; message: string}[] = [] private _response = "Grok answer" private _willFail = false + private _gate: {promise: Promise; release: () => void} | null = null willRespond(text: string) { this._response = text; this._willFail = false } willFail() { this._willFail = true } + // Block every subsequent chat() call until releaseChat() is invoked. Used to + // observe in-flight concurrency without relying on wall-clock timing. + blockChat() { + let release!: () => void + const promise = new Promise(r => { release = r }) + this._gate = {promise, release} + } + releaseChat() { + this._gate?.release() + this._gate = null + } + async chat(history: any[], userMessage: string): Promise { this.calls.push({history, message: userMessage}) + if (this._gate) await this._gate.promise if (this._willFail) { this._willFail = false; throw new Error("Grok API error") } return this._response } @@ -783,6 +799,36 @@ describe("Grok Conversation", () => { expect(grokApi.calls.length).toBe(1) expect(grokApi.calls[0].message).toBe("Customer question") }) + + test("batch: across groups → Grok calls overlap in-flight (parallel dispatch)", async () => { + const GROK_GROUP_A = 201 + const GROK_GROUP_B = 202 + chat.groups.set(GROK_GROUP_A, makeGroupInfo(GROK_GROUP_A)) + chat.groups.set(GROK_GROUP_B, makeGroupInfo(GROK_GROUP_B)) + addCustomerMessageToHistory("A", GROK_GROUP_A) + addCustomerMessageToHistory("B", GROK_GROUP_B) + + const ciA = makeChatItem({dir: "groupRcv", text: "A", memberId: CUSTOMER_ID}) + const ciB = makeChatItem({dir: "groupRcv", text: "B", memberId: CUSTOMER_ID}) + const evt = { + type: "newChatItems" as const, + user: makeUser(GROK_USER_ID), + chatItems: [ + {chatInfo: {type: "group", groupInfo: makeGroupInfo(GROK_GROUP_A)}, chatItem: ciA}, + {chatInfo: {type: "group", groupInfo: makeGroupInfo(GROK_GROUP_B)}, chatItem: ciB}, + ], + } + + // Block both chat() calls until we release. If the handler serialized + // per-group work, only one call would enter chat() before release. + grokApi.blockChat() + const done = bot.onGrokNewChatItems(evt) + // Let both tasks run up to the gate. + await new Promise(r => setTimeout(r, 10)) + expect(grokApi.calls.length).toBe(2) + grokApi.releaseChat() + await done + }) }) describe("/team Activation", () => { @@ -993,6 +1039,22 @@ describe("Card Dashboard", () => { expect(typeof data.cardItemId).toBe("number") }) + test("concurrent mergeCustomData on same group → both patches survive", async () => { + // Without per-group serialization, two overlapping mergeCustomData calls + // can both read the same snapshot and the second write clobbers the first + // patch. The mutex keeps them ordered so both patches land. + const GID = 500 + chat.groups.set(GID, makeGroupInfo(GID)) + + const pA = cards.mergeCustomData(GID, {state: "QUEUE"}) + const pB = cards.mergeCustomData(GID, {cardItemId: 123}) + await Promise.all([pA, pB]) + + const final = chat.customData.get(GID) + expect(final.state).toBe("QUEUE") + expect(final.cardItemId).toBe(123) + }) + test("customer leaves → customData cleared", async () => { await bot.onNewChatItems(customerMessage("Hello")) chat.customData.set(CUSTOMER_GROUP_ID, {cardItemId: 999}) @@ -1041,6 +1103,19 @@ describe("Card Debouncing", () => { expect(chat.deleted.length).toBe(0) expect(chat.sentTo(TEAM_GROUP_ID).length).toBe(0) }) + + test("flush on group with no cardItemId → createCard posts a new card (retries failed create)", async () => { + // customData without cardItemId simulates a prior createCard that failed + // mid-flight and re-queued itself. flushOne must dispatch to createCard, + // not updateCard (which would early-return). + const GID = 777 + chat.groups.set(GID, makeGroupInfo(GID)) + chat.customData.set(GID, {state: "QUEUE"}) + cards.scheduleUpdate(GID) + await cards.flush() + expect(chat.sentTo(TEAM_GROUP_ID).length).toBe(1) + expect(typeof chat.customData.get(GID).cardItemId).toBe("number") + }) }) describe("Card Format & State Derivation", () => { @@ -1114,6 +1189,12 @@ describe("/join Command (Team Group)", () => { await bot.onNewChatItems(customerMessage("/join 50:Test")) expectSentToGroup(CUSTOMER_GROUP_ID, "The team will reply to your message") }) + + test("/join with non-numeric params → error reply, no apiAddMember call", async () => { + await bot.onNewChatItems(teamGroupMessage("/join abc")) + expectSentToGroup(TEAM_GROUP_ID, `Error: invalid group id "abc"`) + expect(chat.added.length).toBe(0) + }) }) describe("DM Handshake", () => { @@ -1561,6 +1642,26 @@ describe("Grok Join Flow", () => { expect(grokApi.calls.length).toBe(callsAfterActivation + 1) expect(grokApi.calls[grokApi.calls.length - 1].message).toBe("Follow-up question") }) + + test("activateGrok groupDuplicateMember path → gate cleared by outer finally", async () => { + // After reachGrok(), gate is cleared and reverseGrokMap is populated. + await reachGrok() + await bot.flush() + const callsBaseline = grokApi.calls.length + + // Second /grok while Grok is already present → apiAddMember throws duplicate. + // The outer try/finally must clear the gate even though the handler returns + // silently from inside the try — otherwise per-message responses stay + // suppressed for this group forever. + chat.apiAddMemberWillFail({chatError: {errorType: {type: "groupDuplicateMember"}}}) + await bot.onNewChatItems(customerMessage("/grok")) + await bot.flush() + + // Gate must be clear: a subsequent per-message event triggers Grok. + addCustomerMessageToHistory("another question", GROK_LOCAL_GROUP_ID) + await bot.onGrokNewChatItems(grokViewCustomerMessage("another question")) + expect(grokApi.calls.length).toBe(callsBaseline + 1) + }) }) describe("Grok No-History Fallback", () => { @@ -2114,3 +2215,55 @@ describe("joinedGroupMember Event Filtering", () => { expect(chat.rawCmds.length).toBe(0) }) }) + +describe("parseConfig Validation", () => { + const baseArgs = ["--team-group", "Support"] + + test("--complete-hours non-numeric → throws", () => { + expect(() => parseConfig([...baseArgs, "--complete-hours", "abc"])) + .toThrow(/--complete-hours must be a non-negative integer, got "abc"/) + }) + + test("--complete-hours negative → throws", () => { + expect(() => parseConfig([...baseArgs, "--complete-hours", "-1"])) + .toThrow(/--complete-hours must be a non-negative integer, got "-1"/) + }) + + test("--card-flush-seconds non-numeric → throws", () => { + expect(() => parseConfig([...baseArgs, "--card-flush-seconds", "xyz"])) + .toThrow(/--card-flush-seconds must be a non-negative integer, got "xyz"/) + }) + + test("--timezone invalid IANA → throws", () => { + expect(() => parseConfig([...baseArgs, "--timezone", "Not/AZone"])) + .toThrow(/--timezone "Not\/AZone" is not a valid IANA time zone/) + }) + + test("--complete-hours 0 → allowed (disables auto-complete)", () => { + const cfg = parseConfig([...baseArgs, "--complete-hours", "0"]) + expect(cfg.completeHours).toBe(0) + }) + + test("valid IANA timezone → accepted", () => { + const cfg = parseConfig([...baseArgs, "--timezone", "America/New_York"]) + expect(cfg.timezone).toBe("America/New_York") + }) +}) + +describe("GrokApiClient HTTP timeout", () => { + test("chat() calls AbortSignal.timeout(60_000) and passes the signal to fetch", async () => { + const timeoutSpy = vi.spyOn(AbortSignal, "timeout") + const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response(JSON.stringify({choices: [{message: {content: "ok"}}]}), {status: 200}), + ) + + const client = new GrokApiClient("test-key", "system prompt") + await client.chat([], "hello") + + expect(timeoutSpy).toHaveBeenCalledWith(60_000) + expect((fetchSpy.mock.calls[0][1] as RequestInit).signal).toBeInstanceOf(AbortSignal) + + fetchSpy.mockRestore() + timeoutSpy.mockRestore() + }) +}) 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 6aca06010f..f3ad98495f 100644 --- a/apps/simplex-support-bot/plans/20260207-support-bot-implementation.md +++ b/apps/simplex-support-bot/plans/20260207-support-bot-implementation.md @@ -1216,7 +1216,7 @@ async function reachTeam(groupId?) // reachTeamPending → add team membe Called as: `const p = simulateGrokJoinSuccess(); await bot.onNewChatItems(...); await p;` -### 20.4 Test Catalog (130 tests, 28 suites) +### 20.4 Test Catalog (141 tests, 30 suites) #### 1. Welcome & First Message (4 tests) - first message → queue reply + card created with /join command @@ -1231,7 +1231,7 @@ Called as: `const p = simulateGrokJoinSuccess(); await bot.onNewChatItems(...); - /grok when grokContactId is null → grokUnavailableMessage - /grok as first message + Grok join fails → queue message sent as fallback -#### 3. Grok Conversation (10 tests) +#### 3. Grok Conversation (11 tests) - Grok per-message: reads history, calls API, sends response - customer non-text → no Grok API call - Grok API error → grokErrorMessage sent @@ -1241,6 +1241,7 @@ Called as: `const p = simulateGrokJoinSuccess(); await bot.onNewChatItems(...); - batch: multiple customer messages in one event → only last triggers Grok API call - batch: messages from different groups → each group gets one response - batch: non-customer messages filtered, only customer messages trigger response +- batch: across groups → Grok calls overlap in-flight (parallel `Promise.allSettled` dispatch, proven via gated `MockGrokApi.chat`) #### 4. /team Activation (4 tests) - /team from QUEUE → ALL team members added, teamAddedMessage sent @@ -1267,19 +1268,21 @@ Called as: `const p = simulateGrokJoinSuccess(); await bot.onNewChatItems(...); - /team after all members left (TEAM-PENDING, no msg sent) → re-adds members - /team after all members left (TEAM, msg was sent) → re-adds members -#### 7. Card Dashboard (6 tests) +#### 7. Card Dashboard (7 tests) - first message creates card with customer name + /join - card final line is `/'join '` (single-quoted, numeric id only, no `:name` suffix) - card update deletes old, posts new - apiDeleteChatItems failure → ignored, new card posted - customData stores cardItemId through flush cycle +- concurrent `mergeCustomData` on same group → both patches survive (per-group `customDataMutex` serializes read-modify-write; without the mutex the second write clobbers the first) - customer leaves → customData cleared -#### 8. Card Debouncing (4 tests) +#### 8. Card Debouncing (5 tests) - rapid schedules → single card update on flush - multiple groups pending → each reposted once - card create is immediate (not debounced) - flush with no pending → no-op +- flush on group with no `cardItemId` → `createCard` posts a new card (proves `flushOne` dispatches to create-path so a failed `createCard` retries) #### 9. Card Format & State Derivation (6 tests) - QUEUE state derived (no Grok/team) @@ -1289,8 +1292,9 @@ Called as: `const p = simulateGrokJoinSuccess(); await bot.onNewChatItems(...); - TEAM derived (team present + message sent) - message count excludes bot's own -#### 10. /join Command (5 tests) +#### 10. /join Command (6 tests) - /join (the only accepted form) → team member added; `params` from `ciBotCommand` is parsed via `Number.parseInt`, no regex +- /join : (historic suffix) → still parses because `Number.parseInt(":", 10)` stops at the colon — handler does not strip the suffix deliberately; the suffix is never emitted by the card - /join with non-numeric `params` (e.g. `/join abc`) → error reply in team group, no `apiAddMember` call - /join non-business group → error - /join non-existent groupId → error @@ -1338,12 +1342,13 @@ Called as: `const p = simulateGrokJoinSuccess(); await bot.onNewChatItems(...); - own messages (groupSnd) → ignored - non-business group messages → ignored -#### 19. Grok Join Flow (5 tests) +#### 19. Grok Join Flow (6 tests) - receivedGroupInvitation → apiJoinGroup called (full async flow) - unmatched Grok invitation → buffered (not joined until activateGrok drains) - buffered invitation drained after pendingGrokJoins set → apiJoinGroup called - per-message responses suppressed during activateGrok initial response (grokInitialResponsePending gate) - per-message responses resume after activateGrok completes +- activateGrok `groupDuplicateMember` path → gate cleared by outer `finally` (subsequent per-message event still triggers Grok; proves the outer `try/finally` covers every exit path from the entry-time `gate.add`, not just the initial-response section) #### 20. Grok No-History Fallback (1 test) - Grok joins but sees no customer messages → grokNoHistoryMessage @@ -1400,6 +1405,17 @@ Called as: `const p = simulateGrokJoinSuccess(); await bot.onNewChatItems(...); - joinedGroupMember in non-team group → ignored - joinedGroupMember from wrong user → ignored +#### 28. parseConfig Validation (6 tests) +- `--complete-hours` non-numeric → throws with message including the flag name and raw value +- `--complete-hours` negative → throws +- `--card-flush-seconds` non-numeric → throws +- `--timezone` invalid IANA → throws (probe `Intl.DateTimeFormat` at parse time) +- `--complete-hours 0` → accepted (disables auto-complete) +- valid IANA timezone → accepted + +#### 29. GrokApiClient HTTP timeout (1 test) +- `chat()` calls `AbortSignal.timeout(60_000)` and passes the signal to `fetch` (spies on `AbortSignal.timeout` and on `globalThis.fetch`; proves the timeout is wired through without waiting 60s of wall-clock) + ### 20.5 Conventions - **File:** `bot.test.ts` (co-located with source, imports from `./src/*.js`) @@ -1411,15 +1427,15 @@ Called as: `const p = simulateGrokJoinSuccess(); await bot.onNewChatItems(...); - **Each test is self-contained** — `beforeEach(() => setup())` creates fresh mocks - **State helpers compose** — `reachTeam()` calls `reachTeamPending()` which calls `reachQueue()` - **Grok join simulation** — `simulateGrokJoinSuccess()` uses 10ms setTimeout to fire events during `waitForGrokJoin` await. Tests call `await bot.flush()` after simulation to await fire-and-forget `activateGrok` completion. -- **No fake timers** — real timers everywhere; flush called explicitly via `cards.flush()` and `bot.flush()` +- **No fake timers** — real timers everywhere; flush called explicitly via `cards.flush()` and `bot.flush()`. Suite 29 spies on `AbortSignal.timeout` rather than advancing a fake clock so it does not need fake timers either. ### 20.6 Test Coverage Notes **Covered vs plan catalog:** -- §20.4 suites 1-13, 15, 17-27 plus 5b fully covered (130 tests across 28 suites) +- §20.4 suites 1-13, 15, 17-29 plus 5b fully covered (141 tests across 30 suites) - Weekend detection (`util.isWeekend`) — not unit-tested; depends on `Intl.DateTimeFormat(new Date())`, would need clock mocking. Not present in the §20.4 catalog. - Profile Mutex serialization — not a standalone suite in §20.4; verified implicitly through all other tests (MockChatApi tracks activeUserId). -- Startup & state persistence (`index.ts` path) — not unit-tested; requires native ChatApi. Integration-test only. Includes `deleteInviteLink` (profileMutex + `apiSetActiveUser` before `apiDeleteGroupLink`), the conditional `apiUpdateGroupProfile` (compare `fullGroupPreferences` before calling), and the best-effort `apiCreateGroupLink` (catch + log on SMP relay failure). Not in §20.4 catalog. +- Startup & state persistence (`index.ts` path) — not unit-tested; requires native ChatApi. Integration-test only. Includes `deleteInviteLink` (profileMutex + `apiSetActiveUser` before `apiDeleteGroupLink`), the conditional `apiUpdateGroupProfile` (compare `fullGroupPreferences` before calling), the best-effort `apiCreateGroupLink` (catch + log on SMP relay failure), the predicate-filtered `chat.wait("contactConnected", ...)` used to identify the Grok contact (§4), and the team-group `/join` command registration with `params: "groupId"` (§11). Not in §20.4 catalog. **Known plan items NOT implemented (conscious gaps, not test gaps):** - Per-group Grok API call serialization (plan §10) — not implemented or tested diff --git a/apps/simplex-support-bot/src/bot.ts b/apps/simplex-support-bot/src/bot.ts index 2493fa40d8..205a3f144b 100644 --- a/apps/simplex-support-bot/src/bot.ts +++ b/apps/simplex-support-bot/src/bot.ts @@ -315,13 +315,10 @@ export class SupportBot { if (chatItem.chatDir.groupMember.memberId !== bc.customerId) continue lastPerGroup.set(chatInfo.groupInfo.groupId, ci) } - for (const [, ci] of lastPerGroup) { - try { - await this.processGrokChatItem(ci) - } catch (err) { - logError("Error processing Grok chat item", err) - } - } + // Groups are independent — avoid serializing one group's xAI latency across the others. + await Promise.allSettled( + [...lastPerGroup.values()].map((ci) => this.processGrokChatItem(ci)), + ) } // --- Main profile message routing --- @@ -576,98 +573,101 @@ export class SupportBot { return } - await this.sendToGroup(groupId, grokInvitingMessage) - - let member: T.GroupMember - try { - member = await this.withMainProfile(() => - this.chat.apiAddMember(groupId, this.config.grokContactId!, T.GroupMemberRole.Member) - ) - } catch (err: unknown) { - const chatErr = err as {chatError?: {errorType?: {type?: string}}} - if (chatErr?.chatError?.errorType?.type === "groupDuplicateMember") { - // Grok already in group (e.g. customer sent /grok again before join completed) — - // the in-flight activation will handle the outcome, just return silently - return - } - logError(`Failed to invite Grok to group ${groupId}`, err) - await revertStateOnFail() - await this.sendToGroup(groupId, grokUnavailableMessage) - if (opts.sendQueueOnFail) await this.sendToGroup(groupId, queueMessage(this.config.timezone, this.grokEnabled)) - this.cards.scheduleUpdate(groupId) - return - } - - this.pendingGrokJoins.set(member.memberId, groupId) - - // Drain buffered invitation that arrived during the apiAddMember await - const buffered = this.bufferedGrokInvitations.get(member.memberId) - if (buffered) { - this.bufferedGrokInvitations.delete(member.memberId) - this.pendingGrokJoins.delete(member.memberId) - await this.processGrokInvitation(buffered, groupId) - } - + // Gate MUST be up before apiAddMember / pendingGrokJoins / reverseGrokMap — + // any later and onGrokNewChatItems can fire a duplicate per-message reply. this.grokInitialResponsePending.add(groupId) - const joined = await this.waitForGrokJoin(groupId, 120_000) - if (!joined) { - this.grokInitialResponsePending.delete(groupId) - this.pendingGrokJoins.delete(member.memberId) - try { - await this.withMainProfile(() => - this.chat.apiRemoveMembers(groupId, [member.groupMemberId]) - ) - } catch {} - this.cleanupGrokMaps(groupId) - await revertStateOnFail() - await this.sendToGroup(groupId, grokUnavailableMessage) - if (opts.sendQueueOnFail) await this.sendToGroup(groupId, queueMessage(this.config.timezone, this.grokEnabled)) - this.cards.scheduleUpdate(groupId) - return - } - - await this.sendToGroup(groupId, grokActivatedMessage) - - // Grok joined — send initial response based on customer's accumulated messages try { - const grokLocalGId = this.grokGroupMap.get(groupId) - if (grokLocalGId === undefined) { - await this.sendToGroup(groupId, grokUnavailableMessage) - return - } + await this.sendToGroup(groupId, grokInvitingMessage) - // Read history from Grok's own view — only customer messages. - // The previous `grokBc && ...` short-circuit let bot and team - // messages through when Grok's view had no businessChat; require - // grokBc.customerId to be present and match strictly. - const chat = await this.withGrokProfile(() => - this.chat.apiGetChat(T.ChatType.Group, grokLocalGId, 100) - ) - const grokBc = chat.chatInfo.type === "group" ? chat.chatInfo.groupInfo.businessChat : null - const customerMessages: string[] = [] - for (const ci of chat.chatItems) { - if (ci.chatDir.type !== "groupRcv") continue - if (!grokBc || ci.chatDir.groupMember.memberId !== grokBc.customerId) continue - const t = util.ciContentText(ci)?.trim() - if (t && !util.ciBotCommand(ci)) customerMessages.push(t) - } - - if (customerMessages.length === 0) { - await this.withGrokProfile(() => - this.chat.apiSendTextMessage([T.ChatType.Group, grokLocalGId], grokNoHistoryMessage) + let member: T.GroupMember + try { + member = await this.withMainProfile(() => + this.chat.apiAddMember(groupId, this.config.grokContactId!, T.GroupMemberRole.Member) ) + } catch (err: unknown) { + const chatErr = err as {chatError?: {errorType?: {type?: string}}} + if (chatErr?.chatError?.errorType?.type === "groupDuplicateMember") { + // Grok already in group (e.g. customer sent /grok again before join completed) — + // the in-flight activation will handle the outcome, just return silently + return + } + logError(`Failed to invite Grok to group ${groupId}`, err) + await revertStateOnFail() + await this.sendToGroup(groupId, grokUnavailableMessage) + if (opts.sendQueueOnFail) await this.sendToGroup(groupId, queueMessage(this.config.timezone, this.grokEnabled)) + this.cards.scheduleUpdate(groupId) return } - const initialMsg = customerMessages.join("\n") - const response = await grokApi.chat([], initialMsg) + this.pendingGrokJoins.set(member.memberId, groupId) - await this.withGrokProfile(() => - this.chat.apiSendTextMessage([T.ChatType.Group, grokLocalGId], response) - ) - } catch (err) { - logError(`Grok initial response failed for group ${groupId}`, err) - await this.sendToGroup(groupId, grokUnavailableMessage) + // Drain buffered invitation that arrived during the apiAddMember await + const buffered = this.bufferedGrokInvitations.get(member.memberId) + if (buffered) { + this.bufferedGrokInvitations.delete(member.memberId) + this.pendingGrokJoins.delete(member.memberId) + await this.processGrokInvitation(buffered, groupId) + } + + const joined = await this.waitForGrokJoin(groupId, 120_000) + if (!joined) { + this.pendingGrokJoins.delete(member.memberId) + try { + await this.withMainProfile(() => + this.chat.apiRemoveMembers(groupId, [member.groupMemberId]) + ) + } catch {} + this.cleanupGrokMaps(groupId) + await revertStateOnFail() + await this.sendToGroup(groupId, grokUnavailableMessage) + if (opts.sendQueueOnFail) await this.sendToGroup(groupId, queueMessage(this.config.timezone, this.grokEnabled)) + this.cards.scheduleUpdate(groupId) + return + } + + await this.sendToGroup(groupId, grokActivatedMessage) + + // Grok joined — send initial response based on customer's accumulated messages + try { + const grokLocalGId = this.grokGroupMap.get(groupId) + if (grokLocalGId === undefined) { + await this.sendToGroup(groupId, grokUnavailableMessage) + return + } + + // Read history from Grok's own view — only customer messages. + // The previous `grokBc && ...` short-circuit let bot and team + // messages through when Grok's view had no businessChat; require + // grokBc.customerId to be present and match strictly. + const chat = await this.withGrokProfile(() => + this.chat.apiGetChat(T.ChatType.Group, grokLocalGId, 100) + ) + const grokBc = chat.chatInfo.type === "group" ? chat.chatInfo.groupInfo.businessChat : null + const customerMessages: string[] = [] + for (const ci of chat.chatItems) { + if (ci.chatDir.type !== "groupRcv") continue + if (!grokBc || ci.chatDir.groupMember.memberId !== grokBc.customerId) continue + const t = util.ciContentText(ci)?.trim() + if (t && !util.ciBotCommand(ci)) customerMessages.push(t) + } + + if (customerMessages.length === 0) { + await this.withGrokProfile(() => + this.chat.apiSendTextMessage([T.ChatType.Group, grokLocalGId], grokNoHistoryMessage) + ) + return + } + + const initialMsg = customerMessages.join("\n") + const response = await grokApi.chat([], initialMsg) + + await this.withGrokProfile(() => + this.chat.apiSendTextMessage([T.ChatType.Group, grokLocalGId], response) + ) + } catch (err) { + logError(`Grok initial response failed for group ${groupId}`, err) + await this.sendToGroup(groupId, grokUnavailableMessage) + } } finally { this.grokInitialResponsePending.delete(groupId) } @@ -721,16 +721,18 @@ export class SupportBot { private async processTeamGroupMessage(chatItem: T.ChatItem): Promise { if (chatItem.chatDir.type !== "groupRcv") return - const text = util.ciContentText(chatItem)?.trim() - if (!text) return const senderContactId = chatItem.chatDir.groupMember.memberContactId if (!senderContactId) return - const joinMatch = text.match(/^\/join\s+(\d+)(?::.*)?\s*$/) - if (joinMatch) { - await this.handleJoinCommand(parseInt(joinMatch[1], 10), senderContactId) + const cmd = util.ciBotCommand(chatItem) + if (cmd?.keyword !== "join") return + + const targetGroupId = Number.parseInt(cmd.params, 10) + if (Number.isNaN(targetGroupId) || targetGroupId <= 0) { + await this.sendToGroup(this.config.teamGroup.id, `Error: invalid group id "${cmd.params}"`) return } + await this.handleJoinCommand(targetGroupId, senderContactId) } private async handleJoinCommand(targetGroupId: number, senderContactId: number): Promise { diff --git a/apps/simplex-support-bot/src/cards.ts b/apps/simplex-support-bot/src/cards.ts index a979c61668..3d27c036e9 100644 --- a/apps/simplex-support-bot/src/cards.ts +++ b/apps/simplex-support-bot/src/cards.ts @@ -1,5 +1,6 @@ import {T} from "@simplex-chat/types" import {api, util} from "simplex-chat" +import {Mutex} from "async-mutex" import {Config} from "./config.js" import {profileMutex, log, logError} from "./util.js" @@ -58,6 +59,8 @@ function contentTypeLabel(ci: T.ChatItem): string | null { export class CardManager { private pendingUpdates = new Set() private flushInterval: NodeJS.Timeout + // Outer lock; profileMutex (via withMainProfile) is the inner lock. + private customDataMutexes = new Map() constructor( private chat: api.ChatApi, @@ -76,6 +79,15 @@ export class CardManager { }) } + private getCustomDataMutex(groupId: number): Mutex { + let m = this.customDataMutexes.get(groupId) + if (!m) { + m = new Mutex() + this.customDataMutexes.set(groupId, m) + } + return m + } + scheduleUpdate(groupId: number): void { this.pendingUpdates.add(groupId) } @@ -96,13 +108,26 @@ export class CardManager { this.pendingUpdates.clear() for (const groupId of groups) { try { - await this.updateCard(groupId) + await this.flushOne(groupId) } catch (err) { logError(`Card flush failed for group ${groupId}`, err) } } } + // Dispatches to create-path when cardItemId is absent so a failed createCard retries. + private async flushOne(groupId: number): Promise { + const groups = await this.withMainProfile(() => this.chat.apiListGroups(this.mainUserId)) + const groupInfo = groups.find(g => g.groupId === groupId) + if (!groupInfo) return + const data = groupInfo.customData as Record | undefined + if (typeof data?.cardItemId === "number") { + await this.updateCard(groupId) + } else { + await this.createCard(groupId, groupInfo) + } + } + async refreshAllCards(): Promise { const groups = await this.withMainProfile(() => this.chat.apiListGroups(this.mainUserId)) const activeCards: {groupId: number; cardItemId: number}[] = [] @@ -197,16 +222,20 @@ export class CardManager { } async mergeCustomData(groupId: number, patch: Partial): Promise { - const current = (await this.getRawCustomData(groupId)) ?? {} - const merged: Partial = {...current, ...patch} - for (const key of Object.keys(merged) as (keyof CardData)[]) { - if (merged[key] === undefined) delete merged[key] - } - await this.withMainProfile(() => this.chat.apiSetGroupCustomData(groupId, merged)) + return this.getCustomDataMutex(groupId).runExclusive(async () => { + const current = (await this.getRawCustomData(groupId)) ?? {} + const merged: Partial = {...current, ...patch} + for (const key of Object.keys(merged) as (keyof CardData)[]) { + if (merged[key] === undefined) delete merged[key] + } + await this.withMainProfile(() => this.chat.apiSetGroupCustomData(groupId, merged)) + }) } async clearCustomData(groupId: number): Promise { - await this.withMainProfile(() => this.chat.apiSetGroupCustomData(groupId)) + return this.getCustomDataMutex(groupId).runExclusive(() => + this.withMainProfile(() => this.chat.apiSetGroupCustomData(groupId)) + ) } // --- Chat history access --- diff --git a/apps/simplex-support-bot/src/config.ts b/apps/simplex-support-bot/src/config.ts index 026108b4de..e4c845644e 100644 --- a/apps/simplex-support-bot/src/config.ts +++ b/apps/simplex-support-bot/src/config.ts @@ -35,6 +35,14 @@ function optionalArg(args: string[], flag: string, defaultValue: string): string return args[i + 1] } +function parseNonNegativeInt(raw: string, flag: string): number { + const n = parseInt(raw, 10) + if (!Number.isFinite(n) || n < 0) { + throw new Error(`${flag} must be a non-negative integer, got "${raw}"`) + } + return n +} + export function parseConfig(args: string[]): Config { // Treat empty string as absent so `GROK_API_KEY=` behaves like unset const grokApiKey = process.env.GROK_API_KEY || null @@ -49,8 +57,13 @@ export function parseConfig(args: string[]): Config { : [] const timezone = optionalArg(args, "--timezone", "UTC") - const completeHours = parseInt(optionalArg(args, "--complete-hours", "3"), 10) - const cardFlushSeconds = parseInt(optionalArg(args, "--card-flush-seconds", "300"), 10) + try { + new Intl.DateTimeFormat("en-US", {timeZone: timezone, weekday: "short"}) + } catch (err) { + throw new Error(`--timezone "${timezone}" is not a valid IANA time zone: ${(err as Error).message}`) + } + const completeHours = parseNonNegativeInt(optionalArg(args, "--complete-hours", "3"), "--complete-hours") + const cardFlushSeconds = parseNonNegativeInt(optionalArg(args, "--card-flush-seconds", "300"), "--card-flush-seconds") const contextFileRaw = optionalArg(args, "--context-file", "") const contextFile = contextFileRaw || null diff --git a/apps/simplex-support-bot/src/grok.ts b/apps/simplex-support-bot/src/grok.ts index 62ff22e25c..b03108439a 100644 --- a/apps/simplex-support-bot/src/grok.ts +++ b/apps/simplex-support-bot/src/grok.ts @@ -27,6 +27,7 @@ export class GrokApiClient { temperature: 0.3, max_tokens: 1024, }), + signal: AbortSignal.timeout(60_000), }) if (!response.ok) { diff --git a/apps/simplex-support-bot/src/index.ts b/apps/simplex-support-bot/src/index.ts index 7336f32686..ae8bed5f16 100644 --- a/apps/simplex-support-bot/src/index.ts +++ b/apps/simplex-support-bot/src/index.ts @@ -158,9 +158,16 @@ async function main(): Promise { }) log("Grok connecting...") - const evt = await chat.wait("contactConnected", 60000) + const grokProfileName = grokUser!.profile.displayName + const evt = await chat.wait( + "contactConnected", + (e) => + e.user.userId === mainUser.userId + && e.contact.profile.displayName === grokProfileName, + 60_000, + ) if (!evt) { - console.error("Timeout waiting for Grok contact (60s). Exiting.") + console.error(`Timeout waiting for Grok contact (60s, displayName="${grokProfileName}"). Exiting.`) process.exit(1) } config.grokContactId = evt.contact.contactId @@ -190,7 +197,7 @@ async function main(): Promise { directMessages: {enable: T.GroupFeatureEnabled.On}, fullDelete: {enable: T.GroupFeatureEnabled.On}, commands: [ - {type: "command", keyword: "join", label: "Join customer chat", params: "groupId:name"}, + {type: "command", keyword: "join", label: "Join customer chat", params: "groupId"}, ], }