mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-07-21 11:11:48 +00:00
support-bot: Implement adversarial review changes
This commit is contained in:
@@ -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<void>; 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<void>(r => { release = r })
|
||||
this._gate = {promise, release}
|
||||
}
|
||||
releaseChat() {
|
||||
this._gate?.release()
|
||||
this._gate = null
|
||||
}
|
||||
|
||||
async chat(history: any[], userMessage: string): Promise<string> {
|
||||
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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 <groupId>'` (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 <groupId> (the only accepted form) → team member added; `params` from `ciBotCommand` is parsed via `Number.parseInt`, no regex
|
||||
- /join <groupId>:<name> (historic suffix) → still parses because `Number.parseInt("<groupId>:<name>", 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
|
||||
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
|
||||
@@ -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<number>()
|
||||
private flushInterval: NodeJS.Timeout
|
||||
// Outer lock; profileMutex (via withMainProfile) is the inner lock.
|
||||
private customDataMutexes = new Map<number, Mutex>()
|
||||
|
||||
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<void> {
|
||||
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<string, unknown> | undefined
|
||||
if (typeof data?.cardItemId === "number") {
|
||||
await this.updateCard(groupId)
|
||||
} else {
|
||||
await this.createCard(groupId, groupInfo)
|
||||
}
|
||||
}
|
||||
|
||||
async refreshAllCards(): Promise<void> {
|
||||
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<CardData>): Promise<void> {
|
||||
const current = (await this.getRawCustomData(groupId)) ?? {}
|
||||
const merged: Partial<CardData> = {...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<CardData> = {...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<void> {
|
||||
await this.withMainProfile(() => this.chat.apiSetGroupCustomData(groupId))
|
||||
return this.getCustomDataMutex(groupId).runExclusive(() =>
|
||||
this.withMainProfile(() => this.chat.apiSetGroupCustomData(groupId))
|
||||
)
|
||||
}
|
||||
|
||||
// --- Chat history access ---
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ export class GrokApiClient {
|
||||
temperature: 0.3,
|
||||
max_tokens: 1024,
|
||||
}),
|
||||
signal: AbortSignal.timeout(60_000),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
|
||||
@@ -158,9 +158,16 @@ async function main(): Promise<void> {
|
||||
})
|
||||
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<void> {
|
||||
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"},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user