From 643a9afad6a4214be8d60fe64bb5d5df2667cdcc Mon Sep 17 00:00:00 2001 From: Evgeny Date: Sun, 27 Sep 2026 17:20:40 +0100 Subject: [PATCH] bots: SimpleX Calculator demo bot (#7583) * bots: SimpleX Calculator demo bot * refactor * use local server in test * simplify * icon * simplify * expression * replies * improve * log * update name in bot address * @simplex-chat/type: 0.12.0 * simplex-chat nodejs: 7.1.0-beta.4.2 * update dependencies * option to add domain to bot address --------- Co-authored-by: Evgeny @ SimpleX Chat <259188159+evgeny-simplex@users.noreply.github.com> --- apps/simplex-calculator-bot/.gitignore | 2 + apps/simplex-calculator-bot/README.md | 36 +++ .../simplex-calculator-bot/calculator.test.ts | 202 ++++++++++++++++ .../calculatorBot.test.ts | 74 ++++++ apps/simplex-calculator-bot/package.json | 22 ++ apps/simplex-calculator-bot/src/calculator.ts | 221 ++++++++++++++++++ .../src/calculatorBot.ts | 100 ++++++++ apps/simplex-calculator-bot/src/icon.ts | 1 + apps/simplex-calculator-bot/src/index.ts | 12 + apps/simplex-calculator-bot/test/smpServer.ts | 101 ++++++++ apps/simplex-calculator-bot/tsconfig.json | 23 ++ bots/README.md | 2 +- bots/api/COMMANDS.md | 48 ++++ bots/src/API/Docs/Commands.hs | 9 +- .../types/typescript/package.json | 2 +- .../types/typescript/src/commands.ts | 15 ++ packages/simplex-chat-nodejs/README.md | 6 +- .../docs/api.TypeAlias.EventSubscriberFunc.md | 6 +- .../docs/bot.Function.subscribeChatItems.md | 2 +- .../docs/bot.Interface.BotConfig.md | 8 +- .../examples/squaring-bot-readme.js | 4 +- .../examples/squaring-bot.ts | 8 +- packages/simplex-chat-nodejs/package.json | 2 +- packages/simplex-chat-nodejs/src/api.ts | 19 +- packages/simplex-chat-nodejs/src/bot.ts | 42 +++- packages/simplex-chat-nodejs/src/util.ts | 4 +- .../tests/api.unit.test.ts | 18 +- .../tests/bot.unit.test.ts | 82 ++++++- .../src/simplex_chat/types/_commands.py | 13 ++ src/Simplex/Chat/Controller.hs | 2 +- src/Simplex/Chat/Library/Commands.hs | 5 +- src/Simplex/Chat/Markdown.hs | 10 +- src/Simplex/Chat/Protocol.hs | 5 +- tests/MarkdownTests.hs | 10 +- tests/ProtocolTests.hs | 8 +- 35 files changed, 1075 insertions(+), 49 deletions(-) create mode 100644 apps/simplex-calculator-bot/.gitignore create mode 100644 apps/simplex-calculator-bot/README.md create mode 100644 apps/simplex-calculator-bot/calculator.test.ts create mode 100644 apps/simplex-calculator-bot/calculatorBot.test.ts create mode 100644 apps/simplex-calculator-bot/package.json create mode 100644 apps/simplex-calculator-bot/src/calculator.ts create mode 100644 apps/simplex-calculator-bot/src/calculatorBot.ts create mode 100644 apps/simplex-calculator-bot/src/icon.ts create mode 100644 apps/simplex-calculator-bot/src/index.ts create mode 100644 apps/simplex-calculator-bot/test/smpServer.ts create mode 100644 apps/simplex-calculator-bot/tsconfig.json diff --git a/apps/simplex-calculator-bot/.gitignore b/apps/simplex-calculator-bot/.gitignore new file mode 100644 index 0000000000..902b28133e --- /dev/null +++ b/apps/simplex-calculator-bot/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +data/ diff --git a/apps/simplex-calculator-bot/README.md b/apps/simplex-calculator-bot/README.md new file mode 100644 index 0000000000..028e470964 --- /dev/null +++ b/apps/simplex-calculator-bot/README.md @@ -0,0 +1,36 @@ +# SimpleX Calculator + +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 receives a calculator message with keys as commands. The bot replaces this message after each input. + +- `×` and `÷` are applied before `+` and `-`, so `2 + 3 × 4 =` equals 14. +- `%` 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 and keys like `+`, `=`, `add` or `c`. Expressions like `(2 + 3) × 4` are entered as one number, and the bot replies to them with the result. +- The bot deletes keys and numbers, tapped or sent, and logs each computed operation, such as `15 + 10 = 25`. +- 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 and keeps its data in `./data`. + +To add a SimpleX name to the address, register the name with the address short link, and run `npm start -- --domain yourname.simplex`. The name is kept when the bot starts without `--domain`. + +To use the library from this repository, build it and run `npm install --no-save ../../packages/simplex-chat-nodejs` instead of `npm install`. + +## Test + +```bash +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`. diff --git a/apps/simplex-calculator-bot/calculator.test.ts b/apps/simplex-calculator-bot/calculator.test.ts new file mode 100644 index 0000000000..e7662666c4 --- /dev/null +++ b/apps/simplex-calculator-bot/calculator.test.ts @@ -0,0 +1,202 @@ +import {describe, test, expect} from "vitest" +import {Calc, Key, calculatorText, initialCalc, press, textInput} from "./src/calculator.js" + +type Input = {tap: string} | {text: string} + +function run(inputs: Input[]): {calc: Calc, logLines: string[]} { + const logLines: string[] = [] + const calc = inputs.reduce((current, input) => { + const [next, logLine] = "tap" in input ? press(current, input.tap as Key) : textInput(input.text)!.update(current) + if (logLine) logLines.push(logLine) + return next + }, initialCalc) + return {calc, logLines} +} + +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", () => { + expect(display("1 2 . 5")).toBe("12.5") + expect(display("0 0 7")).toBe("7") + expect(display(". 5")).toBe("0.5") + expect(display("1 . . 5")).toBe("1.5") + }) + + test("compute × and ÷ before + and -", () => { + expect(tap("2 + 2 =")).toEqual({calc: expect.objectContaining({display: "4"}), logLines: ["2 + 2 = 4"]}) + expect(tap("2 + 3 × 4 =").logLines).toEqual(["3 × 4 = 12\n2 + 12 = 14"]) + expect(tap("2 × 3 + 4 =").logLines).toEqual(["2 × 3 = 6", "6 + 4 = 10"]) + expect(display("1 0 - 2 × 3 =")).toBe("4") + expect(display("1 0 - 2 - 3 =")).toBe("5") + expect(display("8 ÷ 4 ÷ 2 =")).toBe("1") + }) + + test("show the operand of the pressed operator", () => { + expect(display("2 + 3 ×")).toBe("3") + expect(display("2 × 3 +")).toBe("6") + expect(display("2 + 3 × 4 ×")).toBe("12") + expect(display("2 + 3 × 4 +")).toBe("14") + }) + + test("log each computed operation", () => { + expect(tap("1 5 + 1 0 + 5 + 1 =").logLines).toEqual(["15 + 10 = 25", "25 + 5 = 30", "30 + 1 = 31"]) + expect(tap("2 + 3 × 4 × 5 +").logLines).toEqual(["3 × 4 = 12", "12 × 5 = 60\n2 + 60 = 62"]) + expect(tap("2 + 3 × 4").logLines).toEqual([]) + expect(tap("5 =").logLines).toEqual([]) + }) + + test("replace operator", () => { + expect(tap("2 + × 3 =").logLines).toEqual(["2 × 3 = 6"]) + expect(tap("2 + 3 × + 4 =").logLines).toEqual(["2 + 3 = 5", "5 + 4 = 9"]) + expect(tap("2 × 3 + × 4 =").logLines).toEqual(["2 × 3 = 6", "6 × 4 = 24"]) + }) + + test("continue from result", () => { + expect(tap("2 + 2 = + 3 =").logLines).toEqual(["2 + 2 = 4", "4 + 3 = 7"]) + expect(display("2 + 2 = 5")).toBe("5") + }) + + test("percent", () => { + expect(display("1 0 0 + 1 5 %")).toBe("15") + expect(tap("1 0 0 + 1 5 % =").logLines).toEqual(["100 + 15% = 115"]) + expect(display("1 0 0 - 1 5 %")).toBe("15") + expect(display("1 0 0 - 1 5 % =")).toBe("85") + expect(display("5 0 × 1 0 %")).toBe("0.1") + expect(display("5 0 × 1 0 % =")).toBe("5") + expect(display("5 0 ÷ 1 0 %")).toBe("0.1") + expect(display("5 0 ÷ 1 0 % =")).toBe("500") + expect(display("1 5 %")).toBe("0.15") + expect(display("1 0 0 + 2 × 1 5 %")).toBe("0.15") + expect(tap("1 0 0 + 2 × 1 5 % =").logLines).toEqual(["2 × 15% = 0.3\n100 + 0.3 = 100.3"]) + expect(display("2 × 3 + 1 0 %")).toBe("0.6") + }) + + test("square root and sign", () => { + expect(display("9 √")).toBe("3") + 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("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.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("Error") + expect(tap("1 ÷ 0 = C").calc).toEqual(initialCalc) + }) +}) + +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 = 25", "25 ÷ 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(result("2 + 3 × 4")).toBe("14") + expect(result("(2 + 3) × 4")).toBe("20") + expect(result("2 × (3 + 4)")).toBe("14") + expect(result("((1 + 2) × 3)")).toBe("9") + expect(result("-(2 + 3)")).toBe("-5") + expect(result("100 + (10 + 5)%")).toBe("115") + expect(type("10", "×", "2 + 3", "=").logLines).toEqual(["10 × 5 = 50"]) + expect(type("10", "+", "25=").calc.display).toBe("35") + }) + + test("result only for expressions", () => { + const result = (text: string) => textInput(text)?.result + expect(result("(2 + 3) × 4")).toBe("20") + expect(result("(2 + 3) × 4 =")).toBe("20") + expect(result("15%")).toBe("0.15") + expect(result("1 / 0")).toBe("Error") + expect(result("25")).toBeUndefined() + expect(result("-5")).toBeUndefined() + expect(result("25=")).toBeUndefined() + expect(result("+")).toBeUndefined() + }) + + test("overflow in typed numbers", () => { + expect(type("1234567890123456").calc.display).toBe("Error") + expect(type("123456789012345").calc.display).toBe("123456789012345") + }) + + test("reject other text", () => { + expect(textInput("hello")).toBeUndefined() + expect(textInput("2 ^ 3")).toBeUndefined() + expect(textInput("2.3.4")).toBeUndefined() + expect(textInput("(2 + 3")).toBeUndefined() + expect(textInput("2 + 3)")).toBeUndefined() + expect(textInput("()")).toBeUndefined() + expect(textInput("2(3)")).toBeUndefined() + expect(textInput("2 +")).toBeUndefined() + }) +}) + +describe("calculator text", () => { + const nbsp = String.fromCharCode(0xa0) + + test("symbol keys", () => { + expect(calculatorText(initialCalc, true)).toBe([ + "*0*", + "/C /± /% /÷", + "/7 /8 /9 /×", + "/4 /5 /6 /-", + "/1 /2 /3 /+", + "/√ /0 /. /=", + ].join("\n")) + }) + + test("switched off: keys without number", () => { + const [displayLine, ...rows] = calculatorText(undefined, true).split("\n") + 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) => `\`${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)}`) + }) +}) diff --git a/apps/simplex-calculator-bot/calculatorBot.test.ts b/apps/simplex-calculator-bot/calculatorBot.test.ts new file mode 100644 index 0000000000..85a40c0550 --- /dev/null +++ b/apps/simplex-calculator-bot/calculatorBot.test.ts @@ -0,0 +1,74 @@ +import {afterAll, beforeAll, test, expect} from "vitest" +import {mkdtempSync, rmSync} from "fs" +import {tmpdir} from "os" +import {join} from "path" +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" + +let smpServer: SmpServer + +beforeAll(async () => { smpServer = await startSmpServer() }, 120000) + +afterAll(() => smpServer?.stop()) + +async function useSmpServer(chat: api.ChatApi): Promise { + expect(await chat.sendChatCmd(`/smp ${smpServer.address}`)).toMatchObject({type: "cmdOk"}) +} + +async function prepareBotDatabase(dbOpts: bot.BotDbOpts): Promise { + const chat = await api.ChatApi.init(dbOpts) + await chat.apiCreateActiveUser({displayName: "SimpleX Calculator", fullName: ""}) + await chat.startChat() + await useSmpServer(chat) + await chat.close() +} + +type ItemCheck = (ci: T.AChatItem) => boolean + +const calculatorShows = (display: string): ItemCheck => ci => ci.chatItem.meta.itemText.startsWith(`*${display}*\n`) + +const hasText = (text: string): ItemCheck => ci => ci.chatItem.meta.itemText === text + +const repliesTo = (quoted: string, text: string): ItemCheck => ({chatItem}) => + chatItem.meta.itemText === text && chatItem.quotedItem?.content.text === quoted + +test("calculator in business chat", async () => { + const dir = mkdtempSync(join(tmpdir(), "calculator-bot-")) + const botDbOpts: bot.BotDbOpts = {type: "sqlite", filePrefix: join(dir, "bot")} + await prepareBotDatabase(botDbOpts) + const [calculator, _botUser, address] = await runCalculatorBot(botDbOpts) + const alice = await api.ChatApi.init({type: "sqlite", filePrefix: join(dir, "alice")}) + 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) + const deletes = (text: string) => + alice.wait("chatItemsDeleted", ({chatItemDeletions}) => chatItemDeletions.some(({deletedChatItem}) => hasText(text)(deletedChatItem)), 30000) + try { + const [_plan, link] = await alice.apiConnectPlan(aliceUser.userId, util.contactAddressStr(address!.connLinkContact)) + const firstCalculator = receives(calculatorShows("0")) + await alice.apiConnect(aliceUser.userId, false, link) + 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 send = async (texts: string[], ...events: Promise[]) => { + for (const text of texts) await alice.apiSendTextMessage([T.ChatType.Group, groupId], text) + for (const event of events) expect(await event).toBeDefined() + } + await send(["/2", "/+", "/2", "/="], receives(hasText("2 + 2 = 4")), receives(calculatorShows("4")), deletes("/+")) + await send(["2 × (3 + 4) - 1"], receives(repliesTo("2 × (3 + 4) - 1", "13")), receives(calculatorShows("13"))) + await send( + ["15", "+", "10", "+", "5", "="], + receives(hasText("15 + 10 = 25")), + receives(hasText("25 + 5 = 30")), + receives(calculatorShows("30")), + deletes("+"), + ) + } finally { + await alice.close() + await calculator.close() + rmSync(dir, {recursive: true, force: true}) + } +}, 120000) diff --git a/apps/simplex-calculator-bot/package.json b/apps/simplex-calculator-bot/package.json new file mode 100644 index 0000000000..262676ec3e --- /dev/null +++ b/apps/simplex-calculator-bot/package.json @@ -0,0 +1,22 @@ +{ + "name": "simplex-calculator-bot", + "version": "0.1.0", + "private": true, + "main": "dist/index.js", + "scripts": { + "build": "tsc", + "start": "node dist/index.js", + "test": "vitest run" + }, + "dependencies": { + "@simplex-chat/types": "^0.12.0", + "simplex-chat": "^7.1.0-beta.4.2" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "typescript": "^5.9.3", + "vitest": "^1.6.1" + }, + "author": "SimpleX Chat", + "license": "AGPL-3.0" +} diff --git a/apps/simplex-calculator-bot/src/calculator.ts b/apps/simplex-calculator-bot/src/calculator.ts new file mode 100644 index 0000000000..12e33159f3 --- /dev/null +++ b/apps/simplex-calculator-bot/src/calculator.ts @@ -0,0 +1,221 @@ +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?] + +export interface Calc { + display: string + operand: string + terms: Term[] + mode: "typing" | "result" | "operator" +} + +interface Operand { + text: string + value: number +} + +interface Term extends Operand { + op: Operator +} + +export const initialCalc: Calc = {display: "0", operand: "0", terms: [], mode: "typing"} + +const keypad: Key[][] = [ + ["C", "±", "%", "÷"], + ["7", "8", "9", "×"], + ["4", "5", "6", "-"], + ["1", "2", "3", "+"], + ["√", "0", ".", "="], +] + +const keyWords: Partial> = { + "±": "neg", + "%": "pct", + "÷": "div", + "×": "mul", + "-": "sub", + "+": "add", + "√": "sqrt", + ".": "dot", + "=": "eq", +} + +function keyWord(key: Key): string { + return keyWords[key] ?? key +} + +const operatorNames = new Map([ + ["+", "+"], ["-", "-"], ["−", "-"], ["×", "×"], ["x", "×"], ["*", "×"], ["÷", "÷"], ["/", "÷"], +]) + +export const keyNames = new Map([ + ...keypad.flat().flatMap((key): [string, Key][] => [[key, key], [keyWord(key), key]]), + ...operatorNames, + ["c", "C"], +]) + +const maxDigits = 15 +const errorDisplay = "Error" + +export function press(calc: Calc, key: Key): [Calc, string?] { + switch (key) { + case "C": return [clear(calc)] + case "=": return equals(calc) + case "+": case "-": case "×": case "÷": return operator(calc, key) + 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 = append(calc.mode === "typing" ? calc.display : "0", key) + return {...calc, display, operand: display, mode: "typing"} +} + +function append(display: string, key: Digit | "."): string { + if (key === ".") return display.includes(".") ? display : `${display}.` + if (display.replace(/\D/g, "").length >= maxDigits) return display + return display.replace(/^(-?)0$/, "$1") + key +} + +function negate(calc: Calc): 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} +} + +function percent(calc: Calc): number { + const last = calc.terms.at(-1) + const share = value(calc) / 100 + return last && isAdditive(last.op) ? last.value * share : share +} + +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, string?] { + const terms = calc.mode === "operator" ? calc.terms.slice(0, -1) : calc.terms + const reduced = reduce(terms, {text: calc.operand, value: value(calc)}, precedence(op)) + const display = format(reduced.right.value) + const logLine = reduced.lines.length > 0 ? reduced.lines.join("\n") : undefined + return [{display, operand: display, terms: [...reduced.terms, {...reduced.right, op}], mode: "operator"}, logLine] +} + +function equals(calc: Calc): [Calc, string?] { + if (calc.terms.length === 0) return [calc] + const {right, lines} = reduce(calc.terms, {text: calc.operand, value: value(calc)}, 0) + return [{display: right.text, operand: right.text, terms: [], mode: "result"}, lines.join("\n")] +} + +function reduce(terms: Term[], right: Operand, minPrecedence: number): {terms: Term[], right: Operand, lines: string[]} { + const left = terms.at(-1) + if (!left || precedence(left.op) < minPrecedence) return {terms, right, lines: []} + const text = format(apply(left.value, left.op, right.value)) + const reduced = reduce(terms.slice(0, -1), {text, value: Number(text)}, minPrecedence) + return {...reduced, lines: [`${left.text} ${left.op} ${right.text} = ${text}`, ...reduced.lines]} +} + +function precedence(op: Operator): number { + return isAdditive(op) ? 1 : 2 +} + +function isAdditive(op: Operator): boolean { + return op === "+" || op === "-" +} + +function apply(a: number, op: Operator, b: number): number { + switch (op) { + case "+": return a + b + case "-": return a - b + case "×": return a * b + case "÷": return a / b + } +} + +function value(calc: Calc): number { + return Number(calc.display) +} + +function format(n: number): string { + 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 +} + +export function textInput(text: string): {update: Update, result?: string} | undefined { + const input = text.replace(/\s/g, "") + const entry = input.replace(/=$/, "") + const n = expressionValue(entry) + if (n !== undefined) { + const update: Update = calc => { + const entered = showNumber(calc, n) + return input.endsWith("=") ? press(entered, "=") : [entered] + } + return {update, result: Number.isNaN(Number(entry)) ? format(n) : undefined} + } + const key = keyNames.get(input.toLowerCase()) + return key ? {update: calc => press(calc, key)} : undefined +} + +const tokenPattern = /\d+(?:\.\d*)?|\.\d+|[-+−×x*÷\/%()]/g + +interface Parsed { + value: number + rest: string[] +} + +function expressionValue(text: string): number | undefined { + const tokens = text.match(tokenPattern) ?? [] + const parsed = tokens.join("") === text ? expression(tokens, initialCalc) : undefined + return parsed?.rest.length === 0 ? parsed.value : undefined +} + +function expression(tokens: string[], calc: Calc): Parsed | undefined { + const operand = term(tokens) + if (!operand) return undefined + const percent = operand.rest[0] === "%" + const shown = showNumber(calc, operand.value) + const entered = percent ? press(shown, "%")[0] : shown + const rest = percent ? operand.rest.slice(1) : operand.rest + const op = operatorNames.get(rest[0]) + return op ? expression(rest.slice(1), press(entered, op)[0]) : {value: value(press(entered, "=")[0]), rest} +} + +function term([first, ...rest]: string[]): Parsed | undefined { + if (first === "(") { + const inner = expression(rest, initialCalc) + return inner?.rest[0] === ")" ? {value: inner.value, rest: inner.rest.slice(1)} : undefined + } + if (operatorNames.get(first) === "-") { + const operand = term(rest) + return operand && {value: -operand.value, rest: operand.rest} + } + const n = Number(first) + return Number.isNaN(n) ? undefined : {value: n, rest} +} + +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 { + const rows = keypad.map(row => symbolKeys ? row.map(key => `/${key}`).join(" ") : row.map(wordKey).join("")) + return [`*${calc?.display ?? nbsp}*`, ...rows].join("\n") +} + +function wordKey(key: Key): string { + const word = keyWord(key) + return `/${word} \`${nbsp.repeat(wordWidth + 1 - word.length)}\`` +} diff --git a/apps/simplex-calculator-bot/src/calculatorBot.ts b/apps/simplex-calculator-bot/src/calculatorBot.ts new file mode 100644 index 0000000000..85cd1b50d4 --- /dev/null +++ b/apps/simplex-calculator-bot/src/calculatorBot.ts @@ -0,0 +1,100 @@ +import {T} from "@simplex-chat/types" +import {api, bot, util} from "simplex-chat" +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 numbers, keys like + and =, or expressions like (2 + 3) × 4. +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 (2 + 3) × 4." + +interface Session { + calc?: Calc + itemId: number + timer?: NodeJS.Timeout +} + +interface Sender { + groupId: number + member: T.GroupMember +} + +const sessions = new Map() + +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 +} + +async function showCalculator(chat: api.ChatApi, {groupId, member}: Sender, 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) + const timer = setTimeout(() => turnOff(chat, groupId, itemId, symbolKeys), idleMinutes * 60_000) + sessions.set(groupId, {calc, itemId, 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, symbolKeys: boolean): void { + 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, sender: Sender, update: Update): Promise { + 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) { + 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]) + await updateCalculator(chat, sender, update) + } +} + +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 { + const sender = groupSender(ci) + if (!sender || content.type !== "text") return + const input = textInput(content.text) + if (!input) { + await chat.apiSendTextReply(ci, hint) + return + } + if (input.result) await chat.apiSendTextReply(ci, input.result) + else await chat.apiDeleteMemberChatItem(sender.groupId, [ci.chatItem.meta.itemId]) + await updateCalculator(chat, sender, input.update) +} + +export function runCalculatorBot(dbOpts: bot.BotDbOpts, simplexDomain?: string): Promise<[api.ChatApi, T.User, T.UserContactLink | undefined]> { + return bot.run({ + profile: {displayName: "SimpleX Calculator", fullName: "", image: calculatorIcon, preferences: {fullDelete: {allow: T.FeatureAllowed.Yes}}}, + simplexDomain, + 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, {groupId: groupInfo.groupId, member}, initialCalc), + }, + }) +} diff --git a/apps/simplex-calculator-bot/src/icon.ts b/apps/simplex-calculator-bot/src/icon.ts new file mode 100644 index 0000000000..80bb55a347 --- /dev/null +++ b/apps/simplex-calculator-bot/src/icon.ts @@ -0,0 +1 @@ +export const calculatorIcon = "data:image/jpg;base64,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" diff --git a/apps/simplex-calculator-bot/src/index.ts b/apps/simplex-calculator-bot/src/index.ts new file mode 100644 index 0000000000..5464e25ec8 --- /dev/null +++ b/apps/simplex-calculator-bot/src/index.ts @@ -0,0 +1,12 @@ +import {mkdirSync} from "fs" +import {parseArgs} from "util" +import {runCalculatorBot} from "./calculatorBot.js" + +const {values: {domain}} = parseArgs({options: {domain: {type: "string"}}}) + +mkdirSync("data", {recursive: true}) + +runCalculatorBot({type: "sqlite", filePrefix: "./data/calculator"}, domain).catch(e => { + console.log("fatal error", e) + process.exit(1) +}) diff --git a/apps/simplex-calculator-bot/test/smpServer.ts b/apps/simplex-calculator-bot/test/smpServer.ts new file mode 100644 index 0000000000..c7f0d87ec7 --- /dev/null +++ b/apps/simplex-calculator-bot/test/smpServer.ts @@ -0,0 +1,101 @@ +import {ChildProcess, spawn} from "child_process" +import {createHash} from "crypto" +import {once} from "events" +import {copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, renameSync, rmSync, writeFileSync} from "fs" +import {AddressInfo, createConnection, createServer} from "net" +import {tmpdir} from "os" +import {dirname, join} from "path" + +const tlsFixtures = join(__dirname, "../../../tests/fixtures/tls") +const smpServerRelease = "v7.1.0-beta.3" +const releaseArch: Record = {x64: "x86-64", arm64: "aarch64"} + +export interface SmpServer { + address: string + stop: () => Promise +} + +export async function startSmpServer(): Promise { + const port = await freePort() + const dir = mkdtempSync(join(tmpdir(), "smp-server-")) + const configDir = join(dir, "config") + mkdirSync(configDir) + for (const file of ["ca.crt", "server.crt", "server.key"]) copyFileSync(join(tlsFixtures, file), join(configDir, file)) + const fingerprint = caFingerprint(join(configDir, "ca.crt")) + writeFileSync(join(configDir, "fingerprint"), `${fingerprint}\n`) + writeFileSync(join(configDir, "smp-server.ini"), `[STORE_LOG]\nenable: off\n\n[TRANSPORT]\nhost: localhost\nport: ${port}\nwebsockets: off\n`) + const server = spawn(await smpServerExecutable(), ["start"], { + env: {...process.env, SMP_SERVER_CFG_PATH: configDir, SMP_SERVER_LOG_PATH: join(dir, "logs")}, + stdio: ["ignore", "ignore", "inherit"], + }) + 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 { + if (process.env.SMP_SERVER) return process.env.SMP_SERVER + const path = join(__dirname, "../node_modules/.cache/smp-server", `smp-server-${smpServerRelease}`) + if (!existsSync(path)) await downloadSmpServer(path) + return path +} + +async function downloadSmpServer(path: string): Promise { + const arch = releaseArch[process.arch] + if (process.platform !== "linux" || !arch) { + 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) + if (!response.ok) throw new Error(`error downloading ${url}: ${response.status}`) + mkdirSync(dirname(path), {recursive: true}) + writeFileSync(`${path}.download`, Buffer.from(await response.arrayBuffer()), {mode: 0o755}) + renameSync(`${path}.download`, path) +} + +function caFingerprint(caFile: string): string { + const der = Buffer.from(readFileSync(caFile, "utf-8").replace(/-----[^-]+-----|\s/g, ""), "base64") + return createHash("sha256").update(der).digest("base64").replace(/\+/g, "-").replace(/\//g, "_") +} + +function freePort(): Promise { + return new Promise((resolve, reject) => { + const probe = createServer() + probe.on("error", reject) + probe.listen(0, "127.0.0.1", () => { + const {port} = probe.address() as AddressInfo + probe.close(() => resolve(port)) + }) + }) +} + +async function waitForServer(server: ChildProcess, port: number): Promise { + const deadline = Date.now() + 15_000 + while (!(await canConnect(port))) { + 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)) + } +} + +function canConnect(port: number): Promise { + return new Promise(resolve => { + const socket = createConnection({host: "localhost", port}, () => { + socket.destroy() + resolve(true) + }) + socket.on("error", () => resolve(false)) + }) +} diff --git a/apps/simplex-calculator-bot/tsconfig.json b/apps/simplex-calculator-bot/tsconfig.json new file mode 100644 index 0000000000..821fa663e3 --- /dev/null +++ b/apps/simplex-calculator-bot/tsconfig.json @@ -0,0 +1,23 @@ +{ + "include": ["src"], + "compilerOptions": { + "declaration": true, + "forceConsistentCasingInFileNames": true, + "lib": ["ES2022"], + "module": "Node16", + "moduleResolution": "Node16", + "noFallthroughCasesInSwitch": true, + "noImplicitAny": true, + "noImplicitReturns": true, + "noImplicitThis": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noEmitOnError": true, + "outDir": "dist", + "sourceMap": true, + "strict": true, + "strictNullChecks": true, + "target": "ES2022", + "types": ["node"] + } +} diff --git a/bots/README.md b/bots/README.md index 80c6689dce..ee2760ad78 100644 --- a/bots/README.md +++ b/bots/README.md @@ -43,7 +43,7 @@ To distinguish SimpleX user profile as a bot, set its `peerType` property to `"b ### Configure bot commands -Bot commands are messages that start from `/` character. Normally, they would consist of lowercase latin letters, but commands can use any letters, digits and underscores. Commands can have parameters. +Bot commands are messages that start from `/` character. Normally, they would consist of lowercase latin letters, but commands can use any characters, including symbols such as `/+` or `/-`. Commands can have parameters. All commands in messages will be highlighted in the chats with the bot, and when users tap them, they will be instantly sent. If the message has a single line and starts from `/` character, the whole message will be highlighted. Otherwise, if command is included as part of the message, it will be highlighted until the first space after `/` character: e.g., `/list` command in Directory service shows user's groups. diff --git a/bots/api/COMMANDS.md b/bots/api/COMMANDS.md index f90e8e08da..af7b0146f7 100644 --- a/bots/api/COMMANDS.md +++ b/bots/api/COMMANDS.md @@ -7,6 +7,7 @@ This file is generated automatically. - [APIDeleteMyAddress](#apideletemyaddress) - [APIShowMyAddress](#apishowmyaddress) - [APISetProfileAddress](#apisetprofileaddress) +- [APISetUserDomain](#apisetuserdomain) - [APISetAddressSettings](#apisetaddresssettings) [Message commands](#message-commands) @@ -246,6 +247,53 @@ ChatCmdError: Command error (only used in WebSockets API). --- +### APISetUserDomain + +Set or remove SimpleX name of bot address. The name must be registered with the address short link. + +*Network usage*: interactive. + +**Parameters**: +- userId: int64 +- simplexDomain: string? + +**Syntax**: + +``` +/_set domain [ ] +``` + +```javascript +'/_set domain ' + userId + (simplexDomain ? ' ' + simplexDomain : '') // JavaScript +``` + +```python +'/_set domain ' + str(userId) + ((' ' + simplexDomain) if simplexDomain is not None else '') # Python +``` + +**Responses**: + +UserProfileUpdated: User profile updated. +- type: "userProfileUpdated" +- user: [User](./TYPES.md#user) +- fromProfile: [Profile](./TYPES.md#profile) +- toProfile: [Profile](./TYPES.md#profile) +- updateSummary: [UserProfileUpdateSummary](./TYPES.md#userprofileupdatesummary) + +UserProfileNoChange: User profile was not changed. +- type: "userProfileNoChange" +- user: [User](./TYPES.md#user) + +ChatCmdError: Command error (only used in WebSockets API). +- type: "chatCmdError" +- chatError: [ChatError](./TYPES.md#chaterror) + +**Errors**: +- SimplexDomainNotReady: The name does not resolve to the address short link. + +--- + + ### APISetAddressSettings Set bot address settings. diff --git a/bots/src/API/Docs/Commands.hs b/bots/src/API/Docs/Commands.hs index 4ffba7bfbe..79820605a8 100644 --- a/bots/src/API/Docs/Commands.hs +++ b/bots/src/API/Docs/Commands.hs @@ -81,6 +81,14 @@ chatCommandsDocsData = ("APIDeleteMyAddress", [], "Delete bot address.", ["CRUserContactLinkDeleted", "CRChatCmdError"], [], Just UNBackground, "/_delete_address " <> Param "userId"), ("APIShowMyAddress", [], "Get bot address and settings.", ["CRUserContactLink", "CRChatCmdError"], [], Nothing, "/_show_address " <> Param "userId"), ("APISetProfileAddress", [], "Add address to bot profile.", ["CRUserProfileUpdated", "CRUserProfileNoChange", "CRChatCmdError"], [], Just UNInteractive, "/_profile_address " <> Param "userId" <> " " <> OnOff "enable"), + ( "APISetUserDomain", + [], + "Set or remove SimpleX name of bot address. The name must be registered with the address short link.", + ["CRUserProfileUpdated", "CRUserProfileNoChange", "CRChatCmdError"], + [TD "CESimplexDomainNotReady" "The name does not resolve to the address short link"], + Just UNInteractive, + "/_set domain " <> Param "userId" <> Optional "" (" " <> Param "$0") "simplexDomain" + ), ("APISetAddressSettings", [], "Set bot address settings.", ["CRUserContactLinkUpdated", "CRChatCmdError"], [], Just UNInteractive, "/_address_settings " <> Param "userId" <> OnOffParam "pq_ratchet" "pqRatchet" Nothing <> " " <> Json "settings") ] ), @@ -438,7 +446,6 @@ undocumentedCommands = "APISetServerOperators", "APISetUserContactReceipts", "APISetUserGroupReceipts", - "APISetUserDomain", "APISetUserServers", "APISetUserUIThemes", "APIStandaloneFileInfo", diff --git a/packages/simplex-chat-client/types/typescript/package.json b/packages/simplex-chat-client/types/typescript/package.json index 4d353cbdae..24ea58b66b 100644 --- a/packages/simplex-chat-client/types/typescript/package.json +++ b/packages/simplex-chat-client/types/typescript/package.json @@ -1,6 +1,6 @@ { "name": "@simplex-chat/types", - "version": "0.11.4", + "version": "0.12.0", "description": "TypeScript types for SimpleX Chat bot libraries", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/packages/simplex-chat-client/types/typescript/src/commands.ts b/packages/simplex-chat-client/types/typescript/src/commands.ts index c217ef944c..cc01ae9472 100644 --- a/packages/simplex-chat-client/types/typescript/src/commands.ts +++ b/packages/simplex-chat-client/types/typescript/src/commands.ts @@ -66,6 +66,21 @@ export namespace APISetProfileAddress { } } +// Set or remove SimpleX name of bot address. The name must be registered with the address short link. +// Network usage: interactive. +export interface APISetUserDomain { + userId: number // int64 + simplexDomain?: string +} + +export namespace APISetUserDomain { + export type Response = CR.UserProfileUpdated | CR.UserProfileNoChange | CR.ChatCmdError + + export function cmdString(self: APISetUserDomain): string { + return '/_set domain ' + self.userId + (self.simplexDomain ? ' ' + self.simplexDomain : '') + } +} + // Set bot address settings. // Network usage: interactive. export interface APISetAddressSettings { diff --git a/packages/simplex-chat-nodejs/README.md b/packages/simplex-chat-nodejs/README.md index 739b41b34e..05c99eca5c 100644 --- a/packages/simplex-chat-nodejs/README.md +++ b/packages/simplex-chat-nodejs/README.md @@ -24,13 +24,13 @@ Simple bot that replies with squares of numbers you send to it: const {bot} = await import("simplex-chat") // if you are running from this GitHub repo: // const {bot} = await import("../dist/index.js") - const [chat, _user, _address] = await bot.run({ + await bot.run({ profile: {displayName: "Squaring bot example", fullName: ""}, dbOpts: {type: "sqlite", filePrefix: "./squaring_bot"}, options: { addressSettings: {welcomeMessage: "Send a number, I will square it.", }, - onMessage: async (ci, content) => { + onMessage: async (ci, content, chat) => { const n = +content.text const reply = typeof n === "number" && !isNaN(n) ? `${n} * ${n} = ${n * n}` @@ -62,6 +62,8 @@ 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 larger example, with a business address and commands as keys: [SimpleX Calculator](../../apps/simplex-calculator-bot/). + ## PostgreSQL backend By default, the package uses SQLite. To use PostgreSQL instead: diff --git a/packages/simplex-chat-nodejs/docs/api.TypeAlias.EventSubscriberFunc.md b/packages/simplex-chat-nodejs/docs/api.TypeAlias.EventSubscriberFunc.md index bf40b3165c..13efcec7dd 100644 --- a/packages/simplex-chat-nodejs/docs/api.TypeAlias.EventSubscriberFunc.md +++ b/packages/simplex-chat-nodejs/docs/api.TypeAlias.EventSubscriberFunc.md @@ -6,7 +6,7 @@ # Type Alias: EventSubscriberFunc\ -> **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`\> diff --git a/packages/simplex-chat-nodejs/docs/bot.Function.subscribeChatItems.md b/packages/simplex-chat-nodejs/docs/bot.Function.subscribeChatItems.md index 5397cf3aeb..8fbb699f80 100644 --- a/packages/simplex-chat-nodejs/docs/bot.Function.subscribeChatItems.md +++ b/packages/simplex-chat-nodejs/docs/bot.Function.subscribeChatItems.md @@ -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 diff --git a/packages/simplex-chat-nodejs/docs/bot.Interface.BotConfig.md b/packages/simplex-chat-nodejs/docs/bot.Interface.BotConfig.md index f763f414da..3f7757041b 100644 --- a/packages/simplex-chat-nodejs/docs/bot.Interface.BotConfig.md +++ b/packages/simplex-chat-nodejs/docs/bot.Interface.BotConfig.md @@ -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`\> diff --git a/packages/simplex-chat-nodejs/examples/squaring-bot-readme.js b/packages/simplex-chat-nodejs/examples/squaring-bot-readme.js index 8899e9bf15..0b369a2053 100644 --- a/packages/simplex-chat-nodejs/examples/squaring-bot-readme.js +++ b/packages/simplex-chat-nodejs/examples/squaring-bot-readme.js @@ -1,12 +1,12 @@ (async () => { const {bot} = await import("../dist/index.js") - const [chat, _user, _address] = await bot.run({ + await bot.run({ profile: {displayName: "Squaring bot example", fullName: ""}, dbOpts: {type: "sqlite", filePrefix: "./squaring_bot"}, options: { addressSettings: {welcomeMessage: "Send a number, I will square it."}, }, - onMessage: async (ci, content) => { + onMessage: async (ci, content, chat) => { const n = +content.text const reply = typeof n === "number" && !isNaN(n) ? `${n} * ${n} = ${n * n}` diff --git a/packages/simplex-chat-nodejs/examples/squaring-bot.ts b/packages/simplex-chat-nodejs/examples/squaring-bot.ts index dbb58d90dc..e3f746c1d8 100644 --- a/packages/simplex-chat-nodejs/examples/squaring-bot.ts +++ b/packages/simplex-chat-nodejs/examples/squaring-bot.ts @@ -3,7 +3,7 @@ import {bot, util} from "../dist" (async () => { const welcomeMessage = "Hello! I am a simple squaring bot.\n\nIf you send me a number, I will calculate its square." - const [chat, _user, _address] = await bot.run({ + await bot.run({ profile: {displayName: "Squaring bot example", fullName: ""}, dbOpts: {type: "sqlite", filePrefix: "./squaring_bot"}, options: { @@ -15,7 +15,7 @@ import {bot, util} from "../dist" logContacts: true, logNetwork: false }, - onMessage: async (ci, content) => { + onMessage: async (ci, content, chat) => { const n = +content.text const reply = typeof n === "number" && !isNaN(n) ? `${n} * ${n} = ${n * n}` @@ -23,11 +23,11 @@ import {bot, util} from "../dist" await chat.apiSendTextReply(ci, reply) }, onCommands: { // command handlers can be different from commands to be shown in client UI - "help": async (ci: T.AChatItem, _cmd: util.BotCommand) => { + "help": async (ci: T.AChatItem, _cmd: util.BotCommand, chat) => { await chat.apiSendTextMessage(ci.chatInfo, welcomeMessage) }, // fallback handler that will be called for all other commands - "": async (ci: T.AChatItem, _cmd: util.BotCommand) => { + "": async (ci: T.AChatItem, _cmd: util.BotCommand, chat) => { await chat.apiSendTextReply(ci, "This command is not supported") } }, diff --git a/packages/simplex-chat-nodejs/package.json b/packages/simplex-chat-nodejs/package.json index f405be260d..0a57bdc51d 100644 --- a/packages/simplex-chat-nodejs/package.json +++ b/packages/simplex-chat-nodejs/package.json @@ -1,6 +1,6 @@ { "name": "simplex-chat", - "version": "7.1.0-beta.4", + "version": "7.1.0-beta.4.2", "main": "dist/index.js", "types": "dist/index.d.ts", "files": [ diff --git a/packages/simplex-chat-nodejs/src/api.ts b/packages/simplex-chat-nodejs/src/api.ts index 8c11e9fde3..8d4e75b181 100644 --- a/packages/simplex-chat-nodejs/src/api.ts +++ b/packages/simplex-chat-nodejs/src/api.ts @@ -47,7 +47,7 @@ export const defaultBotAddressSettings: BotAddressSettings = { businessAddress: false } -export type EventSubscriberFunc = (event: ChatEvent & {type: K}) => void | Promise +export type EventSubscriberFunc = (event: ChatEvent & {type: K}, chat: ChatApi) => void | Promise export type EventSubscribers = {[K in CEvt.Tag]?: EventSubscriberFunc} @@ -177,7 +177,7 @@ export class ChatApi { if (subs) { for (const {subscriber, once} of [...subs]) { try { - const p = (subscriber as EventSubscriberFunc)(event) + const p = (subscriber as EventSubscriberFunc)(event, this) if (p instanceof Promise) await p } catch(e) { console.log(`${event.type} event processing error`, e) @@ -187,7 +187,7 @@ export class ChatApi { } for (const r of [...this.receivers]) { try { - const p = r(event) + const p = r(event, this) if (p instanceof Promise) await p } catch(e) { console.log(`${event.type} event processing error`, e) @@ -422,7 +422,18 @@ export class ChatApi { const r = await this.sendChatCmd(CC.APISetAddressSettings.cmdString({userId, settings})) if (r.type !== "userContactLinkUpdated") { throw new ChatCommandError("error changing user contact address settings", r) - } + } + } + + async apiSetUserDomain(userId: number, simplexDomain?: string): Promise { + const r = await this.sendChatCmd(CC.APISetUserDomain.cmdString({userId, simplexDomain})) + switch (r.type) { + case "userProfileUpdated": + case "userProfileNoChange": + return r.user + default: + throw new ChatCommandError("error setting SimpleX name", r) + } } /** diff --git a/packages/simplex-chat-nodejs/src/bot.ts b/packages/simplex-chat-nodejs/src/bot.ts index 68e787ca3e..83037e310e 100644 --- a/packages/simplex-chat-nodejs/src/bot.ts +++ b/packages/simplex-chat-nodejs/src/bot.ts @@ -35,17 +35,18 @@ const defaultOpts: Required = { export interface BotConfig { profile: T.Profile, + simplexDomain?: string | null, dbOpts: BotDbOpts, options: BotOptions, - onMessage?: (chatItem: T.AChatItem, content: T.MsgContent) => void | Promise, + onMessage?: (chatItem: T.AChatItem, content: T.MsgContent, chat: api.ChatApi) => void | Promise, // command handlers can be different from commands to be shown in client UI - onCommands?: {[K in string]?: ((chatItem: T.AChatItem, command: util.BotCommand) => void | Promise)}, + onCommands?: {[K in string]?: ((chatItem: T.AChatItem, command: util.BotCommand, chat: api.ChatApi) => void | Promise)}, // If you use `onMessage` and to subscribe "newChatItems" event, exclude content messages from processing // If you use `onCommands` and to subscribe "newChatItems" event, exclude commands from processing events?: api.EventSubscribers } -export async function run({profile, dbOpts, options = defaultOpts, onMessage, onCommands = {}, events = {}}: BotConfig): Promise<[api.ChatApi, T.User, T.UserContactLink | undefined]> { +export async function run({profile, simplexDomain, dbOpts, options = defaultOpts, onMessage, onCommands = {}, events = {}}: BotConfig): Promise<[api.ChatApi, T.User, T.UserContactLink | undefined]> { const bot = await api.ChatApi.init(dbOpts, dbOpts.confirmMigrations || core.MigrationConfirmation.YesUp, dbOpts.queueSize) const opts = fullOptions(options) if (onMessage || Object.keys(onCommands).length > 0) subscribeChatItems(bot, onMessage, onCommands) @@ -60,7 +61,8 @@ export async function run({profile, dbOpts, options = defaultOpts, onMessage, on console.log(`Bot address: ${addressLink}`) if (opts.useBotProfile) botProfile.contactLink = addressLink } - await updateBotUserProfile(bot, user, botProfile, opts) + const namedUser = await updateBotSimplexDomain(bot, user, simplexDomain, opts) + await updateBotUserProfile(bot, namedUser, botProfile, opts) return [bot, user, address] } @@ -107,8 +109,8 @@ function mkBotProfile(profile: T.Profile, opts: Required): T.Profile export function subscribeChatItems( bot: api.ChatApi, - onMessage: ((chatItem: T.AChatItem, content: T.MsgContent) => void | Promise) | undefined, - commands: {[K in string]?: ((chatItem: T.AChatItem, command: util.BotCommand) => void | Promise)} + onMessage: ((chatItem: T.AChatItem, content: T.MsgContent, chat: api.ChatApi) => void | Promise) | undefined, + commands: {[K in string]?: ((chatItem: T.AChatItem, command: util.BotCommand, chat: api.ChatApi) => void | Promise)} ) { bot.on("newChatItems", async ({chatItems}) => { for (const ci of chatItems) { @@ -117,8 +119,8 @@ export function subscribeChatItems( const cmd = util.ciBotCommand(ci.chatItem) const cmdFunc = cmd && (commands[cmd.keyword] || commands[""]) try { - if (cmd && cmdFunc) await cmdFunc(ci, cmd) - else if (onMessage) await onMessage(ci, content.msgContent) + if (cmd && cmdFunc) await cmdFunc(ci, cmd, bot) + else if (onMessage) await onMessage(ci, content.msgContent, bot) } catch (e) { console.log(cmd && cmdFunc ? `${cmd.keyword} command processing error` : "message processing error", e) } @@ -180,15 +182,33 @@ async function createOrUpdateAddress(bot: api.ChatApi, user: T.User, opts: Requi } } - return address + return address +} + +async function updateBotSimplexDomain(bot: api.ChatApi, user: T.User, simplexDomain: string | null | undefined, opts: Required): Promise { + if (simplexDomain === undefined) return user + const domain = simplexDomain === null ? undefined : simplexDomain.toLowerCase() + if (user.profile.contactDomain?.domain === domain) return user + if (!opts.updateAddress) { + console.log("Bot SimpleX name changed") + return user + } + console.log("Bot SimpleX name changed, updating...") + try { + return await bot.apiSetUserDomain(user.userId, domain) + } catch (e) { + console.log("Error updating bot SimpleX name", e) + return user + } } async function updateBotUserProfile(bot: api.ChatApi, user: T.User, profile: T.Profile, opts: Required): Promise { const {userId} = user - if (!equal(util.fromLocalProfile(user.profile), profile)) { + const {contactDomain, ...currentProfile} = util.fromLocalProfile(user.profile) + if (!equal(currentProfile, profile)) { if (opts.updateProfile) { console.log("Bot profile changed, updating...") - const summary = await bot.apiUpdateProfile(userId, profile) + const summary = await bot.apiUpdateProfile(userId, {...profile, contactDomain}) console.log( summary ? `Bot profile updated: ${summary.updateSuccesses} updated contact(s), ${summary.updateFailures} failed contact update(s).` diff --git a/packages/simplex-chat-nodejs/src/util.ts b/packages/simplex-chat-nodejs/src/util.ts index dffb0ce1bc..6b8915e93a 100644 --- a/packages/simplex-chat-nodejs/src/util.ts +++ b/packages/simplex-chat-nodejs/src/util.ts @@ -53,8 +53,8 @@ export function botAddressSettings({addressSettings}: T.UserContactLink): BotAdd } } -export function fromLocalProfile({displayName, fullName, shortDescr, image, contactLink, preferences, peerType}: T.LocalProfile): T.Profile { - const profile = {displayName, fullName, shortDescr, image, contactLink, preferences, peerType} +export function fromLocalProfile({displayName, fullName, shortDescr, image, contactLink, preferences, peerType, contactDomain}: T.LocalProfile): T.Profile { + const profile = {displayName, fullName, shortDescr, image, contactLink, preferences, peerType, contactDomain: contactDomain && {domain: contactDomain.domain}} for (const key in profile) { if (typeof (profile as any)[key] === "undefined") delete (profile as any)[key] } diff --git a/packages/simplex-chat-nodejs/tests/api.unit.test.ts b/packages/simplex-chat-nodejs/tests/api.unit.test.ts index c5a4367b11..b6b92bf93b 100644 --- a/packages/simplex-chat-nodejs/tests/api.unit.test.ts +++ b/packages/simplex-chat-nodejs/tests/api.unit.test.ts @@ -1,4 +1,4 @@ -import {ChatResponse, T} from "@simplex-chat/types" +import {ChatEvent, ChatResponse, T} from "@simplex-chat/types" import * as api from "../src/api" import * as core from "../src/core" @@ -30,6 +30,13 @@ describe("documented success responses", () => { await expect(chat.apiSetProfileAddress(1, true)).resolves.toEqual({updateSuccesses: 0, updateFailures: 0, changedContacts: []}) }) + it("apiSetUserDomain sets and removes the name", async () => { + const chat = await chatWithResponse({type: "userProfileNoChange", user}) + await expect(chat.apiSetUserDomain(1, "calc.simplex")).resolves.toEqual(user) + await expect(chat.apiSetUserDomain(1)).resolves.toEqual(user) + expect(jest.mocked(core.chatSendCmd).mock.calls.map(([, cmd]) => cmd)).toEqual(["/_set domain 1 calc.simplex", "/_set domain 1"]) + }) + it("apiReceiveFile reports a file cancelled by sender", async () => { const chat = await chatWithResponse({type: "rcvFileAcceptedSndCancelled", user, rcvFileTransfer: {}}) await expect(chat.apiReceiveFile(3)).rejects.toThrow("file cancelled by sender") @@ -94,6 +101,15 @@ describe("startChat lifecycle", () => { await chat.stopChat() }) + it("passes the chat api to event subscribers", async () => { + const chat = await chatWithResponses({type: "chatStarted"}, {type: "chatStopped"}) + jest.mocked(core.chatRecvMsgWait).mockResolvedValueOnce({type: "hostConnected"} as ChatEvent) + const subscriberChat = new Promise(resolve => chat.on("hostConnected", (_event, c) => resolve(c))) + await chat.startChat() + await expect(subscriberChat).resolves.toBe(chat) + await chat.stopChat() + }) + it("receives with a 500 ms wait", async () => { const chat = await chatWithResponses() await chat.recvChatEvent() diff --git a/packages/simplex-chat-nodejs/tests/bot.unit.test.ts b/packages/simplex-chat-nodejs/tests/bot.unit.test.ts index 39f9358996..62ff10aab4 100644 --- a/packages/simplex-chat-nodejs/tests/bot.unit.test.ts +++ b/packages/simplex-chat-nodejs/tests/bot.unit.test.ts @@ -1,6 +1,6 @@ import {ChatEvent, T} from "@simplex-chat/types" import * as api from "../src/api" -import {subscribeChatItems} from "../src/bot" +import {run, subscribeChatItems} from "../src/bot" type Handler = (evt: ChatEvent) => Promise @@ -40,6 +40,14 @@ describe("subscribeChatItems", () => { expect(calls).toEqual(["message:/unknown", "message:hello"]) }) + it("passes the chat api to handlers", async () => { + const {bot, deliver} = fakeBot() + const chats: api.ChatApi[] = [] + subscribeChatItems(bot, async (_ci, _content, chat) => { chats.push(chat) }, {help: async (_ci, _cmd, chat) => { chats.push(chat) }}) + await deliver([item("rcvMsgContent", "/help"), item("rcvMsgContent", "hello")]) + expect(chats).toEqual([bot, bot]) + }) + it("ignores sent items", async () => { const {bot, deliver} = fakeBot() const calls: string[] = [] @@ -48,3 +56,75 @@ describe("subscribeChatItems", () => { expect(calls).toEqual([]) }) }) + +describe("run", () => { + const address = { + connLinkContact: {connFullLink: "full", connShortLink: "short"}, + addressSettings: {businessAddress: false, autoAccept: {acceptIncognito: false}}, + } as unknown as T.UserContactLink + + function fakeChat(contactDomain?: T.SimplexDomainClaim) { + const user = {userId: 1, profile: {displayName: "Old", fullName: "", contactDomain}} as unknown as T.User + const chat = { + on: jest.fn(), + apiGetActiveUser: jest.fn().mockResolvedValue(user), + startChat: jest.fn(), + apiGetUserAddress: jest.fn().mockResolvedValue(address), + apiSetAddressSettings: jest.fn(), + apiSetUserDomain: jest.fn(async (_userId: number, domain?: string) => ({...user, profile: {...user.profile, contactDomain: domain && {domain}}})), + apiUpdateProfile: jest.fn().mockResolvedValue({updateSuccesses: 0, updateFailures: 0}), + } + jest.spyOn(api.ChatApi, "init").mockResolvedValue(chat as unknown as api.ChatApi) + return chat + } + + const runBot = (simplexDomain?: string | null, options = {}) => + run({profile: {displayName: "Calculator", fullName: ""}, simplexDomain, dbOpts: {type: "sqlite", filePrefix: "unused"}, options}) + + const updatedProfile = (chat: ReturnType) => chat.apiUpdateProfile.mock.calls[0][1] + + beforeEach(() => jest.spyOn(console, "log").mockImplementation(() => {})) + afterEach(() => jest.restoreAllMocks()) + + it("sets the configured SimpleX name", async () => { + const chat = fakeChat() + await runBot("Calc.simplex") + expect(chat.apiSetUserDomain).toHaveBeenCalledWith(1, "calc.simplex") + expect(updatedProfile(chat).contactDomain).toEqual({domain: "calc.simplex"}) + }) + + it("removes the SimpleX name set to null", async () => { + const chat = fakeChat({domain: "calc.simplex"}) + await runBot(null) + expect(chat.apiSetUserDomain).toHaveBeenCalledWith(1, undefined) + expect(updatedProfile(chat).contactDomain).toBeUndefined() + }) + + it("keeps the stored SimpleX name when it is not configured", async () => { + const chat = fakeChat({domain: "calc.simplex"}) + await runBot() + expect(chat.apiSetUserDomain).not.toHaveBeenCalled() + expect(updatedProfile(chat).contactDomain).toEqual({domain: "calc.simplex"}) + }) + + it("keeps the SimpleX name when updating the profile", async () => { + const chat = fakeChat({domain: "calc.simplex", proof: {presHeader: "header", signature: "signature"}}) + await runBot("calc.simplex") + expect(chat.apiSetUserDomain).not.toHaveBeenCalled() + expect(updatedProfile(chat).displayName).toBe("Calculator") + expect(updatedProfile(chat).contactDomain).toEqual({domain: "calc.simplex"}) + }) + + it("continues when the SimpleX name cannot be set", async () => { + const chat = fakeChat() + chat.apiSetUserDomain.mockRejectedValue(new Error("simplexDomainNotReady")) + await expect(runBot("calc.simplex")).resolves.toBeDefined() + expect(updatedProfile(chat).contactDomain).toBeUndefined() + }) + + it("does not change the SimpleX name without updateAddress", async () => { + const chat = fakeChat() + await runBot("calc.simplex", {updateAddress: false}) + expect(chat.apiSetUserDomain).not.toHaveBeenCalled() + }) +}) diff --git a/packages/simplex-chat-python/src/simplex_chat/types/_commands.py b/packages/simplex-chat-python/src/simplex_chat/types/_commands.py index e6155c9ce7..365eff7e10 100644 --- a/packages/simplex-chat-python/src/simplex_chat/types/_commands.py +++ b/packages/simplex-chat-python/src/simplex_chat/types/_commands.py @@ -59,6 +59,19 @@ def APISetProfileAddress_cmd_string(self: APISetProfileAddress) -> str: APISetProfileAddress_Response = CR.UserProfileUpdated | CR.UserProfileNoChange | CR.ChatCmdError +# Set or remove SimpleX name of bot address. The name must be registered with the address short link. +# Network usage: interactive. +class APISetUserDomain(TypedDict): + userId: int # int64 + simplexDomain: NotRequired[str] + + +def APISetUserDomain_cmd_string(self: APISetUserDomain) -> str: + return '/_set domain ' + str(self['userId']) + ((' ' + self.get('simplexDomain')) if self.get('simplexDomain') is not None else '') + +APISetUserDomain_Response = CR.UserProfileUpdated | CR.UserProfileNoChange | CR.ChatCmdError + + # Set bot address settings. # Network usage: interactive. class APISetAddressSettings(TypedDict): diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 9cc80fc54b..446d4f1f9e 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -446,7 +446,7 @@ data ChatCommand | APIGetCallInvitations | APICallStatus ContactId WebRTCCallStatus | APIUpdateProfile {userId :: UserId, profile :: Profile} - | APISetUserDomain {userId :: UserId, simplexDomain :: Maybe SimplexDomain} + | APISetUserDomain {userId :: UserId, simplexDomain :: Maybe (StrJSON "SimplexDomain" SimplexDomain)} | APISetContactPrefs {contactId :: ContactId, preferences :: Preferences} | APISetContactAlias {contactId :: ContactId, localAlias :: LocalAlias} | APISetGroupAlias {groupId :: GroupId, localAlias :: LocalAlias} diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index d02a189b1d..15e3716112 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -1594,7 +1594,8 @@ processChatCommand cxt nm = \case withCurrentCall contactId $ \user ct call -> updateCallItemStatus user ct call receivedStatus Nothing $> Just call APIUpdateProfile userId profile -> withUserId userId (`updateProfile` profile) - APISetUserDomain userId domain_ -> withUserId userId $ \user@User {profile = p@LocalProfile {contactLink, contactDomain}} -> + APISetUserDomain userId strDomain_ -> withUserId userId $ \user@User {profile = p@LocalProfile {contactLink, contactDomain}} -> do + let domain_ = unStrJSON <$> strDomain_ if (claimDomain <$> contactDomain) == domain_ then pure $ CRUserProfileNoChange user else do @@ -6123,7 +6124,7 @@ chatCommandP = "/_call status @" *> (APICallStatus <$> A.decimal <* A.space <*> strP), "/_call get" $> APIGetCallInvitations, "/_profile " *> (APIUpdateProfile <$> A.decimal <* A.space <*> jsonP), - "/_set domain " *> (APISetUserDomain <$> A.decimal <*> optional (A.space *> strP)), + "/_set domain " *> (APISetUserDomain <$> A.decimal <*> optional (A.space *> (StrJSON <$> strP))), "/_set alias @" *> (APISetContactAlias <$> A.decimal <*> (A.space *> textP <|> pure "")), "/_set alias #" *> (APISetGroupAlias <$> A.decimal <*> (A.space *> textP <|> pure "")), "/_set alias :" *> (APISetConnectionAlias <$> A.decimal <*> (A.space *> textP <|> pure "")), diff --git a/src/Simplex/Chat/Markdown.hs b/src/Simplex/Chat/Markdown.hs index c877a22c2b..c90cd54fab 100644 --- a/src/Simplex/Chat/Markdown.hs +++ b/src/Simplex/Chat/Markdown.hs @@ -518,11 +518,11 @@ displayNameTextP_ = (,"") <$> quoted '\'' <|> splitPunctuation <$> takeNameTill refChar c = c > ' ' && c /= '#' && c /= '@' && c /= '\'' commandTextP :: Parser (Text, Text) -commandTextP = do - (cmd, punct) <- displayNameTextP_ - case T.words cmd of - (keyword : _) | T.all (\c -> isAlpha c || isDigit c || c == '_') keyword -> pure (cmd, punct) - _ -> fail "invalid command keyword" +commandTextP = commandText <$> displayNameTextP_ + where + commandText (cmd, punct) + | T.null cmd = (punct, "") + | otherwise = (cmd, punct) splitPunctuation :: Text -> (Text, Text) splitPunctuation s = (T.dropWhileEnd isPunctuation s, T.takeWhileEnd isPunctuation s) diff --git a/src/Simplex/Chat/Protocol.hs b/src/Simplex/Chat/Protocol.hs index 4671a927d1..fbdafca782 100644 --- a/src/Simplex/Chat/Protocol.hs +++ b/src/Simplex/Chat/Protocol.hs @@ -92,7 +92,7 @@ import Simplex.Messaging.Version hiding (version) -- This indirection is needed for backward/forward compatibility testing. -- Testing with real app versions is still needed, as tests use the current code with different version ranges, not the old code. currentChatVersion :: VersionChat -currentChatVersion = VersionChat 20 +currentChatVersion = VersionChat 21 -- This should not be used directly in code, instead use `chatVRange` from ChatConfig (see comment above) supportedChatVRange :: VersionRangeChat @@ -140,6 +140,9 @@ groupRosterVersion = VersionChat 19 groupMemberKeyVersion :: VersionChat groupMemberKeyVersion = VersionChat 20 +anyTextCommandsVersion :: VersionChat +anyTextCommandsVersion = VersionChat 21 + data ConnectionEntity = RcvDirectMsgConnection {entityConnection :: Connection, contact :: Maybe Contact} | RcvGroupMsgConnection {entityConnection :: Connection, groupInfo :: GroupInfo, groupMember :: GroupMember} diff --git a/tests/MarkdownTests.hs b/tests/MarkdownTests.hs index 1f9936044c..903e3b3773 100644 --- a/tests/MarkdownTests.hs +++ b/tests/MarkdownTests.hs @@ -406,13 +406,20 @@ textWithCommands = describe "text with commands" do "/filter 1" <==> command "filter" "/filter" <> " 1" -- this is parsed as full command by parseMaybeMarkdownList "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 "-" "/-" + "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" "send / help!" <==> "send / help!" "send /help / filter" <==> "send " <> command "help" "/help" <> " / filter" "send /help /" <==> "send " <> command "help" "/help" <> " /" - "send /he?lp" <==> "send /he?lp" uri' :: Text -> FormattedText uri' = FormattedText $ Just Uri @@ -471,6 +478,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 diff --git a/tests/ProtocolTests.hs b/tests/ProtocolTests.hs index 554398520e..d748b5cba2 100644 --- a/tests/ProtocolTests.hs +++ b/tests/ProtocolTests.hs @@ -225,7 +225,7 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do "{\"v\":\"9\",\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"}}}" ##==## ChatMessage chatInitialVRange (Just $ SharedMsgId "\1\2\3\4") (XMsgNew (mcSimple (MCText "hello"))) it "x.msg.new chat message with chat version range" $ - "{\"v\":\"9-20\",\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"}}}" + "{\"v\":\"9-21\",\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"}}}" ##==## ChatMessage supportedChatVRange (Just $ SharedMsgId "\1\2\3\4") (XMsgNew (mcSimple (MCText "hello"))) it "x.msg.new quote" $ "{\"v\":\"9\",\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello to you too\",\"type\":\"text\"},\"quote\":{\"content\":{\"text\":\"hello there!\",\"type\":\"text\"},\"msgRef\":{\"msgId\":\"BQYHCA==\",\"sent\":true,\"sentAt\":\"1970-01-01T00:00:01.000000001Z\"}}}}" @@ -342,13 +342,13 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do "{\"v\":\"9\",\"event\":\"x.grp.mem.new\",\"params\":{\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" #==# XGrpMemNew MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Nothing, profile = testProfile, memberKey = Nothing} Nothing it "x.grp.mem.new with member chat version range" $ - "{\"v\":\"9\",\"event\":\"x.grp.mem.new\",\"params\":{\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"v\":\"9-20\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" + "{\"v\":\"9\",\"event\":\"x.grp.mem.new\",\"params\":{\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"v\":\"9-21\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" #==# XGrpMemNew MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Just $ ChatVersionRange supportedChatVRange, profile = testProfile, memberKey = Nothing} Nothing it "x.grp.mem.intro" $ "{\"v\":\"9\",\"event\":\"x.grp.mem.intro\",\"params\":{\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" #==# XGrpMemIntro MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Nothing, profile = testProfile, memberKey = Nothing} Nothing it "x.grp.mem.intro with member chat version range" $ - "{\"v\":\"9\",\"event\":\"x.grp.mem.intro\",\"params\":{\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"v\":\"9-20\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" + "{\"v\":\"9\",\"event\":\"x.grp.mem.intro\",\"params\":{\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"v\":\"9-21\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" #==# XGrpMemIntro MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Just $ ChatVersionRange supportedChatVRange, profile = testProfile, memberKey = Nothing} Nothing it "x.grp.mem.intro with member restrictions" $ "{\"v\":\"9\",\"event\":\"x.grp.mem.intro\",\"params\":{\"memberRestrictions\":{\"restriction\":\"blocked\"},\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" @@ -363,7 +363,7 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do "{\"v\":\"9\",\"event\":\"x.grp.mem.fwd\",\"params\":{\"memberIntro\":{\"directConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-4%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"groupConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-4%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"},\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" #==# XGrpMemFwd MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Nothing, profile = testProfile, memberKey = Nothing} IntroInvitation {groupConnReq = testConnReq, directConnReq = Just testConnReq} it "x.grp.mem.fwd with member chat version range and w/t directConnReq" $ - "{\"v\":\"9\",\"event\":\"x.grp.mem.fwd\",\"params\":{\"memberIntro\":{\"groupConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-4%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"},\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"v\":\"9-20\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" + "{\"v\":\"9\",\"event\":\"x.grp.mem.fwd\",\"params\":{\"memberIntro\":{\"groupConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-4%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"},\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"v\":\"9-21\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" #==# XGrpMemFwd MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Just $ ChatVersionRange supportedChatVRange, profile = testProfile, memberKey = Nothing} IntroInvitation {groupConnReq = testConnReq, directConnReq = Nothing} it "x.grp.mem.info" $ "{\"v\":\"9\",\"event\":\"x.grp.mem.info\",\"params\":{\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}"