From bd676996e11303845ef3313157bfd3f40f671cd6 Mon Sep 17 00:00:00 2001 From: "Evgeny @ SimpleX Chat" <259188159+evgeny-simplex@users.noreply.github.com> Date: Thu, 24 Sep 2026 21:05:11 +0000 Subject: [PATCH] refactor --- .../calculatorBot.test.ts | 44 +++++++++ .../src/calculatorBot.ts | 89 +++++++++++++++++++ apps/simplex-calculator-bot/src/index.ts | 88 +----------------- tests/MarkdownTests.hs | 5 ++ 4 files changed, 140 insertions(+), 86 deletions(-) create mode 100644 apps/simplex-calculator-bot/calculatorBot.test.ts create mode 100644 apps/simplex-calculator-bot/src/calculatorBot.ts diff --git a/apps/simplex-calculator-bot/calculatorBot.test.ts b/apps/simplex-calculator-bot/calculatorBot.test.ts new file mode 100644 index 0000000000..eb2afa85d7 --- /dev/null +++ b/apps/simplex-calculator-bot/calculatorBot.test.ts @@ -0,0 +1,44 @@ +import {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 {api, util} from "simplex-chat" +import {runCalculatorBot} from "./src/calculatorBot.js" + +const isCalculator = (display: string) => (ci: T.AChatItem) => ci.chatItem.meta.itemText.startsWith(`*${display}*\n`) + +const calculatorShows = (display: string) => ({chatItems}: CEvt.NewChatItems) => chatItems.some(isCalculator(display)) + +const hasText = (text: string) => ({chatItems}: CEvt.NewChatItems) => + chatItems.some(ci => ci.chatItem.meta.itemText === text) + +test("calculator in business chat (uses preset servers)", async () => { + const dir = mkdtempSync(join(tmpdir(), "calculator-bot-")) + const [calculator, _botUser, address] = await runCalculatorBot({type: "sqlite", filePrefix: join(dir, "bot")}) + const alice = await api.ChatApi.init({type: "sqlite", filePrefix: join(dir, "alice")}) + const aliceUser = await alice.apiCreateActiveUser({displayName: "alice", fullName: ""}) + await alice.startChat() + try { + const [_plan, link] = await alice.apiConnectPlan(aliceUser.userId, util.contactAddressStr(address!.connLinkContact)) + const firstCalculator = alice.wait("newChatItems", calculatorShows("0"), 30000) + await alice.apiConnect(aliceUser.userId, false, link) + const calculatorItem = (await firstCalculator)?.chatItems.find(isCalculator("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() + } finally { + await alice.close() + await calculator.close() + rmSync(dir, {recursive: true, force: true}) + } +}, 120000) diff --git a/apps/simplex-calculator-bot/src/calculatorBot.ts b/apps/simplex-calculator-bot/src/calculatorBot.ts new file mode 100644 index 0000000000..537362021a --- /dev/null +++ b/apps/simplex-calculator-bot/src/calculatorBot.ts @@ -0,0 +1,89 @@ +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" + +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." + +interface Session { + calc: Calc + itemId: number + timer: NodeJS.Timeout +} + +const sessions = new Map() + +function groupSender({chatInfo, chatItem}: T.AChatItem): {groupId: number, member: T.GroupMember} | undefined { + return chatInfo.type === "group" && chatItem.chatDir.type === "groupRcv" + ? {groupId: chatInfo.groupInfo.groupId, member: chatItem.chatDir.groupMember} + : 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 { + 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) + sessions.set(groupId, {calc, itemId, timer: setTimeout(() => turnOff(chat, groupId, itemId), idleMinutes * 60_000)}) + 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) + .catch(e => console.log("error turning calculator off", e)) +} + +function tapCommand(update: (calc: Calc) => [Calc, string?]) { + return async (ci: T.AChatItem, _command: util.BotCommand, chat: api.ChatApi): Promise => { + 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) + } +} + +const keyCommands = Object.fromEntries( + keypad.flat().flatMap(key => [key, keyWord(key)].map(keyword => [keyword, tapCommand(calc => press(calc, key))])) +) + +async function onMessage(ci: T.AChatItem, content: T.MsgContent, chat: api.ChatApi): Promise { + 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)) + 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}}}, + 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, groupInfo.groupId, member, initialCalc), + }, + }) +} diff --git a/apps/simplex-calculator-bot/src/index.ts b/apps/simplex-calculator-bot/src/index.ts index 6dbc952c84..db59877b22 100644 --- a/apps/simplex-calculator-bot/src/index.ts +++ b/apps/simplex-calculator-bot/src/index.ts @@ -1,93 +1,9 @@ import {mkdirSync} from "fs" -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" - -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." - -interface Session { - calc: Calc - itemId: number - timer: NodeJS.Timeout -} - -const sessions = new Map() - -function groupSender({chatInfo, chatItem}: T.AChatItem): {groupId: number, member: T.GroupMember} | undefined { - return chatInfo.type === "group" && chatItem.chatDir.type === "groupRcv" - ? {groupId: chatInfo.groupInfo.groupId, member: chatItem.chatDir.groupMember} - : 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 { - 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) - sessions.set(groupId, {calc, itemId, timer: setTimeout(() => turnOff(chat, groupId, itemId), idleMinutes * 60_000)}) - 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) - .catch(e => console.log("error turning calculator off", e)) -} - -function tapCommand(update: (calc: Calc) => [Calc, string?]) { - return async (ci: T.AChatItem, _command: util.BotCommand, chat: api.ChatApi): Promise => { - 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) - } -} - -const keyCommands = Object.fromEntries( - keypad.flat().flatMap(key => [key, keyWord(key)].map(keyword => [keyword, tapCommand(calc => press(calc, key))])) -) - -async function onMessage(ci: T.AChatItem, content: T.MsgContent, chat: api.ChatApi): Promise { - 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)) - else await chat.apiSendTextReply(ci, hint) -} +import {runCalculatorBot} from "./calculatorBot.js" mkdirSync("data", {recursive: true}) -bot.run({ - profile: {displayName: "SimpleX Calculator", fullName: "", preferences: {fullDelete: {allow: T.FeatureAllowed.Yes}}}, - dbOpts: {type: "sqlite", filePrefix: "./data/calculator"}, - 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, groupInfo.groupId, member, initialCalc), - }, -}).catch(e => { +runCalculatorBot({type: "sqlite", filePrefix: "./data/calculator"}).catch(e => { console.log("fatal error", e) process.exit(1) }) diff --git a/tests/MarkdownTests.hs b/tests/MarkdownTests.hs index 30e7b3d4f9..ffb1588c23 100644 --- a/tests/MarkdownTests.hs +++ b/tests/MarkdownTests.hs @@ -413,6 +413,10 @@ textWithCommands = describe "text with commands" do "/√ /÷" <==> command "√" "/√" <> " " <> command "÷" "/÷" "send /+." <==> "send " <> command "+" "/+" <> "." "/'+'" <==> command "+" "/'+'" + it "calculator keys" do + "/C /± /% /÷" <==> command "C" "/C" <> " " <> command "±" "/±" <> " " <> command "%" "/%" <> " " <> command "÷" "/÷" + "/√ /0 /. /=" <==> command "√" "/√" <> " " <> command "0" "/0" <> " " <> command "." "/." <> " " <> command "=" "/=" + "/C `\160\160\160\160`/neg `\160\160`" <==> command "C" "/C" <> " " <> markdown Snippet "\160\160\160\160" <> command "neg" "/neg" <> " " <> markdown Snippet "\160\160" it "ignored as markdown" $ do "send /'filter 1" <==> "send /'filter 1" "send /help /'filter 1" <==> "send " <> command "help" "/help" <> " /'filter 1" @@ -477,6 +481,7 @@ multilineMarkdownList = describe "multiline markdown" do it "command markdown" do "/link 1" <<==>> [command' "link 1" "/link 1"] " /link 1" <<==>> [command' "link 1" " /link 1"] + "*0*\n/7 /+" <<==>> [FormattedText (Just Bold) "0", "\n", command' "7" "/7", " ", command' "+" "/+"] testSanitizeUri :: Spec testSanitizeUri = describe "sanitizeUri" $ do