support-bot: Implement adversarial review changes

This commit is contained in:
Narasimha-sc
2026-04-23 09:43:57 +00:00
parent 6ea8a0cd89
commit a5f3d3f6d3
7 changed files with 339 additions and 118 deletions
+153
View File
@@ -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
+98 -96
View File
@@ -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> {
+37 -8
View File
@@ -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 ---
+15 -2
View File
@@ -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
+1
View File
@@ -27,6 +27,7 @@ export class GrokApiClient {
temperature: 0.3,
max_tokens: 1024,
}),
signal: AbortSignal.timeout(60_000),
})
if (!response.ok) {
+10 -3
View File
@@ -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"},
],
}