bots: SimpleX Calculator demo bot (#7583)

* bots: SimpleX Calculator demo bot

* refactor

* use local server in test

* simplify

* icon

* simplify

* expression

* replies

* improve

* log

* update name in bot address

* @simplex-chat/type: 0.12.0

* simplex-chat nodejs: 7.1.0-beta.4.2

* update dependencies

* option to add domain to bot address

---------

Co-authored-by: Evgeny @ SimpleX Chat <259188159+evgeny-simplex@users.noreply.github.com>
This commit is contained in:
Evgeny
2026-09-27 17:20:40 +01:00
committed by GitHub
co-authored by Evgeny @ SimpleX Chat
parent 4cb244441b
commit 643a9afad6
35 changed files with 1075 additions and 49 deletions
+4 -2
View File
@@ -24,13 +24,13 @@ Simple bot that replies with squares of numbers you send to it:
const {bot} = await import("simplex-chat")
// if you are running from this GitHub repo:
// const {bot} = await import("../dist/index.js")
const [chat, _user, _address] = await bot.run({
await bot.run({
profile: {displayName: "Squaring bot example", fullName: ""},
dbOpts: {type: "sqlite", filePrefix: "./squaring_bot"},
options: {
addressSettings: {welcomeMessage: "Send a number, I will square it.",
},
onMessage: async (ci, content) => {
onMessage: async (ci, content, chat) => {
const n = +content.text
const reply = typeof n === "number" && !isNaN(n)
? `${n} * ${n} = ${n * n}`
@@ -62,6 +62,8 @@ There is an example with more options in [./examples/squaring-bot.ts](./examples
You can run it with: `npx ts-node ./examples/squaring-bot.ts`
A larger example, with a business address and commands as keys: [SimpleX Calculator](../../apps/simplex-calculator-bot/).
## PostgreSQL backend
By default, the package uses SQLite. To use PostgreSQL instead:
@@ -6,7 +6,7 @@
# Type Alias: EventSubscriberFunc\<K\>
> **EventSubscriberFunc**\<`K`\> = (`event`) => `void` \| `Promise`\<`void`\>
> **EventSubscriberFunc**\<`K`\> = (`event`, `chat`) => `void` \| `Promise`\<`void`\>
Defined in: [src/api.ts:50](../src/api.ts#L50)
@@ -22,6 +22,10 @@ Defined in: [src/api.ts:50](../src/api.ts#L50)
`ChatEvent` & `object`
### chat
[`ChatApi`](api.Class.ChatApi.md)
## Returns
`void` \| `Promise`\<`void`\>
@@ -18,7 +18,7 @@ Defined in: [src/bot.ts:108](../src/bot.ts#L108)
### onMessage
((`chatItem`, `content`) => `void` \| `Promise`\<`void`\>) \| `undefined`
((`chatItem`, `content`, `chat`) => `void` \| `Promise`\<`void`\>) \| `undefined`
### commands
@@ -34,13 +34,13 @@ Defined in: [src/bot.ts:42](../src/bot.ts#L42)
#### Index Signature
\[`key`: `string`\]: ((`chatItem`, `command`) => `void` \| `Promise`\<`void`\>) \| `undefined`
\[`key`: `string`\]: ((`chatItem`, `command`, `chat`) => `void` \| `Promise`\<`void`\>) \| `undefined`
***
### onMessage?
> `optional` **onMessage?**: (`chatItem`, `content`) => `void` \| `Promise`\<`void`\>
> `optional` **onMessage?**: (`chatItem`, `content`, `chat`) => `void` \| `Promise`\<`void`\>
Defined in: [src/bot.ts:40](../src/bot.ts#L40)
@@ -54,6 +54,10 @@ Defined in: [src/bot.ts:40](../src/bot.ts#L40)
`MsgContent`
##### chat
[`ChatApi`](api.Class.ChatApi.md)
#### Returns
`void` \| `Promise`\<`void`\>
@@ -1,12 +1,12 @@
(async () => {
const {bot} = await import("../dist/index.js")
const [chat, _user, _address] = await bot.run({
await bot.run({
profile: {displayName: "Squaring bot example", fullName: ""},
dbOpts: {type: "sqlite", filePrefix: "./squaring_bot"},
options: {
addressSettings: {welcomeMessage: "Send a number, I will square it."},
},
onMessage: async (ci, content) => {
onMessage: async (ci, content, chat) => {
const n = +content.text
const reply = typeof n === "number" && !isNaN(n)
? `${n} * ${n} = ${n * n}`
@@ -3,7 +3,7 @@ import {bot, util} from "../dist"
(async () => {
const welcomeMessage = "Hello! I am a simple squaring bot.\n\nIf you send me a number, I will calculate its square."
const [chat, _user, _address] = await bot.run({
await bot.run({
profile: {displayName: "Squaring bot example", fullName: ""},
dbOpts: {type: "sqlite", filePrefix: "./squaring_bot"},
options: {
@@ -15,7 +15,7 @@ import {bot, util} from "../dist"
logContacts: true,
logNetwork: false
},
onMessage: async (ci, content) => {
onMessage: async (ci, content, chat) => {
const n = +content.text
const reply = typeof n === "number" && !isNaN(n)
? `${n} * ${n} = ${n * n}`
@@ -23,11 +23,11 @@ import {bot, util} from "../dist"
await chat.apiSendTextReply(ci, reply)
},
onCommands: { // command handlers can be different from commands to be shown in client UI
"help": async (ci: T.AChatItem, _cmd: util.BotCommand) => {
"help": async (ci: T.AChatItem, _cmd: util.BotCommand, chat) => {
await chat.apiSendTextMessage(ci.chatInfo, welcomeMessage)
},
// fallback handler that will be called for all other commands
"": async (ci: T.AChatItem, _cmd: util.BotCommand) => {
"": async (ci: T.AChatItem, _cmd: util.BotCommand, chat) => {
await chat.apiSendTextReply(ci, "This command is not supported")
}
},
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "simplex-chat",
"version": "7.1.0-beta.4",
"version": "7.1.0-beta.4.2",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"files": [
+15 -4
View File
@@ -47,7 +47,7 @@ export const defaultBotAddressSettings: BotAddressSettings = {
businessAddress: false
}
export type EventSubscriberFunc<K extends CEvt.Tag> = (event: ChatEvent & {type: K}) => void | Promise<void>
export type EventSubscriberFunc<K extends CEvt.Tag> = (event: ChatEvent & {type: K}, chat: ChatApi) => void | Promise<void>
export type EventSubscribers = {[K in CEvt.Tag]?: EventSubscriberFunc<K>}
@@ -177,7 +177,7 @@ export class ChatApi {
if (subs) {
for (const {subscriber, once} of [...subs]) {
try {
const p = (subscriber as EventSubscriberFunc<typeof event.type>)(event)
const p = (subscriber as EventSubscriberFunc<typeof event.type>)(event, this)
if (p instanceof Promise) await p
} catch(e) {
console.log(`${event.type} event processing error`, e)
@@ -187,7 +187,7 @@ export class ChatApi {
}
for (const r of [...this.receivers]) {
try {
const p = r(event)
const p = r(event, this)
if (p instanceof Promise) await p
} catch(e) {
console.log(`${event.type} event processing error`, e)
@@ -422,7 +422,18 @@ export class ChatApi {
const r = await this.sendChatCmd(CC.APISetAddressSettings.cmdString({userId, settings}))
if (r.type !== "userContactLinkUpdated") {
throw new ChatCommandError("error changing user contact address settings", r)
}
}
}
async apiSetUserDomain(userId: number, simplexDomain?: string): Promise<T.User> {
const r = await this.sendChatCmd(CC.APISetUserDomain.cmdString({userId, simplexDomain}))
switch (r.type) {
case "userProfileUpdated":
case "userProfileNoChange":
return r.user
default:
throw new ChatCommandError("error setting SimpleX name", r)
}
}
/**
+31 -11
View File
@@ -35,17 +35,18 @@ const defaultOpts: Required<BotOptions> = {
export interface BotConfig {
profile: T.Profile,
simplexDomain?: string | null,
dbOpts: BotDbOpts,
options: BotOptions,
onMessage?: (chatItem: T.AChatItem, content: T.MsgContent) => void | Promise<void>,
onMessage?: (chatItem: T.AChatItem, content: T.MsgContent, chat: api.ChatApi) => void | Promise<void>,
// command handlers can be different from commands to be shown in client UI
onCommands?: {[K in string]?: ((chatItem: T.AChatItem, command: util.BotCommand) => void | Promise<void>)},
onCommands?: {[K in string]?: ((chatItem: T.AChatItem, command: util.BotCommand, chat: api.ChatApi) => void | Promise<void>)},
// If you use `onMessage` and to subscribe "newChatItems" event, exclude content messages from processing
// If you use `onCommands` and to subscribe "newChatItems" event, exclude commands from processing
events?: api.EventSubscribers
}
export async function run({profile, dbOpts, options = defaultOpts, onMessage, onCommands = {}, events = {}}: BotConfig): Promise<[api.ChatApi, T.User, T.UserContactLink | undefined]> {
export async function run({profile, simplexDomain, dbOpts, options = defaultOpts, onMessage, onCommands = {}, events = {}}: BotConfig): Promise<[api.ChatApi, T.User, T.UserContactLink | undefined]> {
const bot = await api.ChatApi.init(dbOpts, dbOpts.confirmMigrations || core.MigrationConfirmation.YesUp, dbOpts.queueSize)
const opts = fullOptions(options)
if (onMessage || Object.keys(onCommands).length > 0) subscribeChatItems(bot, onMessage, onCommands)
@@ -60,7 +61,8 @@ export async function run({profile, dbOpts, options = defaultOpts, onMessage, on
console.log(`Bot address: ${addressLink}`)
if (opts.useBotProfile) botProfile.contactLink = addressLink
}
await updateBotUserProfile(bot, user, botProfile, opts)
const namedUser = await updateBotSimplexDomain(bot, user, simplexDomain, opts)
await updateBotUserProfile(bot, namedUser, botProfile, opts)
return [bot, user, address]
}
@@ -107,8 +109,8 @@ function mkBotProfile(profile: T.Profile, opts: Required<BotOptions>): T.Profile
export function subscribeChatItems(
bot: api.ChatApi,
onMessage: ((chatItem: T.AChatItem, content: T.MsgContent) => void | Promise<void>) | undefined,
commands: {[K in string]?: ((chatItem: T.AChatItem, command: util.BotCommand) => void | Promise<void>)}
onMessage: ((chatItem: T.AChatItem, content: T.MsgContent, chat: api.ChatApi) => void | Promise<void>) | undefined,
commands: {[K in string]?: ((chatItem: T.AChatItem, command: util.BotCommand, chat: api.ChatApi) => void | Promise<void>)}
) {
bot.on("newChatItems", async ({chatItems}) => {
for (const ci of chatItems) {
@@ -117,8 +119,8 @@ export function subscribeChatItems(
const cmd = util.ciBotCommand(ci.chatItem)
const cmdFunc = cmd && (commands[cmd.keyword] || commands[""])
try {
if (cmd && cmdFunc) await cmdFunc(ci, cmd)
else if (onMessage) await onMessage(ci, content.msgContent)
if (cmd && cmdFunc) await cmdFunc(ci, cmd, bot)
else if (onMessage) await onMessage(ci, content.msgContent, bot)
} catch (e) {
console.log(cmd && cmdFunc ? `${cmd.keyword} command processing error` : "message processing error", e)
}
@@ -180,15 +182,33 @@ async function createOrUpdateAddress(bot: api.ChatApi, user: T.User, opts: Requi
}
}
return address
return address
}
async function updateBotSimplexDomain(bot: api.ChatApi, user: T.User, simplexDomain: string | null | undefined, opts: Required<BotOptions>): Promise<T.User> {
if (simplexDomain === undefined) return user
const domain = simplexDomain === null ? undefined : simplexDomain.toLowerCase()
if (user.profile.contactDomain?.domain === domain) return user
if (!opts.updateAddress) {
console.log("Bot SimpleX name changed")
return user
}
console.log("Bot SimpleX name changed, updating...")
try {
return await bot.apiSetUserDomain(user.userId, domain)
} catch (e) {
console.log("Error updating bot SimpleX name", e)
return user
}
}
async function updateBotUserProfile(bot: api.ChatApi, user: T.User, profile: T.Profile, opts: Required<BotOptions>): Promise<void> {
const {userId} = user
if (!equal(util.fromLocalProfile(user.profile), profile)) {
const {contactDomain, ...currentProfile} = util.fromLocalProfile(user.profile)
if (!equal(currentProfile, profile)) {
if (opts.updateProfile) {
console.log("Bot profile changed, updating...")
const summary = await bot.apiUpdateProfile(userId, profile)
const summary = await bot.apiUpdateProfile(userId, {...profile, contactDomain})
console.log(
summary
? `Bot profile updated: ${summary.updateSuccesses} updated contact(s), ${summary.updateFailures} failed contact update(s).`
+2 -2
View File
@@ -53,8 +53,8 @@ export function botAddressSettings({addressSettings}: T.UserContactLink): BotAdd
}
}
export function fromLocalProfile({displayName, fullName, shortDescr, image, contactLink, preferences, peerType}: T.LocalProfile): T.Profile {
const profile = {displayName, fullName, shortDescr, image, contactLink, preferences, peerType}
export function fromLocalProfile({displayName, fullName, shortDescr, image, contactLink, preferences, peerType, contactDomain}: T.LocalProfile): T.Profile {
const profile = {displayName, fullName, shortDescr, image, contactLink, preferences, peerType, contactDomain: contactDomain && {domain: contactDomain.domain}}
for (const key in profile) {
if (typeof (profile as any)[key] === "undefined") delete (profile as any)[key]
}
@@ -1,4 +1,4 @@
import {ChatResponse, T} from "@simplex-chat/types"
import {ChatEvent, ChatResponse, T} from "@simplex-chat/types"
import * as api from "../src/api"
import * as core from "../src/core"
@@ -30,6 +30,13 @@ describe("documented success responses", () => {
await expect(chat.apiSetProfileAddress(1, true)).resolves.toEqual({updateSuccesses: 0, updateFailures: 0, changedContacts: []})
})
it("apiSetUserDomain sets and removes the name", async () => {
const chat = await chatWithResponse({type: "userProfileNoChange", user})
await expect(chat.apiSetUserDomain(1, "calc.simplex")).resolves.toEqual(user)
await expect(chat.apiSetUserDomain(1)).resolves.toEqual(user)
expect(jest.mocked(core.chatSendCmd).mock.calls.map(([, cmd]) => cmd)).toEqual(["/_set domain 1 calc.simplex", "/_set domain 1"])
})
it("apiReceiveFile reports a file cancelled by sender", async () => {
const chat = await chatWithResponse({type: "rcvFileAcceptedSndCancelled", user, rcvFileTransfer: {}})
await expect(chat.apiReceiveFile(3)).rejects.toThrow("file cancelled by sender")
@@ -94,6 +101,15 @@ describe("startChat lifecycle", () => {
await chat.stopChat()
})
it("passes the chat api to event subscribers", async () => {
const chat = await chatWithResponses({type: "chatStarted"}, {type: "chatStopped"})
jest.mocked(core.chatRecvMsgWait).mockResolvedValueOnce({type: "hostConnected"} as ChatEvent)
const subscriberChat = new Promise(resolve => chat.on("hostConnected", (_event, c) => resolve(c)))
await chat.startChat()
await expect(subscriberChat).resolves.toBe(chat)
await chat.stopChat()
})
it("receives with a 500 ms wait", async () => {
const chat = await chatWithResponses()
await chat.recvChatEvent()
@@ -1,6 +1,6 @@
import {ChatEvent, T} from "@simplex-chat/types"
import * as api from "../src/api"
import {subscribeChatItems} from "../src/bot"
import {run, subscribeChatItems} from "../src/bot"
type Handler = (evt: ChatEvent) => Promise<void>
@@ -40,6 +40,14 @@ describe("subscribeChatItems", () => {
expect(calls).toEqual(["message:/unknown", "message:hello"])
})
it("passes the chat api to handlers", async () => {
const {bot, deliver} = fakeBot()
const chats: api.ChatApi[] = []
subscribeChatItems(bot, async (_ci, _content, chat) => { chats.push(chat) }, {help: async (_ci, _cmd, chat) => { chats.push(chat) }})
await deliver([item("rcvMsgContent", "/help"), item("rcvMsgContent", "hello")])
expect(chats).toEqual([bot, bot])
})
it("ignores sent items", async () => {
const {bot, deliver} = fakeBot()
const calls: string[] = []
@@ -48,3 +56,75 @@ describe("subscribeChatItems", () => {
expect(calls).toEqual([])
})
})
describe("run", () => {
const address = {
connLinkContact: {connFullLink: "full", connShortLink: "short"},
addressSettings: {businessAddress: false, autoAccept: {acceptIncognito: false}},
} as unknown as T.UserContactLink
function fakeChat(contactDomain?: T.SimplexDomainClaim) {
const user = {userId: 1, profile: {displayName: "Old", fullName: "", contactDomain}} as unknown as T.User
const chat = {
on: jest.fn(),
apiGetActiveUser: jest.fn().mockResolvedValue(user),
startChat: jest.fn(),
apiGetUserAddress: jest.fn().mockResolvedValue(address),
apiSetAddressSettings: jest.fn(),
apiSetUserDomain: jest.fn(async (_userId: number, domain?: string) => ({...user, profile: {...user.profile, contactDomain: domain && {domain}}})),
apiUpdateProfile: jest.fn().mockResolvedValue({updateSuccesses: 0, updateFailures: 0}),
}
jest.spyOn(api.ChatApi, "init").mockResolvedValue(chat as unknown as api.ChatApi)
return chat
}
const runBot = (simplexDomain?: string | null, options = {}) =>
run({profile: {displayName: "Calculator", fullName: ""}, simplexDomain, dbOpts: {type: "sqlite", filePrefix: "unused"}, options})
const updatedProfile = (chat: ReturnType<typeof fakeChat>) => chat.apiUpdateProfile.mock.calls[0][1]
beforeEach(() => jest.spyOn(console, "log").mockImplementation(() => {}))
afterEach(() => jest.restoreAllMocks())
it("sets the configured SimpleX name", async () => {
const chat = fakeChat()
await runBot("Calc.simplex")
expect(chat.apiSetUserDomain).toHaveBeenCalledWith(1, "calc.simplex")
expect(updatedProfile(chat).contactDomain).toEqual({domain: "calc.simplex"})
})
it("removes the SimpleX name set to null", async () => {
const chat = fakeChat({domain: "calc.simplex"})
await runBot(null)
expect(chat.apiSetUserDomain).toHaveBeenCalledWith(1, undefined)
expect(updatedProfile(chat).contactDomain).toBeUndefined()
})
it("keeps the stored SimpleX name when it is not configured", async () => {
const chat = fakeChat({domain: "calc.simplex"})
await runBot()
expect(chat.apiSetUserDomain).not.toHaveBeenCalled()
expect(updatedProfile(chat).contactDomain).toEqual({domain: "calc.simplex"})
})
it("keeps the SimpleX name when updating the profile", async () => {
const chat = fakeChat({domain: "calc.simplex", proof: {presHeader: "header", signature: "signature"}})
await runBot("calc.simplex")
expect(chat.apiSetUserDomain).not.toHaveBeenCalled()
expect(updatedProfile(chat).displayName).toBe("Calculator")
expect(updatedProfile(chat).contactDomain).toEqual({domain: "calc.simplex"})
})
it("continues when the SimpleX name cannot be set", async () => {
const chat = fakeChat()
chat.apiSetUserDomain.mockRejectedValue(new Error("simplexDomainNotReady"))
await expect(runBot("calc.simplex")).resolves.toBeDefined()
expect(updatedProfile(chat).contactDomain).toBeUndefined()
})
it("does not change the SimpleX name without updateAddress", async () => {
const chat = fakeChat()
await runBot("calc.simplex", {updateAddress: false})
expect(chat.apiSetUserDomain).not.toHaveBeenCalled()
})
})