mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-11-14 06:57:51 +00:00
* bots, nodejs: document chat, connection, member contact and network commands, typed commands in library * bots, nodejs: restore apiConnect, wrap domain once, test all connect responses * nodejs: JSDoc for new methods * docs: plan for bot API commands and typed Node library methods * tests: channel prepared with a domain has a verified SimpleX name * docs: apply review fixes to the plan for bot API commands * docs: apply second review pass to the plan for bot API commands * docs: refer the bot API plan to the rewritten test commit * docs: record decisions in the plan for bot API commands * docs: plan tests of the generated Python command strings * python: test the generated strings of the documented commands * docs: plan for files, /leave and library methods in the MCP server * docs: wait at stop in the MCP plan * docs: library methods and /leave mechanics in the MCP plan * mcp: typed library methods for chat commands * docs: plan the message information command for bots * mcp: /leave CHAT owner command to leave a chat at once * docs: file servers, GIF and video choices decided in the MCP plan * docs: file mechanics in the MCP plan * mcp: files of received messages * bots, nodejs: message information with the file servers for bots * docs: plan the relay approval option of receiving files in Node * mcp: download of received files with owner approval * docs: name the xftp-server test helper in the files plan * nodejs: receive files without approving unknown relays * docs: plan the user servers command for bots * bots, nodejs: user servers and relays by operator for bots * nodejs: document the message information method * docs: plan the file upload progress event for bots * mcp: send files with owner approval, refusing private keys * bots: file upload progress event for bots * mcp: /leave template in the agent control chat menu * docs: sent copies are deleted for contacts too, at sndFileCompleteXFTP * bots: drop the file upload progress event * docs: drop the upload progress event plan * nodejs: document user servers and relay approval of received files * mcp: download cards name unknown file servers after the upload * docs: record the wait for the file description in the files plan * mcp: fixed wait at stop for cancelled cards, without /sql * docs: record decision C4, a fixed wait at stop, in the files plan * ci: test the MCP server package on pull requests * ci: state when the MCP workflow can be dispatched * ci: run the MCP server tests when its libraries change * mcp: log the errors of image previews and resizing * mcp: keep files in transfer in the data folder * mcp: restrict sent files by the real paths of refused folders * mcp: validate the file at the send by its inode and change time * mcp: limit ffmpeg and ffprobe to local files and no input commands * mcp: ask the owner for unknown file servers; read approvals at creation * mcp: name downloaded files by their ID; return paths of downloads only * mcp: end each download with a result for the agent and the card * mcp: show files above their sender's size limit as failed * mcp: refuse empty files, as without file and captions too long to send * mcp: tell the owner about failed uploads and unposted received files * mcp: delete resized copies left by the last run at start * mcp: name downloads and leaving in the help and greeting, align docs * mcp: compute the download path for received files only * mcp: send a /receive file to the owner under its name * mcp: keep files received without a download in the chat's folder * mcp: keep a left chat's files and delete them with /delete CHAT files * mcp: test that sent images are resized by the core * mcp: wait 30 minutes for a download's progress before cancelling it * mcp: end a download as deleted only when its message is not found * mcp: delete the copy of a /receive file whose post fails * mcp: count the quoted content in a message's length, as the core sends it * mcp: name received bytes, unknown-server downloads and left chat files plainly * mcp: test refused folders, file changes and download approvals * docs: align MCP server docs with refused folders, leaves and download waits * mcp: move received files only in full, delete partials only of downloads * mcp: report a received file missing from its folder as failed * mcp: move received files of a group's member chats into its folder * mcp: read a video's preview and duration as an MP4 or QuickTime file * mcp: 0.2.0 on simplex-chat 7.1.0-beta.10.1 * mcp: file folders by chat type, /delete cancels downloads, files reply * docs: MCP server specs and plan for 0.2.0 * mcp: /receive gives the agent a file, /forward sends it to the owner * docs: MCP server specs for /receive and /forward of download cards * docs: MCP plan records the temp folder command as a library exception * mcp: /delete cancels resumed downloads; /forward quote fits the limit * docs: MCP specs and plans for /forward quotes and resumed downloads * docs: MCP specs name the source commit they are checked against * docs: MCP PLAN states the /forward quote and move rules * mcp: a download moves its completed file whatever its size * mcp: /receive N all covers its card and later files, not pending ones * docs: MCP files approval scope, reply text and README server wording * mcp: a download approves relays only for a card naming unknown servers * mcp: files in member chats of a group are tracked as in the main chat * docs: MCP relay approval of downloads and files in member chats * mcp: a rejected file gets a card again under the files approval * docs: MCP rejected files under the files approval, README scope * mcp: an offered file without a description gets no card or action * docs: MCP a file without a description is not downloaded * mcp: 0.2.0 on simplex-chat 7.1.0-beta.10.2 * ci: test the MCP server as a scope of build-linux
136 lines
4.8 KiB
TypeScript
136 lines
4.8 KiB
TypeScript
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, "tls")
|
|
const serverRelease = "v7.1.0-beta.3"
|
|
const releaseArch: Record<string, string | undefined> = {x64: "x86-64", arm64: "aarch64"}
|
|
|
|
export interface SmpServer {
|
|
address: string
|
|
stop: () => Promise<void>
|
|
}
|
|
|
|
interface ServerKind {
|
|
name: string
|
|
scheme: string
|
|
env: string
|
|
ini: string
|
|
config: (port: number, dir: string) => string
|
|
}
|
|
|
|
const smp: ServerKind = {
|
|
name: "smp-server",
|
|
scheme: "smp",
|
|
env: "SMP_SERVER",
|
|
ini: "smp-server.ini",
|
|
config: port => `[STORE_LOG]\nenable: off\n\n[TRANSPORT]\nhost: localhost\nport: ${port}\nwebsockets: off\n`,
|
|
}
|
|
|
|
const xftp: ServerKind = {
|
|
name: "xftp-server",
|
|
scheme: "xftp",
|
|
env: "XFTP_SERVER",
|
|
ini: "file-server.ini",
|
|
config: (port, dir) => `[STORE_LOG]\nenable: off\n\n[TRANSPORT]\nhost: localhost\nport: ${port}\n\n[FILES]\npath: ${join(dir, "files")}\nstorage_quota: 1gb\n`,
|
|
}
|
|
|
|
export function startSmpServer(): Promise<SmpServer> {
|
|
return startServer(smp)
|
|
}
|
|
|
|
export function startXftpServer(): Promise<SmpServer> {
|
|
return startServer(xftp)
|
|
}
|
|
|
|
async function startServer(kind: ServerKind): Promise<SmpServer> {
|
|
const port = await freePort()
|
|
const dir = mkdtempSync(join(tmpdir(), `${kind.name}-`))
|
|
const configDir = join(dir, "config")
|
|
mkdirSync(configDir)
|
|
mkdirSync(join(dir, "files"))
|
|
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, kind.ini), kind.config(port, dir))
|
|
const server = spawn(await serverExecutable(kind), ["start"], {
|
|
env: {...process.env, [`${kind.env}_CFG_PATH`]: configDir, [`${kind.env}_LOG_PATH`]: join(dir, "logs")},
|
|
stdio: ["ignore", "ignore", "inherit"],
|
|
})
|
|
const stop = async (): Promise<void> => {
|
|
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, kind, port)
|
|
} catch (e) {
|
|
await stop()
|
|
throw e
|
|
}
|
|
return {address: `${kind.scheme}://${fingerprint}@localhost:${port}`, stop}
|
|
}
|
|
|
|
export 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 serverExecutable(kind: ServerKind): Promise<string> {
|
|
const executable = process.env[kind.env]
|
|
if (executable) return executable
|
|
const path = join(__dirname, `../../node_modules/.cache/${kind.name}`, `${kind.name}-${serverRelease}`)
|
|
if (!existsSync(path)) await downloadServer(kind, path)
|
|
return path
|
|
}
|
|
|
|
async function downloadServer(kind: ServerKind, path: string): Promise<void> {
|
|
const arch = releaseArch[process.arch]
|
|
if (process.platform !== "linux" || !arch) {
|
|
throw new Error(`${kind.name} release binaries are not available for ${process.platform} ${process.arch}, set ${kind.env} to the ${kind.name} path`)
|
|
}
|
|
const url = `https://github.com/simplex-chat/simplexmq/releases/download/${serverRelease}/${kind.name}-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, "_")
|
|
}
|
|
|
|
async function waitForServer(server: ChildProcess, kind: ServerKind, port: number): Promise<void> {
|
|
const deadline = Date.now() + 15_000
|
|
while (!(await canConnect(port))) {
|
|
if (server.exitCode !== null || Date.now() > deadline) throw new Error(`${kind.name} 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))
|
|
})
|
|
}
|