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:
EvgenyandEvgeny @ SimpleX Chat authored and GitHub committed 2026-09-27 17:20:40 +01:00
1 parent 4cb244441b
commit 643a9afad6
35 files changed
+1075 -49

No files matched your search

@@ -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),
},
})
}
+1
View File
@@ -0,0 +1 @@
export const calculatorIcon = "data:image/jpg;base64,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 truncated
+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)
})