mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-09-27 17:58:47 +00:00
add feed support to bot
This commit is contained in:
@@ -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 |
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: {},
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user