From 00c56227db42ed806c778db7c94096df7017cd33 Mon Sep 17 00:00:00 2001 From: "Evgeny @ SimpleX Chat" <259188159+evgeny-simplex@users.noreply.github.com> Date: Sat, 12 Sep 2026 09:08:55 +0000 Subject: [PATCH] add feed support to bot --- apps/simplex-support-bot/README.md | 5 ++ apps/simplex-support-bot/bot.test.ts | 90 +++++++++++++++++++ apps/simplex-support-bot/src/bot.ts | 61 +++++++++++-- apps/simplex-support-bot/src/config.ts | 14 ++- apps/simplex-support-bot/src/index.ts | 27 +++--- .../test/__mocks__/simplex-chat-types.js | 3 +- plans/2026-09-12-support-bot-broadcast.md | 51 +++++++++++ 7 files changed, 231 insertions(+), 20 deletions(-) create mode 100644 plans/2026-09-12-support-bot-broadcast.md diff --git a/apps/simplex-support-bot/README.md b/apps/simplex-support-bot/README.md index 19b9ab8bd6..78bd527828 100644 --- a/apps/simplex-support-bot/README.md +++ b/apps/simplex-support-bot/README.md @@ -65,12 +65,17 @@ Run `npm start -- --help` for the auto-generated reference. Summary: | `--pg-conn` | postgres | yes | — | PostgreSQL connection string | | `--pg-schema` | postgres | no | `simplex_v1` | schema prefix used for bot tables | | `-a` / `--auto-add-team-members` | both | no | | comma-separated `ID:name` pairs (e.g. `1:Alice,2:Bob`) | +| `--broadcasters` | both | no | | comma-separated `ID:name` pairs of contacts allowed to use `/broadcast` in the team group | | `--timezone` | both | no | `UTC` | IANA zone for weekend detection | | `--complete-hours` | both | no | `3` | auto-complete chats after N hours idle (`0` disables) | | `--card-flush-seconds` | both | no | `300` | debounce card state writes | | `--context-file` | both | required with `GROK_API_KEY` | | text file with Grok system context | | `-h` / `--help` | both | no | | show usage and exit | +## Broadcasts + +A contact listed in `--broadcasters` sends `/broadcast ` in the team group. The bot sends the text as a feed message to every customer group and every direct contact of the bot, replies that the broadcast is queued, and replies again when delivery completes or fails. The text after `/broadcast` may span several lines. + ## Environment variables | Var | Purpose | diff --git a/apps/simplex-support-bot/bot.test.ts b/apps/simplex-support-bot/bot.test.ts index 37521e49dc..7f561dae12 100644 --- a/apps/simplex-support-bot/bot.test.ts +++ b/apps/simplex-support-bot/bot.test.ts @@ -157,8 +157,14 @@ class MockChatApi { } rawCmds: string[] = [] + feedItemIds: number[] = [] async sendChatCmd(cmd: string) { this.rawCmds.push(cmd) + if (cmd.startsWith("/feed ")) { + const itemId = nextItemId++ + this.feedItemIds.push(itemId) + return {type: "newChatItems", user: makeUser(MAIN_USER_ID), chatItems: [makeFeedAChatItem(itemId, {type: "sndNew"})]} + } return {type: "cmdOk"} } @@ -266,6 +272,7 @@ function makeConfig(overrides: Partial = {}) { {id: TEAM_MEMBER_1_ID, name: "Alice"}, {id: TEAM_MEMBER_2_ID, name: "Bob"}, ], + broadcasters: [{id: TEAM_MEMBER_1_ID, name: "Alice"}], groupLinks: "", timezone: "UTC", completeHours: 3, @@ -360,6 +367,13 @@ function makeDirectAChatItem(chatItem: any, contactId: number): any { } } +function makeFeedAChatItem(itemId: number, itemStatus: any): any { + return { + chatInfo: {type: "feed", feed: {feedId: 1, userId: MAIN_USER_ID}}, + chatItem: {chatDir: {type: "feedSnd"}, meta: {itemId, itemStatus}, content: {type: "sndMsgContent", msgContent: {type: "text", text: ""}}}, + } +} + // ─── Shared test state ─── let chat: MockChatApi @@ -1360,6 +1374,75 @@ describe("/join Command (Team Group)", () => { }) }) +describe("/broadcast Command (Team Group)", () => { + beforeEach(() => setup()) + + function statusEvent(itemId: number, itemStatus: any): any { + return { + type: "chatItemsStatusesUpdated" as const, + user: makeUser(MAIN_USER_ID), + chatItems: [makeFeedAChatItem(itemId, itemStatus)], + } + } + + test("/broadcast from broadcaster → /feed sent with JSON text, queued reply", async () => { + await bot.onNewChatItems(teamGroupMessage("/broadcast hello everyone")) + expect(chat.rawCmds).toEqual(['/feed "hello everyone"']) + expectSentToGroup(TEAM_GROUP_ID, `Broadcast ${chat.feedItemIds[0]} queued`) + }) + + test("/broadcast keeps newlines and quotes", async () => { + await bot.onNewChatItems(teamGroupMessage('/broadcast line "one"\nline two')) + expect(chat.rawCmds).toEqual(['/feed "line \\"one\\"\\nline two"']) + }) + + test("/broadcast on its own line → text starts after the newline", async () => { + await bot.onNewChatItems(teamGroupMessage("/broadcast\nline one\nline two")) + expect(chat.rawCmds).toEqual(['/feed "line one\\nline two"']) + }) + + test("/broadcast from a non-broadcaster → error reply, nothing sent", async () => { + await bot.onNewChatItems(teamGroupMessage("/broadcast hello", TEAM_MEMBER_2_ID)) + expect(chat.rawCmds.length).toBe(0) + expectSentToGroup(TEAM_GROUP_ID, `Error: contact ${TEAM_MEMBER_2_ID} is not allowed to broadcast`) + }) + + test("/broadcast without text → error reply, nothing sent", async () => { + await bot.onNewChatItems(teamGroupMessage("/broadcast ")) + expect(chat.rawCmds.length).toBe(0) + expectSentToGroup(TEAM_GROUP_ID, "Error: broadcast text is empty") + }) + + test("customer sending /broadcast in customer group → treated as normal message", async () => { + await bot.onNewChatItems(customerMessage("/broadcast hello")) + expect(chat.rawCmds.length).toBe(0) + expectSentToGroup(CUSTOMER_GROUP_ID, "The team will reply to your message") + }) + + test("feed item complete → delivered reply once", async () => { + await bot.onNewChatItems(teamGroupMessage("/broadcast hello")) + const itemId = chat.feedItemIds[0] + await bot.onChatItemsStatusesUpdated(statusEvent(itemId, {type: "sndSent", sndProgress: "partial"})) + expectNotSentToGroup(TEAM_GROUP_ID, "delivered") + await bot.onChatItemsStatusesUpdated(statusEvent(itemId, {type: "sndSent", sndProgress: "complete"})) + expectSentToGroup(TEAM_GROUP_ID, `Broadcast ${itemId} delivered to all chats`) + await bot.onChatItemsStatusesUpdated(statusEvent(itemId, {type: "sndSent", sndProgress: "complete"})) + expect(chat.sentTo(TEAM_GROUP_ID).filter(m => m.includes("delivered")).length).toBe(1) + }) + + test("feed item error → failure reply", async () => { + await bot.onNewChatItems(teamGroupMessage("/broadcast hello")) + const itemId = chat.feedItemIds[0] + await bot.onChatItemsStatusesUpdated(statusEvent(itemId, {type: "sndError", agentError: {type: "other", sndError: "boom"}})) + expectSentToGroup(TEAM_GROUP_ID, `Broadcast ${itemId} failed`) + }) + + test("status of an unknown feed item → ignored", async () => { + await bot.onChatItemsStatusesUpdated(statusEvent(4242, {type: "sndSent", sndProgress: "complete"})) + expect(chat.sentTo(TEAM_GROUP_ID).length).toBe(0) + }) +}) + describe("DM Handshake", () => { beforeEach(() => setup()) @@ -2458,6 +2541,13 @@ describe("parseConfig Validation", () => { expect(cfg.db).toEqual({type: "sqlite", filePrefix: "./data/simplex", encryptionKey: "secret"}) }) + test("--broadcasters → parsed as ID:name pairs, empty when absent", () => { + expect(parseConfig(baseArgs).broadcasters).toEqual([]) + const cfg = parseConfig([...baseArgs, "--broadcasters", "3:Carol,4:Dave"]) + expect(cfg.broadcasters).toEqual([{id: 3, name: "Carol"}, {id: 4, name: "Dave"}]) + expect(() => parseConfig([...baseArgs, "--broadcasters", "Carol"])).toThrow(/Invalid ID:name format/) + }) + test("unknown flag → parseArgs throws", () => { expect(() => parseConfig([...baseArgs, "--team-gropu", "typo"])) .toThrow() diff --git a/apps/simplex-support-bot/src/bot.ts b/apps/simplex-support-bot/src/bot.ts index 28b2080793..76d031074e 100644 --- a/apps/simplex-support-bot/src/bot.ts +++ b/apps/simplex-support-bot/src/bot.ts @@ -67,6 +67,8 @@ export class SupportBot { // Contacts that already received the team DM (dedup) private sentTeamDMs = new Set() + private pendingBroadcasts = new Set() + // Tracked fire-and-forget operations (for testing) private _pendingOps: Promise[] = [] @@ -313,6 +315,25 @@ export class SupportBot { await this.deliverPendingDM(evt.contact.contactId) } + async onChatItemsStatusesUpdated(evt: CEvt.ChatItemsStatusesUpdated): Promise { + if (evt.user.userId !== this.mainUserId) return + for (const {chatInfo, chatItem} of evt.chatItems) { + if (chatInfo.type !== "feed") continue + const itemId = chatItem.meta.itemId + if (!this.pendingBroadcasts.has(itemId)) continue + const status = chatItem.meta.itemStatus + let report: string | undefined + if (status.type === "sndSent" && status.sndProgress === T.SndCIStatusProgress.Complete) { + report = `Broadcast ${itemId} delivered to all chats` + } else if (status.type === "sndError") { + report = `Broadcast ${itemId} failed: ${JSON.stringify(status.agentError)}` + } + if (report === undefined) continue + this.pendingBroadcasts.delete(itemId) + await this.sendToGroup(this.config.teamGroup.id, report) + } + } + private async deliverPendingDM(contactId: number): Promise { if (this.sentTeamDMs.has(contactId)) { this.pendingTeamDMs.delete(contactId) @@ -814,14 +835,44 @@ export class SupportBot { if (!senderContactId) return const cmd = util.ciBotCommand(chatItem) - if (cmd?.keyword !== "join") return + switch (cmd?.keyword) { + case "join": { + 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) + break + } + case "broadcast": + await this.handleBroadcastCommand(chatItem, senderContactId) + break + } + } - 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}"`) + private async handleBroadcastCommand(chatItem: T.ChatItem, senderContactId: number): Promise { + const teamGroupId = this.config.teamGroup.id + if (!this.config.broadcasters.some(b => b.id === senderContactId)) { + await this.sendToGroup(teamGroupId, `Error: contact ${senderContactId} is not allowed to broadcast`) return } - await this.handleJoinCommand(targetGroupId, senderContactId) + const text = (util.ciContentText(chatItem)?.trim() ?? "").replace(/^\/broadcast\s?/, "") + if (text === "") { + await this.sendToGroup(teamGroupId, "Error: broadcast text is empty") + return + } + try { + const r = await this.withMainProfile(() => this.chat.sendChatCmd(`/feed ${JSON.stringify(text)}`)) + if (r.type !== "newChatItems") throw new Error(`unexpected response ${r.type}`) + const itemId = r.chatItems[0]?.chatItem.meta.itemId + if (itemId === undefined) throw new Error("no feed item in response") + this.pendingBroadcasts.add(itemId) + await this.sendToGroup(teamGroupId, `Broadcast ${itemId} queued for delivery to all chats`) + } catch (err) { + logError("/broadcast failed", err) + await this.sendToGroup(teamGroupId, "Error sending broadcast") + } } private async handleJoinCommand(targetGroupId: number, senderContactId: number): Promise { diff --git a/apps/simplex-support-bot/src/config.ts b/apps/simplex-support-bot/src/config.ts index 8fbd006aef..95f14f9677 100644 --- a/apps/simplex-support-bot/src/config.ts +++ b/apps/simplex-support-bot/src/config.ts @@ -13,6 +13,7 @@ export interface Config { db: api.DbConfig // passed to ChatApi.init / bot.run teamGroup: IdName // name from CLI, id resolved at startup from state file teamMembers: IdName[] // optional, empty if not provided + broadcasters: IdName[] // optional, empty if not provided grokContactId: number | null // resolved at startup timezone: string completeHours: number @@ -41,6 +42,10 @@ export function parseIdName(s: string): IdName { return {id, name: s.slice(i + 1)} } +function parseIdNames(list: string | undefined): IdName[] { + return list ? list.split(",").map(parseIdName) : [] +} + function parseNonNegativeInt(flag: string) { return (raw: string): number => { const n = parseInt(raw, 10) @@ -62,6 +67,7 @@ function buildCommand(): Command { .option("--pg-conn ", "PostgreSQL connection string (required for postgres)") .option("--pg-schema ", "PostgreSQL schema prefix (default: simplex_v1)") .option("-a, --auto-add-team-members ", "comma-separated ID:name pairs (e.g. 1:Alice,2:Bob)") + .option("--broadcasters ", "comma-separated ID:name pairs of contacts allowed to use /broadcast in the team group") .option("--timezone ", "IANA timezone for weekend detection", "UTC") .option("--complete-hours ", "auto-complete chats after N hours idle (0 disables)", parseNonNegativeInt("--complete-hours"), 3) .option("--card-flush-seconds ", "debounce card state writes", parseNonNegativeInt("--card-flush-seconds"), 300) @@ -77,6 +83,7 @@ interface RawOpts { pgConn?: string pgSchema?: string autoAddTeamMembers?: string + broadcasters?: string timezone: string completeHours: number cardFlushSeconds: number @@ -113,10 +120,8 @@ export function parseConfig(args: string[]): Config { const teamGroup: IdName = {id: 0, name: opts.teamGroup} - const teamMembersRaw = opts.autoAddTeamMembers ?? "" - const teamMembers = teamMembersRaw - ? teamMembersRaw.split(",").map(parseIdName) - : [] + const teamMembers = parseIdNames(opts.autoAddTeamMembers) + const broadcasters = parseIdNames(opts.broadcasters) try { new Intl.DateTimeFormat("en-US", {timeZone: opts.timezone, weekday: "short"}) @@ -134,6 +139,7 @@ export function parseConfig(args: string[]): Config { db, teamGroup, teamMembers, + broadcasters, grokContactId: null, timezone: opts.timezone, completeHours: opts.completeHours, diff --git a/apps/simplex-support-bot/src/index.ts b/apps/simplex-support-bot/src/index.ts index c99b1f5842..c059ae8daf 100644 --- a/apps/simplex-support-bot/src/index.ts +++ b/apps/simplex-support-bot/src/index.ts @@ -1,7 +1,7 @@ import {readFileSync, writeFileSync, existsSync} from "fs" import {api, bot, util} from "simplex-chat" import {T} from "@simplex-chat/types" -import {parseConfig} from "./config.js" +import {IdName, parseConfig} from "./config.js" import {SupportBot} from "./bot.js" import {GrokApiClient, GrokMessage} from "./grok.js" import {loadGrokContext} from "./context.js" @@ -31,6 +31,7 @@ async function main(): Promise { backend: config.db.type, teamGroup: config.teamGroup, teamMembers: config.teamMembers, + broadcasters: config.broadcasters, timezone: config.timezone, completeHours: config.completeHours, }) @@ -113,6 +114,7 @@ async function main(): Promise { newMemberContactReceivedInv: (evt) => supportBot?.onMemberContactReceivedInv(evt), contactConnected: (evt) => supportBot?.onContactConnected(evt), contactSndReady: (evt) => supportBot?.onContactSndReady(evt), + chatItemsStatusesUpdated: (evt) => supportBot?.onChatItemsStatusesUpdated(evt), }, }) log(`Main bot user: ${mainUser.profile.displayName} (userId=${mainUser.userId})`) @@ -229,6 +231,7 @@ async function main(): Promise { fullDelete: {enable: T.GroupFeatureEnabled.On}, commands: [ {type: "command", keyword: "join", label: "Join customer chat", params: "groupId"}, + {type: "command", keyword: "broadcast", label: "Broadcast to all chats", params: "text"}, ], } @@ -300,20 +303,24 @@ async function main(): Promise { inviteLinkTimer.unref() } - // Step 9: Validate team members (lookup by ID, one round-trip per member) - if (config.teamMembers.length > 0) { - log("Validating team members...") - for (const member of config.teamMembers) { - const contact = await getContact(chat, member.id) + // Step 9: Validate team members and broadcasters (lookup by ID, one round-trip per contact) + await validateContacts("Team member", config.teamMembers) + await validateContacts("Broadcaster", config.broadcasters) + + async function validateContacts(role: string, contacts: IdName[]): Promise { + if (contacts.length === 0) return + log(`Validating ${role.toLowerCase()}s...`) + for (const {id, name} of contacts) { + const contact = await getContact(chat, id) if (!contact) { - console.error(`Team member not found: ID=${member.id}`) + console.error(`${role} not found: ID=${id}`) process.exit(1) } - if (contact.profile.displayName !== member.name) { - console.error(`Team member name mismatch: expected "${member.name}", got "${contact.profile.displayName}" (ID=${member.id})`) + if (contact.profile.displayName !== name) { + console.error(`${role} name mismatch: expected "${name}", got "${contact.profile.displayName}" (ID=${id})`) process.exit(1) } - log(`Team member validated: ${member.id}:${member.name}`) + log(`${role} validated: ${id}:${name}`) } } diff --git a/apps/simplex-support-bot/test/__mocks__/simplex-chat-types.js b/apps/simplex-support-bot/test/__mocks__/simplex-chat-types.js index 29fc3d01a4..e1e5556f4e 100644 --- a/apps/simplex-support-bot/test/__mocks__/simplex-chat-types.js +++ b/apps/simplex-support-bot/test/__mocks__/simplex-chat-types.js @@ -5,8 +5,9 @@ const GroupMemberRole = {Member: "member", Owner: "owner", Admin: "admin", Relay const GroupMemberStatus = {Connected: "connected", Complete: "complete", Announced: "announced", Left: "left", Removed: "removed", Invited: "invited"} const GroupFeatureEnabled = {On: "on", Off: "off"} const CIDeleteMode = {Broadcast: "broadcast", Internal: "internal"} +const SndCIStatusProgress = {Partial: "partial", Complete: "complete"} module.exports = { - T: {ChatType, GroupMemberRole, GroupMemberStatus, GroupFeatureEnabled, CIDeleteMode}, + T: {ChatType, GroupMemberRole, GroupMemberStatus, GroupFeatureEnabled, CIDeleteMode, SndCIStatusProgress}, CEvt: {}, } diff --git a/plans/2026-09-12-support-bot-broadcast.md b/plans/2026-09-12-support-bot-broadcast.md new file mode 100644 index 0000000000..60ef996d22 --- /dev/null +++ b/plans/2026-09-12-support-bot-broadcast.md @@ -0,0 +1,51 @@ +# Support bot broadcast + +A feed broadcast sent from the support bot's team group. + +## Decisions + +- Trigger: `/broadcast ` in the team group, accepted only from contacts listed in `--broadcasters`. +- Audience: unrestricted. Every customer group and every direct contact receives it, including the Grok contact and team member DMs. +- Content: text only. +- Command: the bot sends the CLI command `/feed ` through `sendChatCmd`. `APISendFeedMessage` stays undocumented and no lookup command is added until the feed API stabilises. +- Reporting: the bot acknowledges on send, and posts again when the feed item reaches `sndSent` `complete` or an error status. + +## Bot + +`apps/simplex-support-bot/src/config.ts`: + +- `--broadcasters `: comma-separated `ID:name` pairs, parsed with `parseIdName` +- `Config.broadcasters: IdName[]` + +`apps/simplex-support-bot/src/index.ts`: + +- broadcasters are validated at startup as team members are: read the contact by id, then compare the display name +- `teamGroupPreferences.commands` gains + `{type: "command", keyword: "broadcast", label: "Broadcast to all chats", params: "text"}` +- the `events` map gains `chatItemsStatusesUpdated` + +`apps/simplex-support-bot/src/bot.ts`: + +- `processTeamGroupMessage` routes the `broadcast` keyword to `handleBroadcastCommand` and keeps the `join` route +- `handleBroadcastCommand`: + - the sender's `memberContactId` must be in `config.broadcasters`, otherwise the bot replies in the team group and stops + - the text is the trimmed message with the `/broadcast` prefix and one following whitespace character removed; `ciBotCommand`'s `params` stops at the first newline, so it is only used for the keyword + - empty text is an error reply + - the command sent is `"/feed " + JSON.stringify(text)`; `msgTextP` decodes the JSON string, so newlines and quotes survive + - the response must be `newChatItems`; its first item id is held in a pending map, and the bot replies that the broadcast is queued +- `onChatItemsStatusesUpdated` matches items whose chat info type is `feed` and whose id is in the pending map: + - `sndSent` with `sndProgress` `complete` replies that the broadcast is delivered, and drops the entry + - `sndError` replies with the error text, and drops the entry +- a restart drops pending reports; delivery continues in the feed workers + +## Status + +Written: config, startup validation, command routing, the broadcast handler, the status handler, the README section, and nine tests in `bot.test.ts` with a `feed` chat item factory and a `/feed` response in the mock API. + +Not run: `tsc` and `vitest`, since the bot's `node_modules` is absent and installing it was declined. + +## Not changed + +- The feed audience. Restricting a broadcast to customer groups would need a scope on the feed itself. +- The API docs, the TypeScript client and the Python client. +- `updateChatSettings`. It still rejects `*` and `%`, so `/feed drop %` is not accepted.