Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions openclaw.plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,18 @@
},
"additionalProperties": false
},
"files": {
"type": "object",
"properties": {
"inboundDir": {
"type": "string"
},
"outboundDir": {
"type": "string"
}
},
"additionalProperties": false
},
"experimentalChannels": {
"type": "boolean"
},
Expand Down Expand Up @@ -406,6 +418,18 @@
},
"additionalProperties": false
},
"files": {
"type": "object",
"properties": {
"inboundDir": {
"type": "string"
},
"outboundDir": {
"type": "string"
}
},
"additionalProperties": false
},
"experimentalChannels": {
"type": "boolean"
},
Expand Down
4 changes: 4 additions & 0 deletions src/actions/message-actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { readStringArrayParam } from "openclaw/plugin-sdk/channel-actions";
import type { ChannelMessageActionName } from "openclaw/plugin-sdk/channel-contract";
import type { OpenClawConfig } from "openclaw/plugin-sdk/channel-core";
import { normalizePollInput, resolvePollMaxSelections } from "openclaw/plugin-sdk/poll-runtime";
import { cleanupStagedOutboundFiles } from "../channel/media/outbound-files.js";
import { buildComposedMessages } from "../channel/media/simplex-media.js";
import { renderSimplexPollText } from "../channel/messaging/simplex-outbound.js";
import { resolveSimplexChatItemId } from "../simplex/runtime/api.js";
Expand Down Expand Up @@ -61,6 +62,9 @@ async function sendActionComposedMessages(params: {
ttl: params.ttl ?? params.account.config.messageTtlSeconds,
}),
});
// Files staged into a shared outbound dir are no longer needed once the
// send command has been issued (simplex-chat has consumed the path).
await cleanupStagedOutboundFiles(params.composedMessages);
return { messageId: resolveSimplexChatItemId(chatItems[0]) };
}

Expand Down
25 changes: 25 additions & 0 deletions src/channel/events/simplex-inbound-files.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,10 @@ import { setSimplexRuntime } from "../runtime.js";
import {
dispatchInbound,
finalizePendingFile,
markFileAccepted,
type PendingInboundFile,
queuePendingFile,
shouldRetryFileAccept,
} from "./simplex-inbound-files.js";

