mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-11 01:04:32 +00:00
refactor: centralize delivery/path/media/version lifecycle
This commit is contained in:
@@ -7,7 +7,8 @@ import { openBoundaryFile, type BoundaryFileOpenResult } from "../infra/boundary
|
||||
import { writeFileWithinRoot } from "../infra/fs-safe.js";
|
||||
import { PATH_ALIAS_POLICIES, type PathAliasPolicy } from "../infra/path-alias-guards.js";
|
||||
import { applyUpdateHunk } from "./apply-patch-update.js";
|
||||
import { assertSandboxPath, resolveSandboxInputPath } from "./sandbox-paths.js";
|
||||
import { toRelativeSandboxPath, resolvePathFromInput } from "./path-policy.js";
|
||||
import { assertSandboxPath } from "./sandbox-paths.js";
|
||||
import type { SandboxFsBridge } from "./sandbox/fs-bridge.js";
|
||||
|
||||
const BEGIN_PATCH_MARKER = "*** Begin Patch";
|
||||
@@ -261,7 +262,7 @@ function resolvePatchFileOps(options: ApplyPatchOptions): PatchFileOps {
|
||||
await fs.writeFile(filePath, content, "utf8");
|
||||
return;
|
||||
}
|
||||
const relative = toRelativeWorkspacePath(options.cwd, filePath);
|
||||
const relative = toRelativeSandboxPath(options.cwd, filePath);
|
||||
await writeFileWithinRoot({
|
||||
rootDir: options.cwd,
|
||||
relativePath: relative,
|
||||
@@ -318,27 +319,13 @@ async function resolvePatchPath(
|
||||
allowFinalHardlinkForUnlink: aliasPolicy.allowFinalHardlinkForUnlink,
|
||||
})
|
||||
).resolved
|
||||
: resolvePathFromCwd(filePath, options.cwd);
|
||||
: resolvePathFromInput(filePath, options.cwd);
|
||||
return {
|
||||
resolved,
|
||||
display: toDisplayPath(resolved, options.cwd),
|
||||
};
|
||||
}
|
||||
|
||||
function resolvePathFromCwd(filePath: string, cwd: string): string {
|
||||
return path.normalize(resolveSandboxInputPath(filePath, cwd));
|
||||
}
|
||||
|
||||
function toRelativeWorkspacePath(workspaceRoot: string, absolutePath: string): string {
|
||||
const rootResolved = path.resolve(workspaceRoot);
|
||||
const resolved = path.resolve(absolutePath);
|
||||
const relative = path.relative(rootResolved, resolved);
|
||||
if (!relative || relative === "." || relative.startsWith("..") || path.isAbsolute(relative)) {
|
||||
throw new Error(`Path escapes sandbox root (${workspaceRoot}): ${absolutePath}`);
|
||||
}
|
||||
return relative;
|
||||
}
|
||||
|
||||
function assertBoundaryRead(
|
||||
opened: BoundaryFileOpenResult,
|
||||
targetPath: string,
|
||||
|
||||
72
src/agents/path-policy.ts
Normal file
72
src/agents/path-policy.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import path from "node:path";
|
||||
import { resolveSandboxInputPath } from "./sandbox-paths.js";
|
||||
|
||||
type RelativePathOptions = {
|
||||
allowRoot?: boolean;
|
||||
cwd?: string;
|
||||
boundaryLabel?: string;
|
||||
includeRootInError?: boolean;
|
||||
};
|
||||
|
||||
function toRelativePathUnderRoot(params: {
|
||||
root: string;
|
||||
candidate: string;
|
||||
options?: RelativePathOptions;
|
||||
}): string {
|
||||
const rootResolved = path.resolve(params.root);
|
||||
const resolvedCandidate = path.resolve(
|
||||
resolveSandboxInputPath(params.candidate, params.options?.cwd ?? params.root),
|
||||
);
|
||||
const relative = path.relative(rootResolved, resolvedCandidate);
|
||||
if (relative === "" || relative === ".") {
|
||||
if (params.options?.allowRoot) {
|
||||
return "";
|
||||
}
|
||||
const boundary = params.options?.boundaryLabel ?? "workspace root";
|
||||
const suffix = params.options?.includeRootInError ? ` (${rootResolved})` : "";
|
||||
throw new Error(`Path escapes ${boundary}${suffix}: ${params.candidate}`);
|
||||
}
|
||||
if (relative.startsWith("..") || path.isAbsolute(relative)) {
|
||||
const boundary = params.options?.boundaryLabel ?? "workspace root";
|
||||
const suffix = params.options?.includeRootInError ? ` (${rootResolved})` : "";
|
||||
throw new Error(`Path escapes ${boundary}${suffix}: ${params.candidate}`);
|
||||
}
|
||||
return relative;
|
||||
}
|
||||
|
||||
export function toRelativeWorkspacePath(
|
||||
root: string,
|
||||
candidate: string,
|
||||
options?: Pick<RelativePathOptions, "allowRoot" | "cwd">,
|
||||
): string {
|
||||
return toRelativePathUnderRoot({
|
||||
root,
|
||||
candidate,
|
||||
options: {
|
||||
allowRoot: options?.allowRoot,
|
||||
cwd: options?.cwd,
|
||||
boundaryLabel: "workspace root",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function toRelativeSandboxPath(
|
||||
root: string,
|
||||
candidate: string,
|
||||
options?: Pick<RelativePathOptions, "allowRoot" | "cwd">,
|
||||
): string {
|
||||
return toRelativePathUnderRoot({
|
||||
root,
|
||||
candidate,
|
||||
options: {
|
||||
allowRoot: options?.allowRoot,
|
||||
cwd: options?.cwd,
|
||||
boundaryLabel: "sandbox root",
|
||||
includeRootInError: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function resolvePathFromInput(filePath: string, cwd: string): string {
|
||||
return path.normalize(resolveSandboxInputPath(filePath, cwd));
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
import { detectMime } from "../media/mime.js";
|
||||
import { sniffMimeFromBase64 } from "../media/sniff-mime-from-base64.js";
|
||||
import type { ImageSanitizationLimits } from "./image-sanitization.js";
|
||||
import { toRelativeWorkspacePath } from "./path-policy.js";
|
||||
import type { AnyAgentTool } from "./pi-tools.types.js";
|
||||
import { assertSandboxPath } from "./sandbox-paths.js";
|
||||
import type { SandboxFsBridge } from "./sandbox/fs-bridge.js";
|
||||
@@ -784,13 +785,13 @@ function createHostWriteOperations(root: string, options?: { workspaceOnly?: boo
|
||||
// When workspaceOnly is true, enforce workspace boundary
|
||||
return {
|
||||
mkdir: async (dir: string) => {
|
||||
const relative = toRelativePathInRoot(root, dir, { allowRoot: true });
|
||||
const relative = toRelativeWorkspacePath(root, dir, { allowRoot: true });
|
||||
const resolved = relative ? path.resolve(root, relative) : path.resolve(root);
|
||||
await assertSandboxPath({ filePath: resolved, cwd: root, root });
|
||||
await fs.mkdir(resolved, { recursive: true });
|
||||
},
|
||||
writeFile: async (absolutePath: string, content: string) => {
|
||||
const relative = toRelativePathInRoot(root, absolutePath);
|
||||
const relative = toRelativeWorkspacePath(root, absolutePath);
|
||||
await writeFileWithinRoot({
|
||||
rootDir: root,
|
||||
relativePath: relative,
|
||||
@@ -827,7 +828,7 @@ function createHostEditOperations(root: string, options?: { workspaceOnly?: bool
|
||||
// When workspaceOnly is true, enforce workspace boundary
|
||||
return {
|
||||
readFile: async (absolutePath: string) => {
|
||||
const relative = toRelativePathInRoot(root, absolutePath);
|
||||
const relative = toRelativeWorkspacePath(root, absolutePath);
|
||||
const safeRead = await readFileWithinRoot({
|
||||
rootDir: root,
|
||||
relativePath: relative,
|
||||
@@ -835,7 +836,7 @@ function createHostEditOperations(root: string, options?: { workspaceOnly?: bool
|
||||
return safeRead.buffer;
|
||||
},
|
||||
writeFile: async (absolutePath: string, content: string) => {
|
||||
const relative = toRelativePathInRoot(root, absolutePath);
|
||||
const relative = toRelativeWorkspacePath(root, absolutePath);
|
||||
await writeFileWithinRoot({
|
||||
rootDir: root,
|
||||
relativePath: relative,
|
||||
@@ -846,7 +847,7 @@ function createHostEditOperations(root: string, options?: { workspaceOnly?: bool
|
||||
access: async (absolutePath: string) => {
|
||||
let relative: string;
|
||||
try {
|
||||
relative = toRelativePathInRoot(root, absolutePath);
|
||||
relative = toRelativeWorkspacePath(root, absolutePath);
|
||||
} catch {
|
||||
// Path escapes workspace root. Don't throw here – the upstream
|
||||
// library replaces any `access` error with a misleading "File not
|
||||
@@ -876,26 +877,6 @@ function createHostEditOperations(root: string, options?: { workspaceOnly?: bool
|
||||
} as const;
|
||||
}
|
||||
|
||||
function toRelativePathInRoot(
|
||||
root: string,
|
||||
candidate: string,
|
||||
options?: { allowRoot?: boolean },
|
||||
): string {
|
||||
const rootResolved = path.resolve(root);
|
||||
const resolved = path.resolve(candidate);
|
||||
const relative = path.relative(rootResolved, resolved);
|
||||
if (relative === "" || relative === ".") {
|
||||
if (options?.allowRoot) {
|
||||
return "";
|
||||
}
|
||||
throw new Error(`Path escapes workspace root: ${candidate}`);
|
||||
}
|
||||
if (relative.startsWith("..") || path.isAbsolute(relative)) {
|
||||
throw new Error(`Path escapes workspace root: ${candidate}`);
|
||||
}
|
||||
return relative;
|
||||
}
|
||||
|
||||
function createFsAccessError(code: string, filePath: string): NodeJS.ErrnoException {
|
||||
const error = new Error(`Sandbox FS error (${code}): ${filePath}`) as NodeJS.ErrnoException;
|
||||
error.code = code;
|
||||
|
||||
@@ -59,7 +59,7 @@ export type OutboundSendDeps = {
|
||||
sendMSTeams?: (
|
||||
to: string,
|
||||
text: string,
|
||||
opts?: { mediaUrl?: string },
|
||||
opts?: { mediaUrl?: string; mediaLocalRoots?: readonly string[] },
|
||||
) => Promise<{ messageId: string; conversationId: string }>;
|
||||
};
|
||||
|
||||
|
||||
@@ -234,6 +234,8 @@ export {
|
||||
sendMediaWithLeadingCaption,
|
||||
} from "./reply-payload.js";
|
||||
export type { OutboundReplyPayload } from "./reply-payload.js";
|
||||
export type { OutboundMediaLoadOptions } from "./outbound-media.js";
|
||||
export { loadOutboundMediaFromUrl } from "./outbound-media.js";
|
||||
export { resolveChannelAccountConfigBasePath } from "./config-paths.js";
|
||||
export { buildMediaPayload } from "../channels/plugins/media-payload.js";
|
||||
export type { MediaPayload, MediaPayloadInput } from "../channels/plugins/media-payload.js";
|
||||
|
||||
43
src/plugin-sdk/outbound-media.test.ts
Normal file
43
src/plugin-sdk/outbound-media.test.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { loadOutboundMediaFromUrl } from "./outbound-media.js";
|
||||
|
||||
const loadWebMediaMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("../web/media.js", () => ({
|
||||
loadWebMedia: loadWebMediaMock,
|
||||
}));
|
||||
|
||||
describe("loadOutboundMediaFromUrl", () => {
|
||||
it("forwards maxBytes and mediaLocalRoots to loadWebMedia", async () => {
|
||||
loadWebMediaMock.mockResolvedValueOnce({
|
||||
buffer: Buffer.from("x"),
|
||||
kind: "image",
|
||||
contentType: "image/png",
|
||||
});
|
||||
|
||||
await loadOutboundMediaFromUrl("file:///tmp/image.png", {
|
||||
maxBytes: 1024,
|
||||
mediaLocalRoots: ["/tmp/workspace-agent"],
|
||||
});
|
||||
|
||||
expect(loadWebMediaMock).toHaveBeenCalledWith("file:///tmp/image.png", {
|
||||
maxBytes: 1024,
|
||||
localRoots: ["/tmp/workspace-agent"],
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps options optional", async () => {
|
||||
loadWebMediaMock.mockResolvedValueOnce({
|
||||
buffer: Buffer.from("x"),
|
||||
kind: "image",
|
||||
contentType: "image/png",
|
||||
});
|
||||
|
||||
await loadOutboundMediaFromUrl("https://example.com/image.png");
|
||||
|
||||
expect(loadWebMediaMock).toHaveBeenCalledWith("https://example.com/image.png", {
|
||||
maxBytes: undefined,
|
||||
localRoots: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
16
src/plugin-sdk/outbound-media.ts
Normal file
16
src/plugin-sdk/outbound-media.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { loadWebMedia } from "../web/media.js";
|
||||
|
||||
export type OutboundMediaLoadOptions = {
|
||||
maxBytes?: number;
|
||||
mediaLocalRoots?: readonly string[];
|
||||
};
|
||||
|
||||
export async function loadOutboundMediaFromUrl(
|
||||
mediaUrl: string,
|
||||
options: OutboundMediaLoadOptions = {},
|
||||
) {
|
||||
return await loadWebMedia(mediaUrl, {
|
||||
maxBytes: options.maxBytes,
|
||||
localRoots: options.mediaLocalRoots,
|
||||
});
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterAll, afterEach, describe, expect, it } from "vitest";
|
||||
import { withEnv } from "../test-utils/env.js";
|
||||
import { getGlobalHookRunner, resetGlobalHookRunner } from "./hook-runner-global.js";
|
||||
import { __testing, loadOpenClawPlugins } from "./loader.js";
|
||||
|
||||
type TempPlugin = { dir: string; file: string; id: string };
|
||||
@@ -295,6 +296,36 @@ describe("loadOpenClawPlugins", () => {
|
||||
expect(Object.keys(registry.gatewayHandlers)).toContain("allowed.ping");
|
||||
});
|
||||
|
||||
it("re-initializes global hook runner when serving registry from cache", () => {
|
||||
process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = "/nonexistent/bundled/plugins";
|
||||
const plugin = writePlugin({
|
||||
id: "cache-hook-runner",
|
||||
body: `export default { id: "cache-hook-runner", register() {} };`,
|
||||
});
|
||||
|
||||
const options = {
|
||||
workspaceDir: plugin.dir,
|
||||
config: {
|
||||
plugins: {
|
||||
load: { paths: [plugin.file] },
|
||||
allow: ["cache-hook-runner"],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const first = loadOpenClawPlugins(options);
|
||||
expect(getGlobalHookRunner()).not.toBeNull();
|
||||
|
||||
resetGlobalHookRunner();
|
||||
expect(getGlobalHookRunner()).toBeNull();
|
||||
|
||||
const second = loadOpenClawPlugins(options);
|
||||
expect(second).toBe(first);
|
||||
expect(getGlobalHookRunner()).not.toBeNull();
|
||||
|
||||
resetGlobalHookRunner();
|
||||
});
|
||||
|
||||
it("loads plugins when source and root differ only by realpath alias", () => {
|
||||
process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = "/nonexistent/bundled/plugins";
|
||||
const plugin = writePlugin({
|
||||
|
||||
@@ -365,6 +365,11 @@ function warnAboutUntrackedLoadedPlugins(params: {
|
||||
}
|
||||
}
|
||||
|
||||
function activatePluginRegistry(registry: PluginRegistry, cacheKey: string): void {
|
||||
setActivePluginRegistry(registry, cacheKey);
|
||||
initializeGlobalHookRunner(registry);
|
||||
}
|
||||
|
||||
export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegistry {
|
||||
// Test env: default-disable plugins unless explicitly configured.
|
||||
// This keeps unit/gateway suites fast and avoids loading heavyweight plugin deps by accident.
|
||||
@@ -380,7 +385,7 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
|
||||
if (cacheEnabled) {
|
||||
const cached = registryCache.get(cacheKey);
|
||||
if (cached) {
|
||||
setActivePluginRegistry(cached, cacheKey);
|
||||
activatePluginRegistry(cached, cacheKey);
|
||||
return cached;
|
||||
}
|
||||
}
|
||||
@@ -711,8 +716,7 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
|
||||
if (cacheEnabled) {
|
||||
registryCache.set(cacheKey, registry);
|
||||
}
|
||||
setActivePluginRegistry(registry, cacheKey);
|
||||
initializeGlobalHookRunner(registry);
|
||||
activatePluginRegistry(registry, cacheKey);
|
||||
return registry;
|
||||
}
|
||||
|
||||
|
||||
@@ -30,6 +30,14 @@ import {
|
||||
resolveTelegramReplyId,
|
||||
type TelegramThreadSpec,
|
||||
} from "./helpers.js";
|
||||
import {
|
||||
createDeliveryProgress,
|
||||
markDelivered,
|
||||
markReplyApplied,
|
||||
resolveReplyToForSend,
|
||||
sendChunkedTelegramReplyText,
|
||||
type DeliveryProgress,
|
||||
} from "./reply-threading.js";
|
||||
import type { StickerMetadata, TelegramContext } from "./types.js";
|
||||
|
||||
const PARSE_ERR_RE = /can't parse entities|parse entities|find end of the entity/i;
|
||||
@@ -45,11 +53,6 @@ const TELEGRAM_MEDIA_SSRF_POLICY = {
|
||||
allowRfc2544BenchmarkRange: true,
|
||||
};
|
||||
|
||||
type DeliveryProgress = {
|
||||
hasReplied: boolean;
|
||||
hasDelivered: boolean;
|
||||
};
|
||||
|
||||
type ChunkTextFn = (markdown: string) => ReturnType<typeof markdownToTelegramChunks>;
|
||||
|
||||
function buildChunkTextResolver(params: {
|
||||
@@ -82,26 +85,6 @@ function buildChunkTextResolver(params: {
|
||||
};
|
||||
}
|
||||
|
||||
function resolveReplyToForSend(params: {
|
||||
replyToId?: number;
|
||||
replyToMode: ReplyToMode;
|
||||
progress: DeliveryProgress;
|
||||
}): number | undefined {
|
||||
return params.replyToId && (params.replyToMode === "all" || !params.progress.hasReplied)
|
||||
? params.replyToId
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function markReplyApplied(progress: DeliveryProgress, replyToId?: number): void {
|
||||
if (replyToId && !progress.hasReplied) {
|
||||
progress.hasReplied = true;
|
||||
}
|
||||
}
|
||||
|
||||
function markDelivered(progress: DeliveryProgress): void {
|
||||
progress.hasDelivered = true;
|
||||
}
|
||||
|
||||
async function deliverTextReply(params: {
|
||||
bot: Bot;
|
||||
chatId: string;
|
||||
@@ -117,29 +100,26 @@ async function deliverTextReply(params: {
|
||||
progress: DeliveryProgress;
|
||||
}): Promise<void> {
|
||||
const chunks = params.chunkText(params.replyText);
|
||||
for (let i = 0; i < chunks.length; i += 1) {
|
||||
const chunk = chunks[i];
|
||||
if (!chunk) {
|
||||
continue;
|
||||
}
|
||||
const shouldAttachButtons = i === 0 && params.replyMarkup;
|
||||
const replyToForChunk = resolveReplyToForSend({
|
||||
replyToId: params.replyToId,
|
||||
replyToMode: params.replyToMode,
|
||||
progress: params.progress,
|
||||
});
|
||||
await sendTelegramText(params.bot, params.chatId, chunk.html, params.runtime, {
|
||||
replyToMessageId: replyToForChunk,
|
||||
replyQuoteText: params.replyQuoteText,
|
||||
thread: params.thread,
|
||||
textMode: "html",
|
||||
plainText: chunk.text,
|
||||
linkPreview: params.linkPreview,
|
||||
replyMarkup: shouldAttachButtons ? params.replyMarkup : undefined,
|
||||
});
|
||||
markReplyApplied(params.progress, replyToForChunk);
|
||||
markDelivered(params.progress);
|
||||
}
|
||||
await sendChunkedTelegramReplyText({
|
||||
chunks,
|
||||
progress: params.progress,
|
||||
replyToId: params.replyToId,
|
||||
replyToMode: params.replyToMode,
|
||||
replyMarkup: params.replyMarkup,
|
||||
replyQuoteText: params.replyQuoteText,
|
||||
quoteOnlyOnFirstChunk: true,
|
||||
sendChunk: async ({ chunk, replyToMessageId, replyMarkup, replyQuoteText }) => {
|
||||
await sendTelegramText(params.bot, params.chatId, chunk.html, params.runtime, {
|
||||
replyToMessageId,
|
||||
replyQuoteText,
|
||||
thread: params.thread,
|
||||
textMode: "html",
|
||||
plainText: chunk.text,
|
||||
linkPreview: params.linkPreview,
|
||||
replyMarkup,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function sendPendingFollowUpText(params: {
|
||||
@@ -156,24 +136,23 @@ async function sendPendingFollowUpText(params: {
|
||||
progress: DeliveryProgress;
|
||||
}): Promise<void> {
|
||||
const chunks = params.chunkText(params.text);
|
||||
for (let i = 0; i < chunks.length; i += 1) {
|
||||
const chunk = chunks[i];
|
||||
const replyToForFollowUp = resolveReplyToForSend({
|
||||
replyToId: params.replyToId,
|
||||
replyToMode: params.replyToMode,
|
||||
progress: params.progress,
|
||||
});
|
||||
await sendTelegramText(params.bot, params.chatId, chunk.html, params.runtime, {
|
||||
replyToMessageId: replyToForFollowUp,
|
||||
thread: params.thread,
|
||||
textMode: "html",
|
||||
plainText: chunk.text,
|
||||
linkPreview: params.linkPreview,
|
||||
replyMarkup: i === 0 ? params.replyMarkup : undefined,
|
||||
});
|
||||
markReplyApplied(params.progress, replyToForFollowUp);
|
||||
markDelivered(params.progress);
|
||||
}
|
||||
await sendChunkedTelegramReplyText({
|
||||
chunks,
|
||||
progress: params.progress,
|
||||
replyToId: params.replyToId,
|
||||
replyToMode: params.replyToMode,
|
||||
replyMarkup: params.replyMarkup,
|
||||
sendChunk: async ({ chunk, replyToMessageId, replyMarkup }) => {
|
||||
await sendTelegramText(params.bot, params.chatId, chunk.html, params.runtime, {
|
||||
replyToMessageId,
|
||||
thread: params.thread,
|
||||
textMode: "html",
|
||||
plainText: chunk.text,
|
||||
linkPreview: params.linkPreview,
|
||||
replyMarkup,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function deliverMediaReply(params: {
|
||||
@@ -409,10 +388,7 @@ export async function deliverReplies(params: {
|
||||
/** Optional quote text for Telegram reply_parameters. */
|
||||
replyQuoteText?: string;
|
||||
}): Promise<{ delivered: boolean }> {
|
||||
const progress: DeliveryProgress = {
|
||||
hasReplied: false,
|
||||
hasDelivered: false,
|
||||
};
|
||||
const progress = createDeliveryProgress();
|
||||
const chunkText = buildChunkTextResolver({
|
||||
textLimit: params.textLimit,
|
||||
chunkMode: params.chunkMode ?? "length",
|
||||
@@ -679,24 +655,27 @@ async function sendTelegramVoiceFallbackText(opts: {
|
||||
replyQuoteText?: string;
|
||||
}): Promise<void> {
|
||||
const chunks = opts.chunkText(opts.text);
|
||||
let appliedReplyTo = false;
|
||||
for (let i = 0; i < chunks.length; i += 1) {
|
||||
const chunk = chunks[i];
|
||||
// Only apply reply reference, quote text, and buttons to the first chunk.
|
||||
const replyToForChunk = !appliedReplyTo ? opts.replyToId : undefined;
|
||||
await sendTelegramText(opts.bot, opts.chatId, chunk.html, opts.runtime, {
|
||||
replyToMessageId: replyToForChunk,
|
||||
replyQuoteText: !appliedReplyTo ? opts.replyQuoteText : undefined,
|
||||
thread: opts.thread,
|
||||
textMode: "html",
|
||||
plainText: chunk.text,
|
||||
linkPreview: opts.linkPreview,
|
||||
replyMarkup: !appliedReplyTo ? opts.replyMarkup : undefined,
|
||||
});
|
||||
if (replyToForChunk) {
|
||||
appliedReplyTo = true;
|
||||
}
|
||||
}
|
||||
const progress = createDeliveryProgress();
|
||||
await sendChunkedTelegramReplyText({
|
||||
chunks,
|
||||
progress,
|
||||
replyToId: opts.replyToId,
|
||||
replyToMode: "first",
|
||||
replyMarkup: opts.replyMarkup,
|
||||
replyQuoteText: opts.replyQuoteText,
|
||||
quoteOnlyOnFirstChunk: true,
|
||||
sendChunk: async ({ chunk, replyToMessageId, replyMarkup, replyQuoteText }) => {
|
||||
await sendTelegramText(opts.bot, opts.chatId, chunk.html, opts.runtime, {
|
||||
replyToMessageId,
|
||||
replyQuoteText,
|
||||
thread: opts.thread,
|
||||
textMode: "html",
|
||||
plainText: chunk.text,
|
||||
linkPreview: opts.linkPreview,
|
||||
replyMarkup,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function isTelegramThreadNotFoundError(err: unknown): boolean {
|
||||
|
||||
76
src/telegram/bot/reply-threading.ts
Normal file
76
src/telegram/bot/reply-threading.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import type { ReplyToMode } from "../../config/config.js";
|
||||
|
||||
export type DeliveryProgress = {
|
||||
hasReplied: boolean;
|
||||
hasDelivered: boolean;
|
||||
};
|
||||
|
||||
export function createDeliveryProgress(): DeliveryProgress {
|
||||
return {
|
||||
hasReplied: false,
|
||||
hasDelivered: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveReplyToForSend(params: {
|
||||
replyToId?: number;
|
||||
replyToMode: ReplyToMode;
|
||||
progress: DeliveryProgress;
|
||||
}): number | undefined {
|
||||
return params.replyToId && (params.replyToMode === "all" || !params.progress.hasReplied)
|
||||
? params.replyToId
|
||||
: undefined;
|
||||
}
|
||||
|
||||
export function markReplyApplied(progress: DeliveryProgress, replyToId?: number): void {
|
||||
if (replyToId && !progress.hasReplied) {
|
||||
progress.hasReplied = true;
|
||||
}
|
||||
}
|
||||
|
||||
export function markDelivered(progress: DeliveryProgress): void {
|
||||
progress.hasDelivered = true;
|
||||
}
|
||||
|
||||
export async function sendChunkedTelegramReplyText<TChunk, TReplyMarkup = unknown>(params: {
|
||||
chunks: readonly TChunk[];
|
||||
progress: DeliveryProgress;
|
||||
replyToId?: number;
|
||||
replyToMode: ReplyToMode;
|
||||
replyMarkup?: TReplyMarkup;
|
||||
replyQuoteText?: string;
|
||||
quoteOnlyOnFirstChunk?: boolean;
|
||||
sendChunk: (opts: {
|
||||
chunk: TChunk;
|
||||
isFirstChunk: boolean;
|
||||
replyToMessageId?: number;
|
||||
replyMarkup?: TReplyMarkup;
|
||||
replyQuoteText?: string;
|
||||
}) => Promise<void>;
|
||||
}): Promise<void> {
|
||||
for (let i = 0; i < params.chunks.length; i += 1) {
|
||||
const chunk = params.chunks[i];
|
||||
if (!chunk) {
|
||||
continue;
|
||||
}
|
||||
const isFirstChunk = i === 0;
|
||||
const replyToMessageId = resolveReplyToForSend({
|
||||
replyToId: params.replyToId,
|
||||
replyToMode: params.replyToMode,
|
||||
progress: params.progress,
|
||||
});
|
||||
const shouldAttachQuote =
|
||||
Boolean(replyToMessageId) &&
|
||||
Boolean(params.replyQuoteText) &&
|
||||
(params.quoteOnlyOnFirstChunk !== true || isFirstChunk);
|
||||
await params.sendChunk({
|
||||
chunk,
|
||||
isFirstChunk,
|
||||
replyToMessageId,
|
||||
replyMarkup: isFirstChunk ? params.replyMarkup : undefined,
|
||||
replyQuoteText: shouldAttachQuote ? params.replyQuoteText : undefined,
|
||||
});
|
||||
markReplyApplied(params.progress, replyToMessageId);
|
||||
markDelivered(params.progress);
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
readVersionFromBuildInfoForModuleUrl,
|
||||
readVersionFromPackageJsonForModuleUrl,
|
||||
resolveBinaryVersion,
|
||||
resolveRuntimeServiceVersion,
|
||||
resolveVersionFromModuleUrl,
|
||||
} from "./version.js";
|
||||
@@ -94,6 +95,42 @@ describe("version resolution", () => {
|
||||
expect(resolveVersionFromModuleUrl("not-a-valid-url")).toBeNull();
|
||||
});
|
||||
|
||||
it("resolves binary version with explicit precedence", async () => {
|
||||
await withTempDir(async (root) => {
|
||||
await writeJsonFixture(root, "package.json", { name: "openclaw", version: "2.3.4" });
|
||||
const moduleUrl = await ensureModuleFixture(root);
|
||||
expect(
|
||||
resolveBinaryVersion({
|
||||
moduleUrl,
|
||||
injectedVersion: "9.9.9",
|
||||
bundledVersion: "8.8.8",
|
||||
fallback: "0.0.0",
|
||||
}),
|
||||
).toBe("9.9.9");
|
||||
expect(
|
||||
resolveBinaryVersion({
|
||||
moduleUrl,
|
||||
bundledVersion: "8.8.8",
|
||||
fallback: "0.0.0",
|
||||
}),
|
||||
).toBe("2.3.4");
|
||||
expect(
|
||||
resolveBinaryVersion({
|
||||
moduleUrl: "not-a-valid-url",
|
||||
bundledVersion: "8.8.8",
|
||||
fallback: "0.0.0",
|
||||
}),
|
||||
).toBe("8.8.8");
|
||||
expect(
|
||||
resolveBinaryVersion({
|
||||
moduleUrl: "not-a-valid-url",
|
||||
bundledVersion: " ",
|
||||
fallback: "0.0.0",
|
||||
}),
|
||||
).toBe("0.0.0");
|
||||
});
|
||||
});
|
||||
|
||||
it("prefers OPENCLAW_VERSION over service and package versions", () => {
|
||||
expect(
|
||||
resolveRuntimeServiceVersion({
|
||||
|
||||
@@ -71,6 +71,21 @@ export function resolveVersionFromModuleUrl(moduleUrl: string): string | null {
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveBinaryVersion(params: {
|
||||
moduleUrl: string;
|
||||
injectedVersion?: string;
|
||||
bundledVersion?: string;
|
||||
fallback?: string;
|
||||
}): string {
|
||||
return (
|
||||
firstNonEmpty(params.injectedVersion) ||
|
||||
resolveVersionFromModuleUrl(params.moduleUrl) ||
|
||||
firstNonEmpty(params.bundledVersion) ||
|
||||
params.fallback ||
|
||||
"0.0.0"
|
||||
);
|
||||
}
|
||||
|
||||
export type RuntimeVersionEnv = {
|
||||
[key: string]: string | undefined;
|
||||
};
|
||||
@@ -91,8 +106,8 @@ export function resolveRuntimeServiceVersion(
|
||||
// Single source of truth for the current OpenClaw version.
|
||||
// - Embedded/bundled builds: injected define or env var.
|
||||
// - Dev/npm builds: package.json.
|
||||
export const VERSION =
|
||||
(typeof __OPENCLAW_VERSION__ === "string" && __OPENCLAW_VERSION__) ||
|
||||
process.env.OPENCLAW_BUNDLED_VERSION ||
|
||||
resolveVersionFromModuleUrl(import.meta.url) ||
|
||||
"0.0.0";
|
||||
export const VERSION = resolveBinaryVersion({
|
||||
moduleUrl: import.meta.url,
|
||||
injectedVersion: typeof __OPENCLAW_VERSION__ === "string" ? __OPENCLAW_VERSION__ : undefined,
|
||||
bundledVersion: process.env.OPENCLAW_BUNDLED_VERSION,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user