This commit is contained in:
Evgeny @ SimpleX Chat
2026-09-24 21:05:11 +00:00
parent 736942343d
commit bd676996e1
4 changed files with 140 additions and 86 deletions
@@ -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)
@@ -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<number, Session>()
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<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)
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<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)
}
}
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<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))
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),
},
})
}
+2 -86
View File
@@ -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<number, Session>()
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<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)
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<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)
}
}
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<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))
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)
})
+5
View File
@@ -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