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
+2
View File
@@ -0,0 +1,2 @@
node_modules/
data/
+36
View File
@@ -0,0 +1,36 @@
# SimpleX Calculator
A calculator bot built with the [SimpleX Chat Node.js library](../../packages/simplex-chat-nodejs/).
Each user who connects via the bot's business address receives a calculator message with keys as commands. The bot replaces this message after each input.
- `×` and `÷` are applied before `+` and `-`, so `2 + 3 × 4 =` equals 14.
- `%` is computed as in Apple's calculator: `100 + 15 % =` equals 115.
- Tap `C` to clear the number, and `C` again to clear the calculation.
- Numbers are shown without exponent, up to 15 digits; larger results are shown as `Error`.
- You can also send numbers and keys like `+`, `=`, `add` or `c`. Expressions like `(2 + 3) × 4` are entered as one number, and the bot replies to them with the result.
- The bot deletes keys and numbers, tapped or sent, and logs each computed operation, such as `15 + 10 = 25`.
- After 10 minutes without input, the bot turns the calculator off and discards the calculation; tap any key to turn it on.
- The bot sends keys like `/+` to apps with chat protocol version 21 or later, and keys like `/add` to older apps.
## Run
```bash
npm install
npm run build
npm start
```
The bot prints its address and keeps its data in `./data`.
To add a SimpleX name to the address, register the name with the address short link, and run `npm start -- --domain yourname.simplex`. The name is kept when the bot starts without `--domain`.
To use the library from this repository, build it and run `npm install --no-save ../../packages/simplex-chat-nodejs` instead of `npm install`.
## Test
```bash
npm test
```
A local SMP server is started for the end-to-end test. On Linux, `smp-server` is downloaded from [simplexmq releases](https://github.com/simplex-chat/simplexmq/releases); to use another build, or on other systems, set `SMP_SERVER` to the path of `smp-server`.
@@ -0,0 +1,202 @@
import {describe, test, expect} from "vitest"
import {Calc, Key, calculatorText, initialCalc, press, textInput} from "./src/calculator.js"
type Input = {tap: string} | {text: string}
function run(inputs: Input[]): {calc: Calc, logLines: string[]} {
const logLines: string[] = []
const calc = inputs.reduce((current, input) => {
const [next, logLine] = "tap" in input ? press(current, input.tap as Key) : textInput(input.text)!.update(current)
if (logLine) logLines.push(logLine)
return next
}, initialCalc)
return {calc, logLines}
}
const tap = (keys: string) => run(keys.split(" ").map(key => ({tap: key})))
const type = (...texts: string[]) => run(texts.map(text => ({text})))
const display = (keys: string) => tap(keys).calc.display
describe("keys", () => {
test("enter numbers", () => {
expect(display("1 2 . 5")).toBe("12.5")
expect(display("0 0 7")).toBe("7")
expect(display(". 5")).toBe("0.5")
expect(display("1 . . 5")).toBe("1.5")
})
test("compute × and ÷ before + and -", () => {
expect(tap("2 + 2 =")).toEqual({calc: expect.objectContaining({display: "4"}), logLines: ["2 + 2 = 4"]})
expect(tap("2 + 3 × 4 =").logLines).toEqual(["3 × 4 = 12\n2 + 12 = 14"])
expect(tap("2 × 3 + 4 =").logLines).toEqual(["2 × 3 = 6", "6 + 4 = 10"])
expect(display("1 0 - 2 × 3 =")).toBe("4")
expect(display("1 0 - 2 - 3 =")).toBe("5")
expect(display("8 ÷ 4 ÷ 2 =")).toBe("1")
})
test("show the operand of the pressed operator", () => {
expect(display("2 + 3 ×")).toBe("3")
expect(display("2 × 3 +")).toBe("6")
expect(display("2 + 3 × 4 ×")).toBe("12")
expect(display("2 + 3 × 4 +")).toBe("14")
})
test("log each computed operation", () => {
expect(tap("1 5 + 1 0 + 5 + 1 =").logLines).toEqual(["15 + 10 = 25", "25 + 5 = 30", "30 + 1 = 31"])
expect(tap("2 + 3 × 4 × 5 +").logLines).toEqual(["3 × 4 = 12", "12 × 5 = 60\n2 + 60 = 62"])
expect(tap("2 + 3 × 4").logLines).toEqual([])
expect(tap("5 =").logLines).toEqual([])
})
test("replace operator", () => {
expect(tap("2 + × 3 =").logLines).toEqual(["2 × 3 = 6"])
expect(tap("2 + 3 × + 4 =").logLines).toEqual(["2 + 3 = 5", "5 + 4 = 9"])
expect(tap("2 × 3 + × 4 =").logLines).toEqual(["2 × 3 = 6", "6 × 4 = 24"])
})
test("continue from result", () => {
expect(tap("2 + 2 = + 3 =").logLines).toEqual(["2 + 2 = 4", "4 + 3 = 7"])
expect(display("2 + 2 = 5")).toBe("5")
})
test("percent", () => {
expect(display("1 0 0 + 1 5 %")).toBe("15")
expect(tap("1 0 0 + 1 5 % =").logLines).toEqual(["100 + 15% = 115"])
expect(display("1 0 0 - 1 5 %")).toBe("15")
expect(display("1 0 0 - 1 5 % =")).toBe("85")
expect(display("5 0 × 1 0 %")).toBe("0.1")
expect(display("5 0 × 1 0 % =")).toBe("5")
expect(display("5 0 ÷ 1 0 %")).toBe("0.1")
expect(display("5 0 ÷ 1 0 % =")).toBe("500")
expect(display("1 5 %")).toBe("0.15")
expect(display("1 0 0 + 2 × 1 5 %")).toBe("0.15")
expect(tap("1 0 0 + 2 × 1 5 % =").logLines).toEqual(["2 × 15% = 0.3\n100 + 0.3 = 100.3"])
expect(display("2 × 3 + 1 0 %")).toBe("0.6")
})
test("square root and sign", () => {
expect(display("9 √")).toBe("3")
expect(tap("1 + 9 √ =").logLines).toEqual(["1 + √9 = 4"])
expect(display("5 ±")).toBe("-5")
expect(display("5 ± ±")).toBe("5")
expect(display("± 5")).toBe("-5")
expect(display("2 + 2 = ±")).toBe("-4")
})
test("clear entry, then clear all", () => {
expect(tap("2 + 3 C 4 =").logLines).toEqual(["2 + 4 = 6"])
expect(tap("2 + 3 C C").calc).toEqual(initialCalc)
})
test("display without exponent", () => {
expect(display("0 . 1 + 0 . 2 =")).toBe("0.3")
expect(display("1 ÷ 3 =")).toBe("0.33333333333333")
expect(display("1 ÷ 1 0 0 0 0 0 0 0 =")).toBe("0.0000001")
expect(display("1 ÷ 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 =")).toBe("0.00000000000001")
expect(display(". 0 0 0 0 0 0 0 1 × . 0 0 0 0 0 0 0 1 =")).toBe("0")
})
test("limit digits and overflow", () => {
expect(display("1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7")).toBe("123456789012345")
expect(display("9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 + 1 =")).toBe("Error")
})
test("errors", () => {
expect(display("1 ÷ 0 =")).toBe("Error")
expect(display("2 ± √")).toBe("Error")
expect(display("1 ÷ 0 = 5")).toBe("5")
expect(display("1 ÷ 0 + 2 =")).toBe("Error")
expect(tap("1 ÷ 0 = C").calc).toEqual(initialCalc)
})
})
describe("typed messages", () => {
test("numbers and keys", () => {
expect(type("25", "+", "25", "=")).toEqual({calc: expect.objectContaining({display: "50"}), logLines: ["25 + 25 = 50"]})
expect(type("25", "add", "25", "eq").logLines).toEqual(["25 + 25 = 50"])
expect(type("6", "x", "7", "=").logLines).toEqual(["6 × 7 = 42"])
expect(type("5", "*", "5", "/", "2", "=").logLines).toEqual(["5 × 5 = 25", "25 ÷ 2 = 12.5"])
expect(type("25", "+", "3", "c", "4", "=").logLines).toEqual(["25 + 4 = 29"])
})
test("mixed with taps", () => {
expect(run([{text: "25"}, {tap: "+"}, {text: "25"}, {tap: "="}]).logLines).toEqual(["25 + 25 = 50"])
})
test("expressions are entered as a number", () => {
const result = (text: string) => type(text).calc.display
expect(result("2 + 2")).toBe("4")
expect(result("12 × 3 + 4")).toBe("40")
expect(result("12 * 3 + 4 =")).toBe("40")
expect(result("8 / 2")).toBe("4")
expect(result("3x3")).toBe("9")
expect(result("-5")).toBe("-5")
expect(result("2 × -3")).toBe("-6")
expect(result("100 + 15%")).toBe("115")
expect(result("42")).toBe("42")
expect(result("2 + 3 × 4")).toBe("14")
expect(result("(2 + 3) × 4")).toBe("20")
expect(result("2 × (3 + 4)")).toBe("14")
expect(result("((1 + 2) × 3)")).toBe("9")
expect(result("-(2 + 3)")).toBe("-5")
expect(result("100 + (10 + 5)%")).toBe("115")
expect(type("10", "×", "2 + 3", "=").logLines).toEqual(["10 × 5 = 50"])
expect(type("10", "+", "25=").calc.display).toBe("35")
})
test("result only for expressions", () => {
const result = (text: string) => textInput(text)?.result
expect(result("(2 + 3) × 4")).toBe("20")
expect(result("(2 + 3) × 4 =")).toBe("20")
expect(result("15%")).toBe("0.15")
expect(result("1 / 0")).toBe("Error")
expect(result("25")).toBeUndefined()
expect(result("-5")).toBeUndefined()
expect(result("25=")).toBeUndefined()
expect(result("+")).toBeUndefined()
})
test("overflow in typed numbers", () => {
expect(type("1234567890123456").calc.display).toBe("Error")
expect(type("123456789012345").calc.display).toBe("123456789012345")
})
test("reject other text", () => {
expect(textInput("hello")).toBeUndefined()
expect(textInput("2 ^ 3")).toBeUndefined()
expect(textInput("2.3.4")).toBeUndefined()
expect(textInput("(2 + 3")).toBeUndefined()
expect(textInput("2 + 3)")).toBeUndefined()
expect(textInput("()")).toBeUndefined()
expect(textInput("2(3)")).toBeUndefined()
expect(textInput("2 +")).toBeUndefined()
})
})
describe("calculator text", () => {
const nbsp = String.fromCharCode(0xa0)
test("symbol keys", () => {
expect(calculatorText(initialCalc, true)).toBe([
"*0*",
"/C /± /% /÷",
"/7 /8 /9 /×",
"/4 /5 /6 /-",
"/1 /2 /3 /+",
"/√ /0 /. /=",
].join("\n"))
})
test("switched off: keys without number", () => {
const [displayLine, ...rows] = calculatorText(undefined, true).split("\n")
expect(displayLine).toBe(`*${nbsp}*`)
expect(rows).toEqual(calculatorText(initialCalc, true).split("\n").slice(1))
})
test("word keys padded to equal width", () => {
const pad = (n: number) => `\`${nbsp.repeat(n)}\``
const [displayLine, firstRow] = calculatorText(initialCalc, false).split("\n")
expect(displayLine).toBe("*0*")
expect(firstRow).toBe(`/C ${pad(4)}/neg ${pad(2)}/pct ${pad(2)}/div ${pad(2)}`)
})
})
@@ -0,0 +1,74 @@
import {afterAll, beforeAll, test, expect} from "vitest"
import {mkdtempSync, rmSync} from "fs"
import {tmpdir} from "os"
import {join} from "path"
import {T} from "@simplex-chat/types"
import {api, bot, util} from "simplex-chat"
import {runCalculatorBot} from "./src/calculatorBot.js"
import {SmpServer, startSmpServer} from "./test/smpServer.js"
let smpServer: SmpServer
beforeAll(async () => { smpServer = await startSmpServer() }, 120000)
afterAll(() => smpServer?.stop())
async function useSmpServer(chat: api.ChatApi): Promise<void> {
expect(await chat.sendChatCmd(`/smp ${smpServer.address}`)).toMatchObject({type: "cmdOk"})
}
async function prepareBotDatabase(dbOpts: bot.BotDbOpts): Promise<void> {
const chat = await api.ChatApi.init(dbOpts)
await chat.apiCreateActiveUser({displayName: "SimpleX Calculator", fullName: ""})
await chat.startChat()
await useSmpServer(chat)
await chat.close()
}
type ItemCheck = (ci: T.AChatItem) => boolean
const calculatorShows = (display: string): ItemCheck => ci => ci.chatItem.meta.itemText.startsWith(`*${display}*\n`)
const hasText = (text: string): ItemCheck => ci => ci.chatItem.meta.itemText === text
const repliesTo = (quoted: string, text: string): ItemCheck => ({chatItem}) =>
chatItem.meta.itemText === text && chatItem.quotedItem?.content.text === quoted
test("calculator in business chat", async () => {
const dir = mkdtempSync(join(tmpdir(), "calculator-bot-"))
const botDbOpts: bot.BotDbOpts = {type: "sqlite", filePrefix: join(dir, "bot")}
await prepareBotDatabase(botDbOpts)
const [calculator, _botUser, address] = await runCalculatorBot(botDbOpts)
const alice = await api.ChatApi.init({type: "sqlite", filePrefix: join(dir, "alice")})
const aliceUser = await alice.apiCreateActiveUser({displayName: "alice", fullName: ""})
await alice.startChat()
await useSmpServer(alice)
const receives = (check: ItemCheck) => alice.wait("newChatItems", ({chatItems}) => chatItems.some(check), 30000)
const deletes = (text: string) =>
alice.wait("chatItemsDeleted", ({chatItemDeletions}) => chatItemDeletions.some(({deletedChatItem}) => hasText(text)(deletedChatItem)), 30000)
try {
const [_plan, link] = await alice.apiConnectPlan(aliceUser.userId, util.contactAddressStr(address!.connLinkContact))
const firstCalculator = receives(calculatorShows("0"))
await alice.apiConnect(aliceUser.userId, false, link)
const calculatorItem = (await firstCalculator)?.chatItems.find(calculatorShows("0"))
expect(calculatorItem?.chatInfo.type).toBe(T.ChatType.Group)
const groupId = (calculatorItem!.chatInfo as T.ChatInfo.Group).groupInfo.groupId
const send = async (texts: string[], ...events: Promise<unknown>[]) => {
for (const text of texts) await alice.apiSendTextMessage([T.ChatType.Group, groupId], text)
for (const event of events) expect(await event).toBeDefined()
}
await send(["/2", "/+", "/2", "/="], receives(hasText("2 + 2 = 4")), receives(calculatorShows("4")), deletes("/+"))
await send(["2 × (3 + 4) - 1"], receives(repliesTo("2 × (3 + 4) - 1", "13")), receives(calculatorShows("13")))
await send(
["15", "+", "10", "+", "5", "="],
receives(hasText("15 + 10 = 25")),
receives(hasText("25 + 5 = 30")),
receives(calculatorShows("30")),
deletes("+"),
)
} finally {
await alice.close()
await calculator.close()
rmSync(dir, {recursive: true, force: true})
}
}, 120000)
+22
View File
@@ -0,0 +1,22 @@
{
"name": "simplex-calculator-bot",
"version": "0.1.0",
"private": true,
"main": "dist/index.js",
"scripts": {
"build": "tsc",
"start": "node dist/index.js",
"test": "vitest run"
},
"dependencies": {
"@simplex-chat/types": "^0.12.0",
"simplex-chat": "^7.1.0-beta.4.2"
},
"devDependencies": {
"@types/node": "^22.0.0",
"typescript": "^5.9.3",
"vitest": "^1.6.1"
},
"author": "SimpleX Chat",
"license": "AGPL-3.0"
}
@@ -0,0 +1,221 @@
type Digit = "0" | "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9"
type Operator = "+" | "-" | "×" | "÷"
export type Key = Digit | Operator | "C" | "±" | "%" | "√" | "." | "="
export type Update = (calc: Calc) => [Calc, string?]
export interface Calc {
display: string
operand: string
terms: Term[]
mode: "typing" | "result" | "operator"
}
interface Operand {
text: string
value: number
}
interface Term extends Operand {
op: Operator
}
export const initialCalc: Calc = {display: "0", operand: "0", terms: [], mode: "typing"}
const keypad: Key[][] = [
["C", "±", "%", "÷"],
["7", "8", "9", "×"],
["4", "5", "6", "-"],
["1", "2", "3", "+"],
["√", "0", ".", "="],
]
const keyWords: Partial<Record<Key, string>> = {
"±": "neg",
"%": "pct",
"÷": "div",
"×": "mul",
"-": "sub",
"+": "add",
"√": "sqrt",
".": "dot",
"=": "eq",
}
function keyWord(key: Key): string {
return keyWords[key] ?? key
}
const operatorNames = new Map<string, Operator>([
["+", "+"], ["-", "-"], ["−", "-"], ["×", "×"], ["x", "×"], ["*", "×"], ["÷", "÷"], ["/", "÷"],
])
export const keyNames = new Map<string, Key>([
...keypad.flat().flatMap((key): [string, Key][] => [[key, key], [keyWord(key), key]]),
...operatorNames,
["c", "C"],
])
const maxDigits = 15
const errorDisplay = "Error"
export function press(calc: Calc, key: Key): [Calc, string?] {
switch (key) {
case "C": return [clear(calc)]
case "=": return equals(calc)
case "+": case "-": case "×": case "÷": return operator(calc, key)
case "%": return [showNumber(calc, percent(calc), `${calc.operand}%`)]
case "√": return [showNumber(calc, Math.sqrt(value(calc)), `√${calc.operand}`)]
case "±": return [negate(calc)]
default: return [enter(calc, key)]
}
}
function clear(calc: Calc): Calc {
if (calc.display === "0" || calc.display === errorDisplay) return initialCalc
return {...calc, display: "0", operand: "0", mode: "typing"}
}
function enter(calc: Calc, key: Digit | "."): Calc {
const display = append(calc.mode === "typing" ? calc.display : "0", key)
return {...calc, display, operand: display, mode: "typing"}
}
function append(display: string, key: Digit | "."): string {
if (key === ".") return display.includes(".") ? display : `${display}.`
if (display.replace(/\D/g, "").length >= maxDigits) return display
return display.replace(/^(-?)0$/, "$1") + key
}
function negate(calc: Calc): Calc {
if (calc.mode !== "typing") return showNumber(calc, -value(calc))
const display = calc.display.startsWith("-") ? calc.display.slice(1) : `-${calc.display}`
return {...calc, display, operand: display}
}
function percent(calc: Calc): number {
const last = calc.terms.at(-1)
const share = value(calc) / 100
return last && isAdditive(last.op) ? last.value * share : share
}
function showNumber(calc: Calc, n: number, operand?: string): Calc {
const display = format(n)
return {...calc, display, operand: operand ?? display, mode: "result"}
}
function operator(calc: Calc, op: Operator): [Calc, string?] {
const terms = calc.mode === "operator" ? calc.terms.slice(0, -1) : calc.terms
const reduced = reduce(terms, {text: calc.operand, value: value(calc)}, precedence(op))
const display = format(reduced.right.value)
const logLine = reduced.lines.length > 0 ? reduced.lines.join("\n") : undefined
return [{display, operand: display, terms: [...reduced.terms, {...reduced.right, op}], mode: "operator"}, logLine]
}
function equals(calc: Calc): [Calc, string?] {
if (calc.terms.length === 0) return [calc]
const {right, lines} = reduce(calc.terms, {text: calc.operand, value: value(calc)}, 0)
return [{display: right.text, operand: right.text, terms: [], mode: "result"}, lines.join("\n")]
}
function reduce(terms: Term[], right: Operand, minPrecedence: number): {terms: Term[], right: Operand, lines: string[]} {
const left = terms.at(-1)
if (!left || precedence(left.op) < minPrecedence) return {terms, right, lines: []}
const text = format(apply(left.value, left.op, right.value))
const reduced = reduce(terms.slice(0, -1), {text, value: Number(text)}, minPrecedence)
return {...reduced, lines: [`${left.text} ${left.op} ${right.text} = ${text}`, ...reduced.lines]}
}
function precedence(op: Operator): number {
return isAdditive(op) ? 1 : 2
}
function isAdditive(op: Operator): boolean {
return op === "+" || op === "-"
}
function apply(a: number, op: Operator, b: number): number {
switch (op) {
case "+": return a + b
case "-": return a - b
case "×": return a * b
case "÷": return a / b
}
}
function value(calc: Calc): number {
return Number(calc.display)
}
function format(n: number): string {
const rounded = Number(n.toPrecision(maxDigits))
if (!Number.isFinite(rounded) || Math.abs(rounded) >= 10 ** maxDigits) return errorDisplay
const integerDigits = Math.trunc(Math.abs(rounded)).toString().length
const fixed = rounded.toFixed(maxDigits - integerDigits)
const trimmed = fixed.includes(".") ? fixed.replace(/\.?0+$/, "") : fixed
return trimmed === "-0" ? "0" : trimmed
}
export function textInput(text: string): {update: Update, result?: string} | undefined {
const input = text.replace(/\s/g, "")
const entry = input.replace(/=$/, "")
const n = expressionValue(entry)
if (n !== undefined) {
const update: Update = calc => {
const entered = showNumber(calc, n)
return input.endsWith("=") ? press(entered, "=") : [entered]
}
return {update, result: Number.isNaN(Number(entry)) ? format(n) : undefined}
}
const key = keyNames.get(input.toLowerCase())
return key ? {update: calc => press(calc, key)} : undefined
}
const tokenPattern = /\d+(?:\.\d*)?|\.\d+|[-+−×x*÷\/%()]/g
interface Parsed {
value: number
rest: string[]
}
function expressionValue(text: string): number | undefined {
const tokens = text.match(tokenPattern) ?? []
const parsed = tokens.join("") === text ? expression(tokens, initialCalc) : undefined
return parsed?.rest.length === 0 ? parsed.value : undefined
}
function expression(tokens: string[], calc: Calc): Parsed | undefined {
const operand = term(tokens)
if (!operand) return undefined
const percent = operand.rest[0] === "%"
const shown = showNumber(calc, operand.value)
const entered = percent ? press(shown, "%")[0] : shown
const rest = percent ? operand.rest.slice(1) : operand.rest
const op = operatorNames.get(rest[0])
return op ? expression(rest.slice(1), press(entered, op)[0]) : {value: value(press(entered, "=")[0]), rest}
}
function term([first, ...rest]: string[]): Parsed | undefined {
if (first === "(") {
const inner = expression(rest, initialCalc)
return inner?.rest[0] === ")" ? {value: inner.value, rest: inner.rest.slice(1)} : undefined
}
if (operatorNames.get(first) === "-") {
const operand = term(rest)
return operand && {value: -operand.value, rest: operand.rest}
}
const n = Number(first)
return Number.isNaN(n) ? undefined : {value: n, rest}
}
const nbsp = String.fromCharCode(0xa0)
const wordWidth = Math.max(...keypad.flat().map(key => keyWord(key).length))
export function calculatorText(calc: Calc | undefined, symbolKeys: boolean): string {
const rows = keypad.map(row => symbolKeys ? row.map(key => `/${key}`).join(" ") : row.map(wordKey).join(""))
return [`*${calc?.display ?? nbsp}*`, ...rows].join("\n")
}
function wordKey(key: Key): string {
const word = keyWord(key)
return `/${word} \`${nbsp.repeat(wordWidth + 1 - word.length)}\``
}
@@ -0,0 +1,100 @@
import {T} from "@simplex-chat/types"
import {api, bot, util} from "simplex-chat"
import {Calc, Update, calculatorText, initialCalc, keyNames, press, textInput} from "./calculator.js"
import {calculatorIcon} from "./icon.js"
const anyTextCommandsVersion = 21
const idleMinutes = 10
const welcomeMessage = `Tap the keys or send numbers, keys like + and =, or expressions like (2 + 3) × 4.
The calculator turns off after ${idleMinutes} minutes; any key turns it on.`
const hint = "Send a number, a key like + or =, or an expression like (2 + 3) × 4."
interface Session {
calc?: Calc
itemId: number
timer?: NodeJS.Timeout
}
interface Sender {
groupId: number
member: T.GroupMember
}
const sessions = new Map<number, Session>()
function groupSender({chatInfo, chatItem}: T.AChatItem): Sender | undefined {
return chatInfo.type === "group" && chatItem.chatDir.type === "groupRcv"
? {groupId: chatInfo.groupInfo.groupId, member: chatItem.chatDir.groupMember}
: undefined
}
async function showCalculator(chat: api.ChatApi, {groupId, member}: Sender, calc: Calc): Promise<void> {
const symbolKeys = member.memberChatVRange.maxVersion >= anyTextCommandsVersion
const [sent] = await chat.apiSendTextMessage([T.ChatType.Group, groupId], calculatorText(calc, symbolKeys))
const itemId = sent.chatItem.meta.itemId
const previous = sessions.get(groupId)
const timer = setTimeout(() => turnOff(chat, groupId, itemId, symbolKeys), idleMinutes * 60_000)
sessions.set(groupId, {calc, itemId, timer})
if (previous) {
clearTimeout(previous.timer)
await chat.apiDeleteChatItems(T.ChatType.Group, groupId, [previous.itemId], T.CIDeleteMode.Broadcast)
}
}
function turnOff(chat: api.ChatApi, groupId: number, itemId: number, symbolKeys: boolean): void {
sessions.set(groupId, {itemId})
chat.apiUpdateChatItem(T.ChatType.Group, groupId, itemId, {type: "text", text: calculatorText(undefined, symbolKeys)}, false)
.catch(e => console.log("error turning calculator off", e))
}
async function updateCalculator(chat: api.ChatApi, sender: Sender, update: Update): Promise<void> {
const [calc, logLine] = update(sessions.get(sender.groupId)?.calc ?? initialCalc)
if (logLine) await chat.apiSendTextMessage([T.ChatType.Group, sender.groupId], logLine)
await showCalculator(chat, sender, calc)
}
function tapCommand(update: Update) {
return async (ci: T.AChatItem, _command: util.BotCommand, chat: api.ChatApi): Promise<void> => {
const sender = groupSender(ci)
if (!sender) return
await chat.apiDeleteMemberChatItem(sender.groupId, [ci.chatItem.meta.itemId])
await updateCalculator(chat, sender, update)
}
}
const keyCommands = Object.fromEntries([...keyNames].map(([name, key]) => [name, tapCommand(calc => press(calc, key))]))
async function onMessage(ci: T.AChatItem, content: T.MsgContent, chat: api.ChatApi): Promise<void> {
const sender = groupSender(ci)
if (!sender || content.type !== "text") return
const input = textInput(content.text)
if (!input) {
await chat.apiSendTextReply(ci, hint)
return
}
if (input.result) await chat.apiSendTextReply(ci, input.result)
else await chat.apiDeleteMemberChatItem(sender.groupId, [ci.chatItem.meta.itemId])
await updateCalculator(chat, sender, input.update)
}
export function runCalculatorBot(dbOpts: bot.BotDbOpts, simplexDomain?: string): Promise<[api.ChatApi, T.User, T.UserContactLink | undefined]> {
return bot.run({
profile: {displayName: "SimpleX Calculator", fullName: "", image: calculatorIcon, preferences: {fullDelete: {allow: T.FeatureAllowed.Yes}}},
simplexDomain,
dbOpts,
options: {
addressSettings: {businessAddress: true, welcomeMessage},
commands: [{type: "command", keyword: "calc", label: "Show calculator"}],
},
onMessage,
onCommands: {
...keyCommands,
calc: tapCommand(calc => [calc]),
"": async (ci, _command, chat) => { await chat.apiSendTextReply(ci, hint) },
},
events: {
joinedGroupMember: ({groupInfo, member}, chat) => showCalculator(chat, {groupId: groupInfo.groupId, member}, initialCalc),
},
})
}
File diff suppressed because one or more lines are too long
+12
View File
@@ -0,0 +1,12 @@
import {mkdirSync} from "fs"
import {parseArgs} from "util"
import {runCalculatorBot} from "./calculatorBot.js"
const {values: {domain}} = parseArgs({options: {domain: {type: "string"}}})
mkdirSync("data", {recursive: true})
runCalculatorBot({type: "sqlite", filePrefix: "./data/calculator"}, domain).catch(e => {
console.log("fatal error", e)
process.exit(1)
})
@@ -0,0 +1,101 @@
import {ChildProcess, spawn} from "child_process"
import {createHash} from "crypto"
import {once} from "events"
import {copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, renameSync, rmSync, writeFileSync} from "fs"
import {AddressInfo, createConnection, createServer} from "net"
import {tmpdir} from "os"
import {dirname, join} from "path"
const tlsFixtures = join(__dirname, "../../../tests/fixtures/tls")
const smpServerRelease = "v7.1.0-beta.3"
const releaseArch: Record<string, string | undefined> = {x64: "x86-64", arm64: "aarch64"}
export interface SmpServer {
address: string
stop: () => Promise<void>
}
export async function startSmpServer(): Promise<SmpServer> {
const port = await freePort()
const dir = mkdtempSync(join(tmpdir(), "smp-server-"))
const configDir = join(dir, "config")
mkdirSync(configDir)
for (const file of ["ca.crt", "server.crt", "server.key"]) copyFileSync(join(tlsFixtures, file), join(configDir, file))
const fingerprint = caFingerprint(join(configDir, "ca.crt"))
writeFileSync(join(configDir, "fingerprint"), `${fingerprint}\n`)
writeFileSync(join(configDir, "smp-server.ini"), `[STORE_LOG]\nenable: off\n\n[TRANSPORT]\nhost: localhost\nport: ${port}\nwebsockets: off\n`)
const server = spawn(await smpServerExecutable(), ["start"], {
env: {...process.env, SMP_SERVER_CFG_PATH: configDir, SMP_SERVER_LOG_PATH: join(dir, "logs")},
stdio: ["ignore", "ignore", "inherit"],
})
const stop = async () => {
if (server.exitCode === null) {
const exited = once(server, "exit")
server.kill()
await exited
}
rmSync(dir, {recursive: true, force: true})
}
try {
await once(server, "spawn")
await waitForServer(server, port)
} catch (e) {
await stop()
throw e
}
return {address: `smp://${fingerprint}@localhost:${port}`, stop}
}
async function smpServerExecutable(): Promise<string> {
if (process.env.SMP_SERVER) return process.env.SMP_SERVER
const path = join(__dirname, "../node_modules/.cache/smp-server", `smp-server-${smpServerRelease}`)
if (!existsSync(path)) await downloadSmpServer(path)
return path
}
async function downloadSmpServer(path: string): Promise<void> {
const arch = releaseArch[process.arch]
if (process.platform !== "linux" || !arch) {
throw new Error(`smp-server release binaries are not available for ${process.platform} ${process.arch}, set SMP_SERVER to the smp-server path`)
}
const url = `https://github.com/simplex-chat/simplexmq/releases/download/${smpServerRelease}/smp-server-ubuntu-22_04-${arch}`
const response = await fetch(url)
if (!response.ok) throw new Error(`error downloading ${url}: ${response.status}`)
mkdirSync(dirname(path), {recursive: true})
writeFileSync(`${path}.download`, Buffer.from(await response.arrayBuffer()), {mode: 0o755})
renameSync(`${path}.download`, path)
}
function caFingerprint(caFile: string): string {
const der = Buffer.from(readFileSync(caFile, "utf-8").replace(/-----[^-]+-----|\s/g, ""), "base64")
return createHash("sha256").update(der).digest("base64").replace(/\+/g, "-").replace(/\//g, "_")
}
function freePort(): Promise<number> {
return new Promise((resolve, reject) => {
const probe = createServer()
probe.on("error", reject)
probe.listen(0, "127.0.0.1", () => {
const {port} = probe.address() as AddressInfo
probe.close(() => resolve(port))
})
})
}
async function waitForServer(server: ChildProcess, port: number): Promise<void> {
const deadline = Date.now() + 15_000
while (!(await canConnect(port))) {
if (server.exitCode !== null || Date.now() > deadline) throw new Error(`smp-server did not start on port ${port}`)
await new Promise(resolve => setTimeout(resolve, 100))
}
}
function canConnect(port: number): Promise<boolean> {
return new Promise(resolve => {
const socket = createConnection({host: "localhost", port}, () => {
socket.destroy()
resolve(true)
})
socket.on("error", () => resolve(false))
})
}
+23
View File
@@ -0,0 +1,23 @@
{
"include": ["src"],
"compilerOptions": {
"declaration": true,
"forceConsistentCasingInFileNames": true,
"lib": ["ES2022"],
"module": "Node16",
"moduleResolution": "Node16",
"noFallthroughCasesInSwitch": true,
"noImplicitAny": true,
"noImplicitReturns": true,
"noImplicitThis": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noEmitOnError": true,
"outDir": "dist",
"sourceMap": true,
"strict": true,
"strictNullChecks": true,
"target": "ES2022",
"types": ["node"]
}
}