const simplexClientMock = vi.hoisted(() => ({
Expand Down Expand Up @@ -135,6 +137,29 @@ describe("simplex inbound live replies", () => {
expect(cancelFile).not.toHaveBeenCalled();
});

it("tracks accept retries for queued pending files", async () => {
vi.useFakeTimers();
installRuntime(async () => undefined);
const current = pending();
current.fileId = 42;
current.client = fileClient(vi.fn(async () => undefined));

// Unknown file: nothing queued, nothing to retry.
expect(shouldRetryFileAccept(current.account.accountId, 42)).toBe(false);

// Queued but not yet accepted (initial /freceive failed): retry wanted.
queuePendingFile({ pending: current, accountId: current.account.accountId, fileId: 42 });
expect(shouldRetryFileAccept(current.account.accountId, 42)).toBe(true);

// Accepted: no further retries.
markFileAccepted(current.account.accountId, 42);
expect(shouldRetryFileAccept(current.account.accountId, 42)).toBe(false);

// Finalized: entry removed entirely.
await finalizePendingFile({ accountId: current.account.accountId, fileId: 42 });
expect(shouldRetryFileAccept(current.account.accountId, 42)).toBe(false);
});

it("replaces an older pending file timeout for the same key", async () => {
vi.useFakeTimers();
installRuntime(async () => undefined);
Expand Down
103 changes: 96 additions & 7 deletions src/channel/events/simplex-inbound-files.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
import { readFile } from "node:fs/promises";
import path from "node:path";
import type { ChannelAccountSnapshot } from "openclaw/plugin-sdk/channel-contract";
import type { OpenClawConfig } from "openclaw/plugin-sdk/channel-core";
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
import { SIMPLEX_CHANNEL_ID } from "../../constants.js";
import type { SimplexClient } from "../../simplex/runtime/client.js";
import type { ResolvedSimplexAccount } from "../../types/config.js";
import { resolveSimplexMediaMaxBytes } from "../media/simplex-media.js";
import {
createSimplexLiveReplyController,
type SimplexLiveReplyPayload,
Expand All @@ -11,6 +15,27 @@ import { getSimplexRuntime } from "../runtime.js";

const PENDING_FILE_TIMEOUT_MS = 90_000;

/**
* Where simplex-chat saves received files when started without --files-folder.
*/
const DEFAULT_INBOUND_DIR = "/tmp";

/**
* Directory where the simplex-chat runtime saves received files (its
* --files-folder). The WS API reports received files with a path relative to
* this directory (`fileSource.filePath` is typically just the file name), so
* the plugin needs it to locate the file on disk. Defaults to /tmp, where
* simplex-chat saves files when no --files-folder is configured.
*/
function resolveSimplexInboundDir(params: {
cfg: OpenClawConfig;
accountId?: string | null;
}): string {
const channel = params.cfg.channels?.[SIMPLEX_CHANNEL_ID];
const account = params.accountId ? channel?.accounts?.[params.accountId] : undefined;
return account?.files?.inboundDir ?? channel?.files?.inboundDir ?? DEFAULT_INBOUND_DIR;
}

type SimplexReplyPayload = {
text?: string;
mediaUrl?: string;
Expand All @@ -37,6 +62,7 @@ export type PendingInboundFile = {
type QueuedPendingInboundFile = {
pending: PendingInboundFile;
timeout: ReturnType<typeof setTimeout>;
accepted: boolean;
};

const pendingFiles = new Map<string, QueuedPendingInboundFile>();
Expand All @@ -45,16 +71,20 @@ function pendingKey(accountId: string, fileId: number): string {
return `${accountId}:${fileId}`;
}

export function isFileAutoAcceptEnabled(account: ResolvedSimplexAccount): boolean {
return (
account.config.filePolicy?.autoAccept ?? account.config.connection?.autoAcceptFiles !== false
);
}

export async function requestFileDownload(params: {
fileId: number;
account: ResolvedSimplexAccount;
client: SimplexClient;
runtime: RuntimeEnv;
}): Promise<boolean> {
const { fileId, account, client, runtime } = params;
const autoAccept =
account.config.filePolicy?.autoAccept ?? account.config.connection?.autoAcceptFiles !== false;
if (!autoAccept) {
if (!isFileAutoAcceptEnabled(account)) {
return false;
}
try {
Expand All @@ -66,6 +96,23 @@ export async function requestFileDownload(params: {
return true;
}

/**
* Whether a pending file still needs its download accepted. The initial
* /freceive issued on newChatItems can fail because the XFTP file
* description is not ready yet; the accept is retried on rcvFileDescrReady.
*/
export function shouldRetryFileAccept(accountId: string, fileId: number): boolean {
const queued = pendingFiles.get(pendingKey(accountId, fileId));
return Boolean(queued && !queued.accepted);
}

export function markFileAccepted(accountId: string, fileId: number): void {
const queued = pendingFiles.get(pendingKey(accountId, fileId));
if (queued) {
queued.accepted = true;
}
}

export function queuePendingFile(params: {
pending: PendingInboundFile;
accountId: string;
Expand Down Expand Up @@ -99,13 +146,14 @@ export function queuePendingFile(params: {
});
}, PENDING_FILE_TIMEOUT_MS);
timeout.unref?.();
pendingFiles.set(key, { pending, timeout });
pendingFiles.set(key, { pending, timeout, accepted: false });
}

export async function finalizePendingFile(params: {
accountId: string;
fileId: number;
filePath?: string;
fileName?: string;
}): Promise<void> {
const queued = pendingFiles.get(pendingKey(params.accountId, params.fileId));
if (!queued) {
Expand All @@ -114,10 +162,50 @@ export async function finalizePendingFile(params: {
pendingFiles.delete(pendingKey(params.accountId, params.fileId));
clearTimeout(queued.timeout);
const { pending } = queued;
const mediaPath = params.filePath?.trim() || undefined;
let rawPath = params.filePath?.trim() || undefined;
// The WS API reports received files relative to the runtime's
// --files-folder (fileSource.filePath is typically just the file name).
// Resolve against the configured inbound dir (default: /tmp, where
// simplex-chat saves files when no --files-folder is configured).
if (rawPath && !path.isAbsolute(rawPath)) {
rawPath = path.join(
resolveSimplexInboundDir({
cfg: pending.cfg,
accountId: pending.account.accountId,
}),
rawPath
);
}
let mediaPath: string | undefined;
let mediaType: string | undefined;
if (mediaPath) {
mediaType = await getSimplexRuntime().media.detectMime({ filePath: mediaPath });
if (rawPath) {
const core = getSimplexRuntime();
// Stage the file into OpenClaw's shared media store (media/inbound/*),
// like other bundled channels. The raw path from the SimpleX runtime
// (e.g. /tmp/... or ~/.simplex/files/...) lives outside the store,
// so media tools and sandboxed workspaces would reject it.
try {
mediaType = await core.media.detectMime({ filePath: rawPath });
const buffer = await readFile(rawPath);
const saved = await core.channel.media.saveMediaBuffer(
buffer,
mediaType,
"inbound",
resolveSimplexMediaMaxBytes({
cfg: pending.cfg,
accountId: pending.account.accountId,
}),
params.fileName ?? path.basename(rawPath),
rawPath
);
mediaPath = saved.path;
mediaType = saved.contentType ?? mediaType;
} catch (err) {
pending.runtime.error?.(
`[${params.accountId}] SimpleX inbound media staging failed, using raw path: ${String(err)}`
);
mediaPath = rawPath;
}
}
await dispatchInbound({ pending, mediaPath, mediaType });
}
Expand All @@ -143,6 +231,7 @@ export async function dispatchInbound(params: {
const ctxPayload = {
...pending.ctxPayload,
MediaPath: mediaPath,
MediaPaths: mediaPath ? [mediaPath] : undefined,
MediaType: mediaType,
MediaUrl: mediaPath,
};
Expand Down
31 changes: 27 additions & 4 deletions src/channel/events/simplex-monitor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,12 @@ import {
dispatchInbound,
finalizePendingFile,
hasPendingFile,
isFileAutoAcceptEnabled,
markFileAccepted,
type PendingInboundFile,
queuePendingFile,
requestFileDownload,
shouldRetryFileAccept,
} from "./simplex-inbound-files.js";

export type SimplexMonitorOpts = {
Expand Down Expand Up @@ -163,8 +166,19 @@ async function handleSimplexEvent(params: {

if (event.type === "rcvFileDescrReady") {
const fileId = (event as { rcvFileTransfer?: { fileId?: unknown } })?.rcvFileTransfer?.fileId;
if (typeof fileId === "number" && Number.isInteger(fileId) && fileId > 0) {
await requestFileDownload({ fileId, account, client, runtime });
if (
typeof fileId === "number" &&
Number.isInteger(fileId) &&
fileId > 0 &&
// Retry the accept for a queued pending file whose initial /freceive
// failed (issued on newChatItems, before the file description was
// ready). Skip files we never queued (e.g. oversize) or that were
// already accepted.
shouldRetryFileAccept(account.accountId, fileId)
) {
if (await requestFileDownload({ fileId, account, client, runtime })) {
markFileAccepted(account.accountId, fileId);
}
}
return;
}
Expand All @@ -179,6 +193,7 @@ async function handleSimplexEvent(params: {
accountId: account.accountId,
fileId,
filePath,
fileName: typeof file?.fileName === "string" ? file.fileName : undefined,
});
}
return;
Expand Down Expand Up @@ -328,9 +343,17 @@ async function handleSimplexEvent(params: {
);
continue;
}
const accepted = await requestFileDownload({ fileId, account, client, runtime });
if (accepted) {
if (isFileAutoAcceptEnabled(account)) {
// Queue BEFORE attempting the accept: at this point the file may
// still be an invitation whose XFTP file description is not ready,
// so /freceive can fail. The accept is retried on rcvFileDescrReady
// and the dispatch happens on rcvFileComplete with the downloaded
// file's absolute path. If the transfer never completes, the
// pending-file timeout dispatches without media.
queuePendingFile({ pending, accountId: account.accountId, fileId });
if (await requestFileDownload({ fileId, account, client, runtime })) {
markFileAccepted(account.accountId, fileId);
}
continue;
}
}
Expand Down
Loading
Loading