mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-09-28 15:38:56 +00:00
simplify
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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},
|
||||
|
||||
Reference in New Issue
Block a user