add feed support to bot

This commit is contained in:
Evgeny @ SimpleX Chat
2026-09-12 09:09:06 +00:00
parent 68770bb71f
commit 00c56227db
7 changed files with 231 additions and 20 deletions
+5
View File
@@ -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 <text>` 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 |
+90
View File
@@ -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<any> = {}) {
{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()
+56 -5
View File
@@ -67,6 +67,8 @@ export class SupportBot {
// Contacts that already received the team DM (dedup)
private sentTeamDMs = new Set<number>()
private pendingBroadcasts = new Set<number>()
// Tracked fire-and-forget operations (for testing)
private _pendingOps: Promise<void>[] = []
@@ -313,6 +315,25 @@ export class SupportBot {
await this.deliverPendingDM(evt.contact.contactId)
}
async onChatItemsStatusesUpdated(evt: CEvt.ChatItemsStatusesUpdated): Promise<void> {
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<void> {
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<void> {
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<void> {
+10 -4
View File
@@ -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 <conn>", "PostgreSQL connection string (required for postgres)")
.option("--pg-schema <prefix>", "PostgreSQL schema prefix (default: simplex_v1)")
.option("-a, --auto-add-team-members <list>", "comma-separated ID:name pairs (e.g. 1:Alice,2:Bob)")
.option("--broadcasters <list>", "comma-separated ID:name pairs of contacts allowed to use /broadcast in the team group")
.option("--timezone <iana>", "IANA timezone for weekend detection", "UTC")
.option("--complete-hours <n>", "auto-complete chats after N hours idle (0 disables)", parseNonNegativeInt("--complete-hours"), 3)
.option("--card-flush-seconds <n>", "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,
+17 -10
View File
@@ -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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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}`)
}
}
@@ -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: {},
}
+51
View File
@@ -0,0 +1,51 @@
# Support bot broadcast
A feed broadcast sent from the support bot's team group.
## Decisions
- Trigger: `/broadcast <text>` 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 <json text>` 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 <list>`: 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.