This commit is contained in:
Evgeny @ SimpleX Chat
2026-09-25 09:33:05 +00:00
parent de3488b779
commit 099be349ab
11 changed files with 97 additions and 122 deletions
+16 -39
View File
@@ -1,48 +1,29 @@
# SimpleX Calculator
A pocket calculator in a chat, built with the [SimpleX Chat Node.js library](../../packages/simplex-chat-nodejs/).
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 gets their own chat with a calculator message. The keys are bot commands: tapping a key sends it to the bot, and the bot replaces the calculator message with the updated one.
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.
## How it works
- 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 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
```bash
cd apps/simplex-calculator-bot
npm install
npm run build
```
The bot requires a `simplex-chat` library version that passes the chat API to event handlers (7.1.0-beta.4.1 or later). To run against the in-tree library:
```bash
# In packages/simplex-chat-nodejs
npm link
# In apps/simplex-calculator-bot
npm link simplex-chat
```
- Keys are applied left to right, so `2 + 3 × 4 =` equals 20.
- `%` 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, expressions like `12 × 3 + 4`, which are entered as one number, and keys like `+`, `=`, `add` or `c`.
- The bot deletes tapped keys and sends each calculation after `=`.
- 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 on start. The database is stored in `./data`.
The bot prints its address and keeps its data in `./data`.
To use the library from this repository, build it and run `npm install --no-save ../../packages/simplex-chat-nodejs` instead of `npm install`.
## Test
@@ -50,8 +31,4 @@ The bot prints its address on start. The database is stored in `./data`.
npm test
```
The end-to-end test runs a local SMP server with the TLS certificates from `tests/fixtures/tls`. On Linux, the test downloads `smp-server` from [simplexmq releases](https://github.com/simplex-chat/simplexmq/releases) to `node_modules/.cache` on the first run. To use another build, or on other systems, pass its path in `SMP_SERVER` variable:
```bash
SMP_SERVER=/path/to/smp-server 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`.
@@ -134,6 +134,8 @@ describe("typed messages", () => {
})
describe("calculator text", () => {
const nbsp = String.fromCharCode(0xa0)
test("symbol keys", () => {
expect(calculatorText(initialCalc, true)).toBe([
"*0*",
@@ -147,12 +149,12 @@ describe("calculator text", () => {
test("switched off: keys without number", () => {
const [displayLine, ...rows] = calculatorText(undefined, true).split("\n")
expect(displayLine).toBe("* *")
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) => `\`${" ".repeat(n)}\``
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)}`)
@@ -2,7 +2,7 @@ import {afterAll, beforeAll, test, expect} from "vitest"
import {mkdtempSync, rmSync} from "fs"
import {tmpdir} from "os"
import {join} from "path"
import {CEvt, T} from "@simplex-chat/types"
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"
@@ -25,12 +25,11 @@ async function prepareBotDatabase(dbOpts: bot.BotDbOpts): Promise<void> {
await chat.close()
}
const isCalculator = (display: string) => (ci: T.AChatItem) => ci.chatItem.meta.itemText.startsWith(`*${display}*\n`)
type ItemCheck = (ci: T.AChatItem) => boolean
const calculatorShows = (display: string) => ({chatItems}: CEvt.NewChatItems) => chatItems.some(isCalculator(display))
const calculatorShows = (display: string): ItemCheck => ci => ci.chatItem.meta.itemText.startsWith(`*${display}*\n`)
const hasText = (text: string) => ({chatItems}: CEvt.NewChatItems) =>
chatItems.some(ci => ci.chatItem.meta.itemText === text)
const hasText = (text: string): ItemCheck => ci => ci.chatItem.meta.itemText === text
test("calculator in business chat", async () => {
const dir = mkdtempSync(join(tmpdir(), "calculator-bot-"))
@@ -41,29 +40,22 @@ test("calculator in business chat", async () => {
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)
try {
const [_plan, link] = await alice.apiConnectPlan(aliceUser.userId, util.contactAddressStr(address!.connLinkContact))
const firstCalculator = alice.wait("newChatItems", calculatorShows("0"), 30000)
const firstCalculator = receives(calculatorShows("0"))
await alice.apiConnect(aliceUser.userId, false, link)
const calculatorItem = (await firstCalculator)?.chatItems.find(isCalculator("0"))
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 logLine = alice.wait("newChatItems", hasText("2 + 2 = 4"), 30000)
const four = alice.wait("newChatItems", calculatorShows("4"), 30000)
for (const key of ["/2", "/+", "/2", "/="]) await alice.apiSendTextMessage([T.ChatType.Group, groupId], key)
expect(await logLine).toBeDefined()
expect(await four).toBeDefined()
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()
const send = async (texts: string[], ...checks: ItemCheck[]) => {
const received = checks.map(receives)
for (const text of texts) await alice.apiSendTextMessage([T.ChatType.Group, groupId], text)
for (const event of received) expect(await event).toBeDefined()
}
await send(["/2", "/+", "/2", "/="], hasText("2 + 2 = 4"), calculatorShows("4"))
await send(["12 × 3 + 4"], calculatorShows("40"))
await send(["25", "+", "25", "="], hasText("25 + 25 = 50"), calculatorShows("50"))
} finally {
await alice.close()
await calculator.close()
+20 -18
View File
@@ -1,5 +1,5 @@
export type Digit = "0" | "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9"
export type Operator = "+" | "-" | "×" | "÷"
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?]
@@ -13,7 +13,7 @@ export interface Calc {
export const initialCalc: Calc = {display: "0", operand: "0", terms: [], mode: "typing"}
export const keypad: Key[][] = [
const keypad: Key[][] = [
["C", "±", "%", "÷"],
["7", "8", "9", "×"],
["4", "5", "6", "-"],
@@ -33,7 +33,7 @@ const keyWords: Partial<Record<Key, string>> = {
"=": "eq",
}
export function keyWord(key: Key): string {
function keyWord(key: Key): string {
return keyWords[key] ?? key
}
@@ -49,18 +49,23 @@ const errorDisplay = "Error"
export function press(calc: Calc, key: Key): [Calc, string?] {
switch (key) {
case "C": return [calc.display === "0" || calc.display === errorDisplay ? initialCalc : {...calc, display: "0", operand: "0", mode: "typing"}]
case "C": return [clear(calc)]
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 [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 = calc.mode === "typing" ? append(calc.display, key) : key === "." ? "0." : key
const display = append(calc.mode === "typing" ? calc.display : "0", key)
return {...calc, display, operand: display, mode: "typing"}
}
@@ -71,7 +76,7 @@ function append(display: string, key: Digit | "."): string {
}
function negate(calc: Calc): Calc {
if (calc.mode !== "typing") return enterNumber(calc, -value(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}
}
@@ -82,12 +87,9 @@ 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"}
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 {
@@ -132,7 +134,7 @@ export function textInput(text: string): Update | undefined {
const n = expressionValue(input.replace(/=$/, ""))
if (n !== undefined) {
return calc => {
const entered = enterNumber(calc, n)
const entered = showNumber(calc, n)
return input.endsWith("=") ? press(entered, "=") : [entered]
}
}
@@ -150,13 +152,13 @@ function expressionValue(expression: string): number | undefined {
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))
const entered = showNumber(withOperator, sign ? -Number(number) : Number(number))
return percentSign ? press(entered, "%")[0] : entered
}, initialCalc)
return value(press(calc, "=")[0])
}
const nbsp = " "
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 {
@@ -6,13 +6,14 @@ import {calculatorIcon} from "./icon.js"
const anyTextCommandsVersion = 21
const idleMinutes = 10
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 welcomeMessage = `Tap the keys or send numbers, keys like + and =, or expressions like 12 × 3 + 4.
Keys are applied left to right, as on a pocket calculator.
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 12 × 3 + 4."
interface Session {
calc?: Calc
itemId: number
symbolKeys: boolean
timer?: NodeJS.Timeout
}
@@ -29,17 +30,13 @@ function groupSender({chatInfo, chatItem}: T.AChatItem): Sender | undefined {
: undefined
}
function currentCalc(groupId: number): Calc {
return sessions.get(groupId)?.calc ?? initialCalc
}
async function showCalculator(chat: api.ChatApi, groupId: number, member: T.GroupMember, calc: Calc): Promise<void> {
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, symbolKeys, timer})
sessions.set(groupId, {calc, itemId, timer})
if (previous) {
clearTimeout(previous.timer)
await chat.apiDeleteChatItems(T.ChatType.Group, groupId, [previous.itemId], T.CIDeleteMode.Broadcast)
@@ -47,15 +44,15 @@ async function showCalculator(chat: api.ChatApi, groupId: number, member: T.Grou
}
function turnOff(chat: api.ChatApi, groupId: number, itemId: number, symbolKeys: boolean): void {
sessions.set(groupId, {itemId, symbolKeys})
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, {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)
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) {
@@ -92,7 +89,7 @@ export function runCalculatorBot(dbOpts: bot.BotDbOpts): Promise<[api.ChatApi, T
"": async (ci, _command, chat) => { await chat.apiSendTextReply(ci, hint) },
},
events: {
joinedGroupMember: ({groupInfo, member}, chat) => showCalculator(chat, groupInfo.groupId, member, initialCalc),
joinedGroupMember: ({groupInfo, member}, chat) => showCalculator(chat, {groupId: groupInfo.groupId, member}, initialCalc),
},
})
}
+18 -18
View File
@@ -28,20 +28,22 @@ export async function startSmpServer(): Promise<SmpServer> {
env: {...process.env, SMP_SERVER_CFG_PATH: configDir, SMP_SERVER_LOG_PATH: join(dir, "logs")},
stdio: ["ignore", "ignore", "inherit"],
})
let spawnError: Error | undefined
server.on("error", e => { spawnError = e })
await waitForServer(server, port, () => spawnError)
return {
address: `smp://${fingerprint}@localhost:${port}`,
stop: async () => {
if (server.exitCode === null) {
const exited = once(server, "exit")
server.kill()
await exited
}
rmSync(dir, {recursive: true, force: true})
},
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> {
@@ -54,7 +56,7 @@ async function smpServerExecutable(): Promise<string> {
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 only available for Linux, set SMP_SERVER to the smp-server executable")
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)
@@ -80,12 +82,10 @@ function freePort(): Promise<number> {
})
}
async function waitForServer(server: ChildProcess, port: number, spawnError: () => Error | undefined): Promise<void> {
async function waitForServer(server: ChildProcess, port: number): Promise<void> {
const deadline = Date.now() + 15_000
while (!(await canConnect(port))) {
if (server.pid === undefined || server.exitCode !== null || Date.now() > deadline) {
throw new Error(`smp-server did not start on port ${port}: ${spawnError()?.message ?? "no error"}`)
}
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))
}
}
+1 -1
View File
@@ -62,7 +62,7 @@ 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 complete bot using business chats, command keys and message updates: [SimpleX Calculator](../../apps/simplex-calculator-bot/).
A larger example, with a business address and commands as keys: [SimpleX Calculator](../../apps/simplex-calculator-bot/).
## PostgreSQL backend
@@ -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`\>
-3
View File
@@ -407,10 +407,7 @@ textWithCommands = describe "text with commands" do
"send /'filter 1'." <==> "send " <> command "filter 1" "/'filter 1'" <> "."
"send /'filter 1.'!" <==> "send " <> command "filter 1." "/'filter 1.'" <> "!"
"send /he?lp" <==> "send " <> command "he?lp" "/he?lp"
"/+" <==> command "+" "/+"
"/-" <==> command "-" "/-"
"/." <==> command "." "/."
"/√ /÷" <==> command "√" "/√" <> " " <> command "÷" "/÷"
"send /+." <==> "send " <> command "+" "/+" <> "."
"/'+'" <==> command "+" "/'+'"
it "calculator keys" do