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>
This commit is contained in:
Evgeny
2026-09-27 17:20:40 +01:00
committed by GitHub
co-authored by Evgeny @ SimpleX Chat
parent 4cb244441b
commit 643a9afad6
35 changed files with 1075 additions and 49 deletions
+2
View File
@@ -0,0 +1,2 @@
node_modules/
data/
+36
View File
@@ -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`.
@@ -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)}`)
})
})
@@ -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<void> {
expect(await chat.sendChatCmd(`/smp ${smpServer.address}`)).toMatchObject({type: "cmdOk"})
}
async function prepareBotDatabase(dbOpts: bot.BotDbOpts): Promise<void> {
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<unknown>[]) => {
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)
+22
View File
@@ -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"
}
@@ -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<Record<Key, string>> = {
"±": "neg",
"%": "pct",
"÷": "div",
"×": "mul",
"-": "sub",
"+": "add",
"√": "sqrt",
".": "dot",
"=": "eq",
}
function keyWord(key: Key): string {
return keyWords[key] ?? key
}
const operatorNames = new Map<string, Operator>([
["+", "+"], ["-", "-"], ["−", "-"], ["×", "×"], ["x", "×"], ["*", "×"], ["÷", "÷"], ["/", "÷"],
])
export const keyNames = new Map<string, Key>([
...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)}\``
}
@@ -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<number, Session>()
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<void> {
const symbolKeys = member.memberChatVRange.maxVersion >= anyTextCommandsVersion
const [sent] = await chat.apiSendTextMessage([T.ChatType.Group, groupId], calculatorText(calc, symbolKeys))
const itemId = sent.chatItem.meta.itemId
const previous = sessions.get(groupId)
const timer = setTimeout(() => turnOff(chat, groupId, itemId, symbolKeys), idleMinutes * 60_000)
sessions.set(groupId, {calc, itemId, 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<void> {
const [calc, logLine] = update(sessions.get(sender.groupId)?.calc ?? initialCalc)
if (logLine) await chat.apiSendTextMessage([T.ChatType.Group, sender.groupId], logLine)
await showCalculator(chat, sender, calc)
}
function tapCommand(update: Update) {
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])
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<void> {
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),
},
})
}
File diff suppressed because one or more lines are too long
+12
View File
@@ -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)
})
@@ -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<string, string | undefined> = {x64: "x86-64", arm64: "aarch64"}
export interface SmpServer {
address: string
stop: () => Promise<void>
}
export async function startSmpServer(): Promise<SmpServer> {
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<string> {
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<void> {
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<number> {
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<void> {
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<boolean> {
return new Promise(resolve => {
const socket = createConnection({host: "localhost", port}, () => {
socket.destroy()
resolve(true)
})
socket.on("error", () => resolve(false))
})
}
+23
View File
@@ -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"]
}
}
+1 -1
View File
@@ -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.
+48
View File
@@ -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 <userId>[ <simplexDomain>]
```
```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.
+8 -1
View File
@@ -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",
@@ -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",
@@ -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 {
+4 -2
View File
@@ -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:
@@ -6,7 +6,7 @@
# Type Alias: EventSubscriberFunc\<K\>
> **EventSubscriberFunc**\<`K`\> = (`event`) => `void` \| `Promise`\<`void`\>
> **EventSubscriberFunc**\<`K`\> = (`event`, `chat`) => `void` \| `Promise`\<`void`\>
Defined in: [src/api.ts:50](../src/api.ts#L50)
@@ -22,6 +22,10 @@ Defined in: [src/api.ts:50](../src/api.ts#L50)
`ChatEvent` & `object`
### chat
[`ChatApi`](api.Class.ChatApi.md)
## Returns
`void` \| `Promise`\<`void`\>
@@ -18,7 +18,7 @@ Defined in: [src/bot.ts:108](../src/bot.ts#L108)
### onMessage
((`chatItem`, `content`) => `void` \| `Promise`\<`void`\>) \| `undefined`
((`chatItem`, `content`, `chat`) => `void` \| `Promise`\<`void`\>) \| `undefined`
### commands
@@ -34,13 +34,13 @@ Defined in: [src/bot.ts:42](../src/bot.ts#L42)
#### Index Signature
\[`key`: `string`\]: ((`chatItem`, `command`) => `void` \| `Promise`\<`void`\>) \| `undefined`
\[`key`: `string`\]: ((`chatItem`, `command`, `chat`) => `void` \| `Promise`\<`void`\>) \| `undefined`
***
### onMessage?
> `optional` **onMessage?**: (`chatItem`, `content`) => `void` \| `Promise`\<`void`\>
> `optional` **onMessage?**: (`chatItem`, `content`, `chat`) => `void` \| `Promise`\<`void`\>
Defined in: [src/bot.ts:40](../src/bot.ts#L40)
@@ -54,6 +54,10 @@ Defined in: [src/bot.ts:40](../src/bot.ts#L40)
`MsgContent`
##### chat
[`ChatApi`](api.Class.ChatApi.md)
#### Returns
`void` \| `Promise`\<`void`\>
@@ -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}`
@@ -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")
}
},
+1 -1
View File
@@ -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": [
+15 -4
View File
@@ -47,7 +47,7 @@ export const defaultBotAddressSettings: BotAddressSettings = {
businessAddress: false
}
export type EventSubscriberFunc<K extends CEvt.Tag> = (event: ChatEvent & {type: K}) => void | Promise<void>
export type EventSubscriberFunc<K extends CEvt.Tag> = (event: ChatEvent & {type: K}, chat: ChatApi) => void | Promise<void>
export type EventSubscribers = {[K in CEvt.Tag]?: EventSubscriberFunc<K>}
@@ -177,7 +177,7 @@ export class ChatApi {
if (subs) {
for (const {subscriber, once} of [...subs]) {
try {
const p = (subscriber as EventSubscriberFunc<typeof event.type>)(event)
const p = (subscriber as EventSubscriberFunc<typeof event.type>)(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<T.User> {
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)
}
}
/**
+31 -11
View File
@@ -35,17 +35,18 @@ const defaultOpts: Required<BotOptions> = {
export interface BotConfig {
profile: T.Profile,
simplexDomain?: string | null,
dbOpts: BotDbOpts,
options: BotOptions,
onMessage?: (chatItem: T.AChatItem, content: T.MsgContent) => void | Promise<void>,
onMessage?: (chatItem: T.AChatItem, content: T.MsgContent, chat: api.ChatApi) => void | Promise<void>,
// 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<void>)},
onCommands?: {[K in string]?: ((chatItem: T.AChatItem, command: util.BotCommand, chat: api.ChatApi) => void | Promise<void>)},
// 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<BotOptions>): T.Profile
export function subscribeChatItems(
bot: api.ChatApi,
onMessage: ((chatItem: T.AChatItem, content: T.MsgContent) => void | Promise<void>) | undefined,
commands: {[K in string]?: ((chatItem: T.AChatItem, command: util.BotCommand) => void | Promise<void>)}
onMessage: ((chatItem: T.AChatItem, content: T.MsgContent, chat: api.ChatApi) => void | Promise<void>) | undefined,
commands: {[K in string]?: ((chatItem: T.AChatItem, command: util.BotCommand, chat: api.ChatApi) => void | Promise<void>)}
) {
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<BotOptions>): Promise<T.User> {
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<BotOptions>): Promise<void> {
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).`
+2 -2
View File
@@ -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]
}
@@ -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()
@@ -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<void>
@@ -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<typeof fakeChat>) => 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()
})
})
@@ -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):
+1 -1
View File
@@ -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}
+3 -2
View File
@@ -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 "")),
+5 -5
View File
@@ -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)
+4 -1
View File
@@ -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}
+9 -1
View File
@@ -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
+4 -4
View File
@@ -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\"}}}}}"