mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-11-14 15:37:41 +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
276 lines
12 KiB
TypeScript
276 lines
12 KiB
TypeScript
import {tmpdir} from "os"
|
|
import {join} from "path"
|
|
import {ChatResponse, T} from "@simplex-chat/types"
|
|
import {api, core} from "simplex-chat"
|
|
import {Actions, ActionHooks} from "../../src/actions"
|
|
import {ToolCall} from "../../src/messages"
|
|
|
|
export const user = {userId: 1, profile: {displayName: "agent", peerType: "agent", profileId: 1}} as unknown as T.User
|
|
|
|
export const data = join(tmpdir(), `simplex-mcp-unit-${process.pid}`)
|
|
|
|
export function contact(contactId: number, displayName: string, extra: object = {}): T.Contact {
|
|
return {
|
|
contactId,
|
|
profile: {displayName, fullName: "", profileId: 100 + contactId, localAlias: "", ...extra},
|
|
activeConn: {connId: 200 + contactId, connStatus: {type: "ready"}},
|
|
updatedAt: "2026-10-17T10:00:00Z",
|
|
} as unknown as T.Contact
|
|
}
|
|
|
|
export function member(groupMemberId: number, displayName: string, role: T.GroupMemberRole = T.GroupMemberRole.Member, status: T.GroupMemberStatus = T.GroupMemberStatus.Connected): T.GroupMember {
|
|
return {
|
|
groupMemberId,
|
|
memberRole: role,
|
|
memberStatus: status,
|
|
memberProfile: {displayName, fullName: "", profileId: 300 + groupMemberId, localAlias: ""},
|
|
memberContactProfileId: 300 + groupMemberId,
|
|
} as unknown as T.GroupMember
|
|
}
|
|
|
|
export function group(groupId: number, displayName: string, membership: T.GroupMember = member(1, "agent", T.GroupMemberRole.Owner, T.GroupMemberStatus.Creator), extra: object = {}): T.GroupInfo {
|
|
return {groupId, groupProfile: {displayName, fullName: ""}, membership, useRelays: false, updatedAt: "2026-10-17T10:00:00Z", ...extra} as unknown as T.GroupInfo
|
|
}
|
|
|
|
export function direct(c: T.Contact): T.ChatInfo {
|
|
return {type: "direct", contact: c}
|
|
}
|
|
|
|
export function groupChat(g: T.GroupInfo): T.ChatInfo {
|
|
return {type: "group", groupInfo: g}
|
|
}
|
|
|
|
export function item(itemId: number, text: string, chatDir: T.CIDirection = {type: "directRcv"}, extra: {status?: string; quoted?: number; mention?: boolean; ts?: string; live?: boolean} = {}): T.ChatItem {
|
|
const received = chatDir.type.endsWith("Rcv")
|
|
return {
|
|
chatDir,
|
|
meta: {
|
|
itemId,
|
|
itemTs: extra.ts ?? "2026-10-17T10:00:00Z",
|
|
itemText: text,
|
|
itemStatus: {type: extra.status ?? (received ? "rcvNew" : "sndSent")},
|
|
userMention: extra.mention ?? false,
|
|
...(extra.live === undefined ? {} : {itemLive: extra.live}),
|
|
},
|
|
content: {type: received ? "rcvMsgContent" : "sndMsgContent", msgContent: {type: "text", text}},
|
|
mentions: {},
|
|
reactions: [],
|
|
...(extra.quoted === undefined ? {} : {quotedItem: {itemId: extra.quoted, sentAt: "", content: {type: "text", text: ""}}}),
|
|
} as unknown as T.ChatItem
|
|
}
|
|
|
|
export function fileItem(itemId: number, msgContent: object, fileStatus: object, extra: {chatDir?: T.CIDirection; fileSource?: object; fileId?: number} = {}): T.ChatItem {
|
|
const chatItem = item(itemId, "", extra.chatDir)
|
|
const file = {fileId: extra.fileId ?? 7, fileName: "report.pdf", fileSize: 2400000, fileStatus, fileProtocol: "xftp", ...(extra.fileSource === undefined ? {} : {fileSource: extra.fileSource})}
|
|
return {...chatItem, content: {...chatItem.content, msgContent}, file} as unknown as T.ChatItem
|
|
}
|
|
|
|
export function event(chatInfo: T.ChatInfo, chatItem: T.ChatItem): T.AChatItem {
|
|
return {chatInfo, chatItem}
|
|
}
|
|
|
|
export function signal(): {controller: AbortController; call: ToolCall; progress: string[]} {
|
|
const controller = new AbortController()
|
|
const progress: string[] = []
|
|
return {controller, progress, call: {signal: controller.signal, progress: message => progress.push(message)}}
|
|
}
|
|
|
|
export interface FakeHooks {
|
|
calls: string[]
|
|
hooks: ActionHooks
|
|
performs: {resolve: () => void; reject: (error: Error) => void}[]
|
|
approvers: number
|
|
}
|
|
|
|
export function fakeHooks(): FakeHooks {
|
|
const fake: FakeHooks = {calls: [], performs: [], approvers: 1, hooks: undefined as unknown as ActionHooks}
|
|
fake.hooks = {
|
|
refusal: () => (fake.approvers > 0 ? undefined : "no approver"),
|
|
saveCounter: async next => {
|
|
fake.calls.push(`counter ${next}`)
|
|
},
|
|
postCard: async action => {
|
|
fake.calls.push(`card ${action.n}`)
|
|
return 1000 + action.n
|
|
},
|
|
perform: action =>
|
|
new Promise((resolve, reject) => {
|
|
fake.calls.push(`perform ${action.n}`)
|
|
fake.performs.push({resolve, reject})
|
|
}),
|
|
outcome: async action => {
|
|
fake.calls.push(`outcome ${action.n} ${action.state}${action.interrupted ? " interrupted" : ""}`)
|
|
},
|
|
}
|
|
return fake
|
|
}
|
|
|
|
export function actionsWith(mode: "requests" | "host", fake: FakeHooks, next = 1, expireHours = 23): Actions {
|
|
return new Actions(mode, expireHours, next, fake.hooks)
|
|
}
|
|
|
|
export async function settled(): Promise<void> {
|
|
for (let i = 0; i < 10; i++) await Promise.resolve()
|
|
}
|
|
|
|
interface FakeChatData {
|
|
chatInfo: T.ChatInfo
|
|
items: T.ChatItem[]
|
|
}
|
|
|
|
const library = api.ChatApi.prototype
|
|
|
|
export class FakeChat {
|
|
apiGetChat = library.apiGetChat
|
|
apiChatItemsRead = library.apiChatItemsRead
|
|
apiGetChats = library.apiGetChats
|
|
apiContactInfo = library.apiContactInfo
|
|
apiConnectPlan = library.apiConnectPlan
|
|
apiPrepareContact = library.apiPrepareContact
|
|
apiPrepareGroup = library.apiPrepareGroup
|
|
apiConnectPreparedContact = library.apiConnectPreparedContact
|
|
apiConnectPreparedGroup = library.apiConnectPreparedGroup
|
|
apiConnectContactViaAddress = library.apiConnectContactViaAddress
|
|
apiConnectPreparedLink = library.apiConnectPreparedLink
|
|
apiReceiveFile = library.apiReceiveFile
|
|
apiCancelFile = library.apiCancelFile
|
|
apiGetChatItemInfo = library.apiGetChatItemInfo
|
|
apiGetUserServers = library.apiGetUserServers
|
|
commands: string[] = []
|
|
sent: {chat: [T.ChatType, number]; messages: T.ComposedMessage[]}[] = []
|
|
updates: {chatId: number; itemId: number; text: string}[] = []
|
|
replies: string[] = []
|
|
deleted: {chatType: T.ChatType; chatId: number; mode?: T.ChatDeleteMode}[] = []
|
|
left: number[] = []
|
|
customData: {chat: string; customData?: object}[] = []
|
|
profiles: {groupId: number; groupProfile: T.GroupProfile}[] = []
|
|
members = new Map<number, T.GroupMember[]>()
|
|
chats = new Map<string, FakeChatData>()
|
|
responses = new Map<string, object>()
|
|
nextItemId = 500
|
|
|
|
async sendChatCmd(cmd: string): Promise<ChatResponse> {
|
|
this.commands.push(cmd)
|
|
const fixed = this.responses.get(cmd)
|
|
if (fixed !== undefined) return fixed as ChatResponse
|
|
const getChat = /^\/_get chat ([@#]\d+) (.+)$/.exec(cmd)
|
|
if (getChat) return this.getChat(getChat[1], getChat[2])
|
|
if (cmd.startsWith("/_get chats ")) {
|
|
const [, before, count] = /(?: before=(\S+))? count=(\d+) /.exec(cmd)!
|
|
const chats = [...this.chats.values()]
|
|
.filter(({chatInfo}) => before === undefined || chatTs(chatInfo) < before)
|
|
.slice(0, Number(count))
|
|
.map(({chatInfo, items}) => ({chatInfo, chatItems: [], chatStats: chatStats(items)}))
|
|
return structuredClone({type: "apiChats", user, chats}) as unknown as ChatResponse
|
|
}
|
|
if (cmd.startsWith("/_read chat items ")) {
|
|
const [, ref, ids] = /^\/_read chat items ([@#]\d+) (.+)$/.exec(cmd)!
|
|
const data = this.chats.get(ref)!
|
|
for (const id of ids.split(",").map(Number)) {
|
|
const found = data.items.find(({meta}) => meta.itemId === id)
|
|
if (found) found.meta.itemStatus = {type: "rcvRead"}
|
|
}
|
|
return {type: "itemsReadForChat"} as unknown as ChatResponse
|
|
}
|
|
return {type: "cmdOk"}
|
|
}
|
|
|
|
async apiSendMessages(chat: [T.ChatType, number], messages: T.ComposedMessage[]): Promise<T.AChatItem[]> {
|
|
this.sent.push({chat, messages})
|
|
const ref = `${chat[0] === T.ChatType.Direct ? "@" : "#"}${chat[1]}`
|
|
const chatInfo = this.chats.get(ref)?.chatInfo ?? groupChat(group(chat[1], "control"))
|
|
return messages.map(({msgContent}) => event(chatInfo, item(this.nextItemId++, msgContent.text, chatInfo.type === "direct" ? {type: "directSnd"} : {type: "groupSnd"})))
|
|
}
|
|
|
|
async apiUpdateChatItem(_type: T.ChatType, chatId: number, itemId: number, msgContent: T.MsgContent): Promise<T.ChatItem> {
|
|
this.updates.push({chatId, itemId, text: msgContent.text})
|
|
return item(itemId, msgContent.text, {type: "groupSnd"})
|
|
}
|
|
|
|
async apiSendTextReply(_ci: T.AChatItem, text: string): Promise<T.AChatItem[]> {
|
|
this.replies.push(text)
|
|
return []
|
|
}
|
|
|
|
async apiListMembers(groupId: number): Promise<T.GroupMember[]> {
|
|
return this.members.get(groupId) ?? []
|
|
}
|
|
|
|
async apiDeleteChat(chatType: T.ChatType, chatId: number, mode?: T.ChatDeleteMode): Promise<void> {
|
|
this.deleted.push({chatType, chatId, mode})
|
|
this.chats.delete(`${chatType === T.ChatType.Direct ? "@" : "#"}${chatId}`)
|
|
}
|
|
|
|
async apiLeaveGroup(groupId: number): Promise<T.GroupInfo> {
|
|
this.left.push(groupId)
|
|
const {groupInfo} = this.chats.get(`#${groupId}`)!.chatInfo as T.ChatInfo.Group
|
|
groupInfo.membership = {...groupInfo.membership, memberStatus: T.GroupMemberStatus.Left}
|
|
return groupInfo
|
|
}
|
|
|
|
async apiSetContactCustomData(contactId: number, customData?: object): Promise<void> {
|
|
this.setCustomData(`@${contactId}`, customData)
|
|
}
|
|
|
|
async apiSetGroupCustomData(groupId: number, customData?: object): Promise<void> {
|
|
this.setCustomData(`#${groupId}`, customData)
|
|
}
|
|
|
|
async apiDeleteGroupLink(): Promise<void> {}
|
|
|
|
async apiUpdateGroupProfile(groupId: number, groupProfile: T.GroupProfile): Promise<T.GroupInfo> {
|
|
this.profiles.push({groupId, groupProfile})
|
|
return {...group(groupId, groupProfile.displayName), groupProfile}
|
|
}
|
|
|
|
api(): api.ChatApi {
|
|
return this as unknown as api.ChatApi
|
|
}
|
|
|
|
addItem(ref: string, chatItem: T.ChatItem): T.AChatItem {
|
|
const data = this.chats.get(ref)!
|
|
data.items.push(chatItem)
|
|
return event(data.chatInfo, structuredClone(chatItem))
|
|
}
|
|
|
|
private setCustomData(ref: string, customData: object | undefined): void {
|
|
this.customData.push({chat: ref, customData})
|
|
const {chatInfo} = this.chats.get(ref)!
|
|
if (chatInfo.type === "direct") chatInfo.contact.customData = customData
|
|
else (chatInfo as T.ChatInfo.Group).groupInfo.customData = customData
|
|
}
|
|
|
|
private getChat(ref: string, pagination: string): ChatResponse {
|
|
const data = this.chats.get(ref)
|
|
if (data === undefined) throw storeError(ref)
|
|
const around = /^around=(\d+) count=0$/.exec(pagination)
|
|
const after = /^after=(\d+) count=(\d+)$/.exec(pagination)
|
|
const before = /^before=(\d+) count=(\d+)$/.exec(pagination)
|
|
const last = /^count=(\d+)$/.exec(pagination)
|
|
const chatItems = around
|
|
? data.items.filter(({meta}) => meta.itemId === Number(around[1]))
|
|
: after
|
|
? data.items.filter(({meta}) => meta.itemId > Number(after[1])).slice(0, Number(after[2]))
|
|
: before
|
|
? data.items.filter(({meta}) => meta.itemId < Number(before[1])).slice(-Number(before[2]))
|
|
: last && Number(last[1]) > 0
|
|
? data.items.slice(-Number(last[1]))
|
|
: []
|
|
if (around && chatItems.length === 0) throw storeError(ref)
|
|
return structuredClone({type: "apiChat", chat: {chatInfo: data.chatInfo, chatItems, chatStats: chatStats(data.items)}}) as unknown as ChatResponse
|
|
}
|
|
}
|
|
|
|
function chatTs(chatInfo: T.ChatInfo): string {
|
|
return chatInfo.type === "direct" ? chatInfo.contact.updatedAt : (chatInfo as T.ChatInfo.Group).groupInfo.updatedAt
|
|
}
|
|
|
|
function chatStats(items: T.ChatItem[]): T.ChatStats {
|
|
const unread = items.filter(({meta}) => meta.itemStatus.type === "rcvNew")
|
|
return {unreadCount: unread.length, minUnreadItemId: unread[0]?.meta.itemId ?? 0, unreadMentions: 0, reportsCount: 0, unreadChat: false}
|
|
}
|
|
|
|
function storeError(ref: string): Error {
|
|
return new core.ChatAPIError("Chat command error", {type: "errorStore", storeError: {type: "contactNotFound", contactId: Number(ref.slice(1))}})
|
|
}
|