This commit is contained in:
Evgeny @ SimpleX Chat
2026-09-25 06:45:25 +00:00
parent c6dd1f2152
commit c02d9f21e6
5 changed files with 158 additions and 66 deletions
+6 -2
View File
@@ -8,10 +8,14 @@ Each user who connects via the bot's business address gets their own chat with a
- Keys are applied left to right, without operator priority: `2 + 3 × 4 =` gives 20.
- `%` follows Apple's calculator: `100 + 15 %` shows 15, and `=` gives 115; `50 × 10 %` shows 0.1, and `=` gives 5.
- `C` clears the current number; the second `C` clears the whole calculation.
- The display shows up to 15 digits, without exponent; larger results show `Error`.
- After `=`, the bot sends the calculation as a message, e.g. `2 + 2 = 4`.
- Tapped keys are deleted from the chat; the calculation messages stay as a log.
- Users can also send a number or an expression, e.g. `12 × 3 + 4`; the calculator shows the result.
- The calculator turns off after 10 minutes without use. Tap `/calc` or send an expression to turn it on.
- Users can also send messages instead of tapping keys:
- a number or an expression, e.g. `12 × 3 + 4`, is computed and entered as the current number, so `25`, `/add`, `25`, `=` gives 50.
- a key, e.g. `+`, `=`, `x`, `*`, `/`, `add` or `c`, works as the tapped key.
- The calculator turns off after 10 minutes without use: the number is removed from the display, the keys stay. Any key, number or `/calc` turns it on.
- Apps that support commands made of symbols (chat protocol version 21) get keys like `/+`; older apps get keys like `/add`.
## Install & build
+60 -20
View File
@@ -1,19 +1,21 @@
import {describe, test, expect} from "vitest"
import {Calc, Key, calculatorText, evaluate, initialCalc, press, textKeys} from "./src/calculator.js"
import {Calc, Key, calculatorText, initialCalc, press, textInput} from "./src/calculator.js"
function tap(keys: string): {calc: Calc, logLines: string[]} {
type Input = {tap: string} | {text: string}
function run(inputs: Input[]): {calc: Calc, logLines: string[]} {
const logLines: string[] = []
const calc = (keys.split(" ") as Key[]).reduce((current, key) => {
const [next, logLine] = press(current, key)
const calc = inputs.reduce((current, input) => {
const [next, logLine] = "tap" in input ? press(current, input.tap as Key) : textInput(input.text)!(current)
if (logLine) logLines.push(logLine)
return next
}, initialCalc)
return {calc, logLines}
}
function display(keys: string): string {
return tap(keys).calc.display
}
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", () => {
@@ -60,42 +62,74 @@ describe("keys", () => {
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("rounding", () => {
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.333333333333")
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("2")
})
test("clear", () => {
expect(tap("2 + 3 C").calc).toEqual(initialCalc)
expect(display("1 ÷ 0 + 2 =")).toBe("Error")
expect(tap("1 ÷ 0 = C").calc).toEqual(initialCalc)
})
})
describe("typed expressions", () => {
test("evaluate", () => {
const result = (text: string) => evaluate(textKeys(text)!).display
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 ÷ 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(type("10", "×", "2 + 3", "=").logLines).toEqual(["10 × 5 = 50"])
expect(type("10", "+", "25=").calc.display).toBe("35")
})
test("overflow in typed numbers", () => {
expect(type("1234567890123456").calc.display).toBe("Error")
expect(type("123456789012345").calc.display).toBe("123456789012345")
})
test("reject other text", () => {
expect(textKeys("hello")).toBeUndefined()
expect(textKeys("+")).toBeUndefined()
expect(textKeys("2 ^ 3")).toBeUndefined()
expect(textInput("hello")).toBeUndefined()
expect(textInput("2 ^ 3")).toBeUndefined()
expect(textInput("2.3.4")).toBeUndefined()
})
})
@@ -111,6 +145,12 @@ describe("calculator text", () => {
].join("\n"))
})
test("switched off: keys without number", () => {
const [displayLine, ...rows] = calculatorText(undefined, true).split("\n")
expect(displayLine).toBe("* *")
expect(rows).toEqual(calculatorText(initialCalc, true).split("\n").slice(1))
})
test("word keys padded to equal width", () => {
const pad = (n: number) => `\`${" ".repeat(n)}\``
const [displayLine, firstRow] = calculatorText(initialCalc, false).split("\n")
@@ -58,6 +58,12 @@ test("calculator in business chat", async () => {
const forty = alice.wait("newChatItems", calculatorShows("40"), 30000)
await alice.apiSendTextMessage([T.ChatType.Group, groupId], "12 × 3 + 4")
expect(await forty).toBeDefined()
const typedLogLine = alice.wait("newChatItems", hasText("25 + 25 = 50"), 30000)
const fifty = alice.wait("newChatItems", calculatorShows("50"), 30000)
for (const text of ["25", "+", "25", "="]) await alice.apiSendTextMessage([T.ChatType.Group, groupId], text)
expect(await typedLogLine).toBeDefined()
expect(await fifty).toBeDefined()
} finally {
await alice.close()
await calculator.close()
+56 -23
View File
@@ -1,6 +1,7 @@
export type Digit = "0" | "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9"
export type Operator = "+" | "-" | "×" | "÷"
export type Key = Digit | Operator | "C" | "±" | "%" | "√" | "." | "="
export type Update = (calc: Calc) => [Calc, string?]
export interface Calc {
display: string
@@ -10,7 +11,7 @@ export interface Calc {
mode: "typing" | "result" | "operator"
}
export const initialCalc: Calc = {display: "0", operand: "0", terms: [], mode: "result"}
export const initialCalc: Calc = {display: "0", operand: "0", terms: [], mode: "typing"}
export const keypad: Key[][] = [
["C", "±", "%", "÷"],
@@ -36,19 +37,25 @@ export function keyWord(key: Key): string {
return keyWords[key] ?? key
}
const maxDigits = 12
const keyAliases: Record<string, Key> = {"c": "C", "*": "×", "x": "×", "/": "÷", "−": "-"}
export const keyNames = new Map<string, Key>([
...keypad.flat().flatMap((key): [string, Key][] => [[key, key], [keyWord(key), key]]),
...Object.entries(keyAliases),
])
const maxDigits = 15
const errorDisplay = "Error"
export function press(calc: Calc, key: Key): [Calc, string?] {
const current = calc.display === errorDisplay ? initialCalc : calc
switch (key) {
case "C": return [initialCalc]
case "=": return equals(current)
case "+": case "-": case "×": case "÷": return [operator(current, key)]
case "%": return [result(current, percent(current), `${current.operand}%`)]
case "√": return [result(current, Math.sqrt(value(current)), `√${current.operand}`)]
case "±": return [negate(current)]
default: return [enter(current, key)]
case "C": return [calc.display === "0" || calc.display === errorDisplay ? initialCalc : {...calc, display: "0", operand: "0", mode: "typing"}]
case "=": return equals(calc)
case "+": case "-": case "×": case "÷": return [operator(calc, key)]
case "%": return [result(calc, percent(calc), `${calc.operand}%`)]
case "√": return [result(calc, Math.sqrt(value(calc)), `√${calc.operand}`)]
case "±": return [negate(calc)]
default: return [enter(calc, key)]
}
}
@@ -64,8 +71,9 @@ function append(display: string, key: Digit | "."): string {
}
function negate(calc: Calc): Calc {
if (calc.mode !== "typing") return enterNumber(calc, -value(calc))
const display = calc.display.startsWith("-") ? calc.display.slice(1) : `-${calc.display}`
return {...calc, display, operand: display, mode: calc.mode === "typing" ? "typing" : "result"}
return {...calc, display, operand: display}
}
function percent(calc: Calc): number {
@@ -74,6 +82,10 @@ function percent(calc: Calc): number {
return pending && (pending.op === "+" || pending.op === "-") ? pending.acc * b / 100 : b / 100
}
function enterNumber(calc: Calc, n: number): Calc {
return result(calc, n, format(n))
}
function result(calc: Calc, n: number, operand: string): Calc {
return {...calc, display: format(n), operand, mode: "result"}
}
@@ -107,28 +119,49 @@ function value(calc: Calc): number {
}
function format(n: number): string {
return Number.isFinite(n) ? String(Number(n.toPrecision(maxDigits))) : errorDisplay
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
}
const typedKeys: Record<string, Key> = {"*": "×", "x": "×", "/": "÷", "−": "-"}
export function textKeys(text: string): Key[] | undefined {
const expression = text.replace(/\s/g, "").replace(/=$/, "")
if (!/^[\d.+\-−×x*÷\/%]+$/.test(expression) || !/\d/.test(expression)) return undefined
return [...expression].map(c => typedKeys[c] ?? c as Key)
export function textInput(text: string): Update | undefined {
const input = text.replace(/\s/g, "")
const n = expressionValue(input.replace(/=$/, ""))
if (n !== undefined) {
return calc => {
const entered = enterNumber(calc, n)
return input.endsWith("=") ? press(entered, "=") : [entered]
}
}
const key = keyNames.get(input.toLowerCase())
return key ? calc => press(calc, key) : undefined
}
export function evaluate(keys: Key[]): Calc {
const allKeys: Key[] = [...keys, "="]
return allKeys.reduce((calc, key) => press(calc, key)[0], initialCalc)
const termPattern = /([-+−×x*÷\/]?)([-−]?)(\d+(?:\.\d*)?|\.\d+)(%?)/g
function expressionValue(expression: string): number | undefined {
const terms = [...expression.matchAll(termPattern)]
const valid = terms.length > 0
&& terms.map(([term]) => term).join("") === expression
&& terms.every(([, op], i) => i === 0 || op !== "")
if (!valid) return undefined
const calc = terms.reduce((current, [, op, sign, number, percentSign]) => {
const withOperator = op ? press(current, keyAliases[op] ?? op as Key)[0] : current
const entered = enterNumber(withOperator, sign ? -Number(number) : Number(number))
return percentSign ? press(entered, "%")[0] : entered
}, initialCalc)
return value(press(calc, "=")[0])
}
const nbsp = " "
const wordWidth = Math.max(...keypad.flat().map(key => keyWord(key).length))
export function calculatorText(calc: Calc, symbolKeys: boolean): string {
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}*`, ...rows].join("\n")
return [`*${calc?.display ?? nbsp}*`, ...rows].join("\n")
}
function wordKey(key: Key): string {
@@ -1,23 +1,29 @@
import {T} from "@simplex-chat/types"
import {api, bot, util} from "simplex-chat"
import {Calc, calculatorText, evaluate, initialCalc, keyWord, keypad, press, textKeys} from "./calculator.js"
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 an expression like 12 × 3 + 4.\nKeys are applied left to right, as on a pocket calculator.\nThe calculator turns off after ${idleMinutes} minutes.`
const offText = "*Off*\nTap /calc or send an expression."
const hint = "Send a number or an expression like 12 × 3 + 4."
const welcomeMessage = `Tap the keys, or send numbers, keys like + or =, and expressions like 12 × 3 + 4.\nKeys are applied left to right, as on a pocket calculator.\nThe calculator turns off after ${idleMinutes} minutes, any key turns it on.`
const hint = "Send a number, a key like + or =, or an expression like 12 × 3 + 4."
interface Session {
calc: Calc
calc?: Calc
itemId: number
timer: NodeJS.Timeout
symbolKeys: boolean
timer?: NodeJS.Timeout
}
interface Sender {
groupId: number
member: T.GroupMember
}
const sessions = new Map<number, Session>()
function groupSender({chatInfo, chatItem}: T.AChatItem): {groupId: number, member: T.GroupMember} | undefined {
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
@@ -32,45 +38,48 @@ async function showCalculator(chat: api.ChatApi, groupId: number, member: T.Grou
const [sent] = await chat.apiSendTextMessage([T.ChatType.Group, groupId], calculatorText(calc, symbolKeys))
const itemId = sent.chatItem.meta.itemId
const previous = sessions.get(groupId)
sessions.set(groupId, {calc, itemId, timer: setTimeout(() => turnOff(chat, groupId, itemId), idleMinutes * 60_000)})
const timer = setTimeout(() => turnOff(chat, groupId, itemId, symbolKeys), idleMinutes * 60_000)
sessions.set(groupId, {calc, itemId, symbolKeys, 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): void {
sessions.delete(groupId)
chat.apiUpdateChatItem(T.ChatType.Group, groupId, itemId, {type: "text", text: offText}, false)
function turnOff(chat: api.ChatApi, groupId: number, itemId: number, symbolKeys: boolean): void {
sessions.set(groupId, {itemId, symbolKeys})
chat.apiUpdateChatItem(T.ChatType.Group, groupId, itemId, {type: "text", text: calculatorText(undefined, symbolKeys)}, false)
.catch(e => console.log("error turning calculator off", e))
}
function tapCommand(update: (calc: Calc) => [Calc, string?]) {
async function updateCalculator(chat: api.ChatApi, {groupId, member}: Sender, update: Update): Promise<void> {
const [calc, logLine] = update(currentCalc(groupId))
if (logLine) await chat.apiSendTextMessage([T.ChatType.Group, groupId], logLine)
await showCalculator(chat, groupId, member, 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])
const [calc, logLine] = update(currentCalc(sender.groupId))
if (logLine) await chat.apiSendTextMessage([T.ChatType.Group, sender.groupId], logLine)
await showCalculator(chat, sender.groupId, sender.member, calc)
await updateCalculator(chat, sender, update)
}
}
const keyCommands = Object.fromEntries(
keypad.flat().flatMap(key => [key, keyWord(key)].map(keyword => [keyword, tapCommand(calc => press(calc, key))]))
)
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 keys = textKeys(content.text)
if (keys) await showCalculator(chat, sender.groupId, sender.member, evaluate(keys))
const update = textInput(content.text)
if (update) await updateCalculator(chat, sender, update)
else await chat.apiSendTextReply(ci, hint)
}
export function runCalculatorBot(dbOpts: bot.BotDbOpts): Promise<[api.ChatApi, T.User, T.UserContactLink | undefined]> {
return bot.run({
profile: {displayName: "SimpleX Calculator", fullName: "", preferences: {fullDelete: {allow: T.FeatureAllowed.Yes}}},
profile: {displayName: "SimpleX Calculator", fullName: "", image: calculatorIcon, preferences: {fullDelete: {allow: T.FeatureAllowed.Yes}}},
dbOpts,
options: {
addressSettings: {businessAddress: true, welcomeMessage},