fix: honor whatsapp mediaMaxMb (#505) (thanks @koala73)
This commit is contained in:
@@ -1,7 +1,10 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
import type { AgentMessage, AgentToolResult } from "@mariozechner/pi-agent-core";
|
||||
import type {
|
||||
AgentMessage,
|
||||
AgentToolResult,
|
||||
} from "@mariozechner/pi-agent-core";
|
||||
import type { AssistantMessage } from "@mariozechner/pi-ai";
|
||||
import {
|
||||
normalizeThinkLevel,
|
||||
|
||||
@@ -758,7 +758,6 @@ export async function compactEmbeddedPiSession(params: {
|
||||
enqueue?: typeof enqueueCommand;
|
||||
extraSystemPrompt?: string;
|
||||
ownerNumbers?: string[];
|
||||
serveBaseUrl?: string;
|
||||
}): Promise<EmbeddedPiCompactResult> {
|
||||
const sessionLane = resolveSessionLane(
|
||||
params.sessionKey?.trim() || params.sessionId,
|
||||
@@ -854,7 +853,6 @@ export async function compactEmbeddedPiSession(params: {
|
||||
sessionKey: params.sessionKey ?? params.sessionId,
|
||||
agentDir,
|
||||
config: params.config,
|
||||
serveBaseUrl: params.serveBaseUrl,
|
||||
});
|
||||
const machineName = await getMachineDisplayName();
|
||||
const runtimeInfo = {
|
||||
@@ -1017,7 +1015,6 @@ export async function runEmbeddedPiAgent(params: {
|
||||
extraSystemPrompt?: string;
|
||||
ownerNumbers?: string[];
|
||||
enforceFinalTag?: boolean;
|
||||
serveBaseUrl?: string;
|
||||
}): Promise<EmbeddedPiRunResult> {
|
||||
const sessionLane = resolveSessionLane(
|
||||
params.sessionKey?.trim() || params.sessionId,
|
||||
@@ -1053,7 +1050,7 @@ export async function runEmbeddedPiAgent(params: {
|
||||
provider,
|
||||
preferredProfile: explicitProfileId,
|
||||
});
|
||||
if (explicitProfileId && !profileOrder.includes(explicitProfileId)) {
|
||||
if (explicitProfileId && !profileOrder.includes(explicitProfileId)) {
|
||||
throw new Error(
|
||||
`Auth profile "${explicitProfileId}" is not configured for ${provider}.`,
|
||||
);
|
||||
@@ -1169,7 +1166,6 @@ if (explicitProfileId && !profileOrder.includes(explicitProfileId)) {
|
||||
sessionKey: params.sessionKey ?? params.sessionId,
|
||||
agentDir,
|
||||
config: params.config,
|
||||
serveBaseUrl: params.serveBaseUrl,
|
||||
});
|
||||
const machineName = await getMachineDisplayName();
|
||||
const runtimeInfo = {
|
||||
|
||||
@@ -508,7 +508,6 @@ export function createClawdbotCodingTools(options?: {
|
||||
sessionKey?: string;
|
||||
agentDir?: string;
|
||||
config?: ClawdbotConfig;
|
||||
serveBaseUrl?: string;
|
||||
}): AnyAgentTool[] {
|
||||
const bashToolName = "bash";
|
||||
const sandbox = options?.sandbox?.enabled ? options.sandbox : undefined;
|
||||
|
||||
@@ -1,97 +0,0 @@
|
||||
import { Type } from "@sinclair/typebox";
|
||||
|
||||
import { getTailnetHostname } from "../../infra/tailscale.js";
|
||||
import {
|
||||
serveCreate,
|
||||
serveDelete,
|
||||
serveList,
|
||||
} from "../../gateway/serve.js";
|
||||
import { type AnyAgentTool, jsonResult, readStringParam } from "./common.js";
|
||||
|
||||
async function resolveServeBaseUrl(providedUrl?: string): Promise<string> {
|
||||
if (providedUrl) return providedUrl;
|
||||
try {
|
||||
const tailnetHost = await getTailnetHostname();
|
||||
return `https://${tailnetHost}`;
|
||||
} catch {
|
||||
return "http://localhost:18789";
|
||||
}
|
||||
}
|
||||
|
||||
export function createServeTool(opts?: { baseUrl?: string }): AnyAgentTool {
|
||||
return {
|
||||
label: "Serve File",
|
||||
name: "serve",
|
||||
description:
|
||||
"Create a publicly accessible URL for a file. Returns a URL that can be shared. " +
|
||||
"Supports optional title, description, and OG image for rich link previews. " +
|
||||
"TTL can be specified as a duration (e.g., '1h', '7d') or 'forever'.",
|
||||
parameters: Type.Object({
|
||||
path: Type.String({ description: "Absolute path to the file to serve" }),
|
||||
slug: Type.Optional(
|
||||
Type.String({ description: "Custom URL slug (auto-generated if omitted)" }),
|
||||
),
|
||||
title: Type.Optional(
|
||||
Type.String({ description: "Title for link preview" }),
|
||||
),
|
||||
description: Type.Optional(
|
||||
Type.String({ description: "Description for link preview" }),
|
||||
),
|
||||
ttl: Type.Optional(
|
||||
Type.String({
|
||||
description: "Time to live: '1h', '7d', 'forever' (default: '24h')",
|
||||
}),
|
||||
),
|
||||
ogImage: Type.Optional(
|
||||
Type.String({ description: "URL or path to Open Graph preview image" }),
|
||||
),
|
||||
}),
|
||||
execute: async (_toolCallId: string, args: unknown) => {
|
||||
const params = (args ?? {}) as Record<string, unknown>;
|
||||
const filePath = readStringParam(params, "path", { required: true });
|
||||
const slug = readStringParam(params, "slug");
|
||||
const title = readStringParam(params, "title");
|
||||
const description = readStringParam(params, "description");
|
||||
const ttl = readStringParam(params, "ttl");
|
||||
const ogImage = readStringParam(params, "ogImage");
|
||||
|
||||
const baseUrl = await resolveServeBaseUrl(opts?.baseUrl);
|
||||
const result = serveCreate(
|
||||
{ path: filePath, slug: slug || "file", title: title || "", description: description || "", ttl, ogImage },
|
||||
baseUrl,
|
||||
);
|
||||
return jsonResult(result);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createServeListTool(opts?: { baseUrl?: string }): AnyAgentTool {
|
||||
return {
|
||||
label: "List Served Files",
|
||||
name: "serve_list",
|
||||
description: "List all currently served files with their URLs and metadata.",
|
||||
parameters: Type.Object({}),
|
||||
execute: async () => {
|
||||
const baseUrl = await resolveServeBaseUrl(opts?.baseUrl);
|
||||
const items = serveList(baseUrl);
|
||||
return jsonResult({ count: items.length, items });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createServeDeleteTool(): AnyAgentTool {
|
||||
return {
|
||||
label: "Delete Served File",
|
||||
name: "serve_delete",
|
||||
description: "Remove a served file by its slug.",
|
||||
parameters: Type.Object({
|
||||
slug: Type.String({ description: "The slug of the served file to delete" }),
|
||||
}),
|
||||
execute: async (_toolCallId: string, args: unknown) => {
|
||||
const params = (args ?? {}) as Record<string, unknown>;
|
||||
const slug = readStringParam(params, "slug", { required: true });
|
||||
const deleted = serveDelete(slug);
|
||||
return jsonResult({ deleted, slug });
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,366 +0,0 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
readdirSync,
|
||||
unlinkSync,
|
||||
writeFileSync,
|
||||
copyFileSync,
|
||||
statSync,
|
||||
} from "node:fs";
|
||||
import { extname, join, basename } from "node:path";
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
import MarkdownIt from "markdown-it";
|
||||
import { CONFIG_DIR } from "../utils.js";
|
||||
|
||||
const md = new MarkdownIt({ html: true, linkify: true, typographer: true });
|
||||
|
||||
export type ServeMetadata = {
|
||||
slug: string;
|
||||
contentPath: string;
|
||||
contentHash: string;
|
||||
contentType: string;
|
||||
size: number;
|
||||
title: string;
|
||||
description: string;
|
||||
ogImage: string | null;
|
||||
createdAt: string;
|
||||
expiresAt: string | null;
|
||||
ttl: string;
|
||||
};
|
||||
|
||||
export type ServeCreateParams = {
|
||||
path: string;
|
||||
slug: string;
|
||||
title: string;
|
||||
description: string;
|
||||
ttl?: string;
|
||||
ogImage?: string;
|
||||
};
|
||||
|
||||
const SERVE_DIR = join(CONFIG_DIR, "serve");
|
||||
|
||||
function ensureServeDir() {
|
||||
if (!existsSync(SERVE_DIR)) {
|
||||
mkdirSync(SERVE_DIR, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
export function parseTtl(ttl: string): number | null {
|
||||
if (ttl === "forever") return null;
|
||||
const match = ttl.match(/^(\d+)(m|h|d)$/);
|
||||
if (!match) return 24 * 60 * 60 * 1000; // default 24h
|
||||
const value = parseInt(match[1], 10);
|
||||
const unit = match[2];
|
||||
switch (unit) {
|
||||
case "m":
|
||||
return value * 60 * 1000;
|
||||
case "h":
|
||||
return value * 60 * 60 * 1000;
|
||||
case "d":
|
||||
return value * 24 * 60 * 60 * 1000;
|
||||
default:
|
||||
return 24 * 60 * 60 * 1000;
|
||||
}
|
||||
}
|
||||
|
||||
function hashContent(content: Buffer): string {
|
||||
return createHash("sha256").update(content).digest("hex").slice(0, 16);
|
||||
}
|
||||
|
||||
function getMimeType(ext: string): string {
|
||||
const mimes: Record<string, string> = {
|
||||
".md": "text/markdown",
|
||||
".html": "text/html",
|
||||
".htm": "text/html",
|
||||
".txt": "text/plain",
|
||||
".json": "application/json",
|
||||
".pdf": "application/pdf",
|
||||
".png": "image/png",
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".gif": "image/gif",
|
||||
".svg": "image/svg+xml",
|
||||
".webp": "image/webp",
|
||||
".mp3": "audio/mpeg",
|
||||
".wav": "audio/wav",
|
||||
".mp4": "video/mp4",
|
||||
".webm": "video/webm",
|
||||
".css": "text/css",
|
||||
".js": "application/javascript",
|
||||
};
|
||||
return mimes[ext.toLowerCase()] ?? "application/octet-stream";
|
||||
}
|
||||
|
||||
function findUniqueSlug(baseSlug: string): string {
|
||||
ensureServeDir();
|
||||
let slug = baseSlug;
|
||||
let counter = 1;
|
||||
while (existsSync(join(SERVE_DIR, `${slug}.json`))) {
|
||||
const existing = loadMetadata(slug);
|
||||
if (!existing) break;
|
||||
slug = `${baseSlug}-${counter}`;
|
||||
counter++;
|
||||
}
|
||||
return slug;
|
||||
}
|
||||
|
||||
function loadMetadata(slug: string): ServeMetadata | null {
|
||||
const metaPath = join(SERVE_DIR, `${slug}.json`);
|
||||
if (!existsSync(metaPath)) return null;
|
||||
try {
|
||||
return JSON.parse(readFileSync(metaPath, "utf-8")) as ServeMetadata;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function saveMetadata(meta: ServeMetadata) {
|
||||
ensureServeDir();
|
||||
const metaPath = join(SERVE_DIR, `${meta.slug}.json`);
|
||||
writeFileSync(metaPath, JSON.stringify(meta, null, 2));
|
||||
}
|
||||
|
||||
function isExpired(meta: ServeMetadata): boolean {
|
||||
if (!meta.expiresAt) return false;
|
||||
return new Date(meta.expiresAt).getTime() < Date.now();
|
||||
}
|
||||
|
||||
function deleteServedContent(slug: string) {
|
||||
const meta = loadMetadata(slug);
|
||||
if (!meta) return false;
|
||||
const metaPath = join(SERVE_DIR, `${slug}.json`);
|
||||
const contentPath = join(SERVE_DIR, meta.contentPath);
|
||||
try {
|
||||
if (existsSync(contentPath)) unlinkSync(contentPath);
|
||||
if (existsSync(metaPath)) unlinkSync(metaPath);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function serveCreate(
|
||||
params: ServeCreateParams,
|
||||
baseUrl: string,
|
||||
): { url: string; slug: string } {
|
||||
ensureServeDir();
|
||||
|
||||
if (!existsSync(params.path)) {
|
||||
throw new Error(`File not found: ${params.path}`);
|
||||
}
|
||||
|
||||
const content = readFileSync(params.path);
|
||||
const hash = hashContent(content);
|
||||
const ext = extname(params.path);
|
||||
const contentType = getMimeType(ext);
|
||||
|
||||
// Check for existing with same slug
|
||||
const existing = loadMetadata(params.slug);
|
||||
let slug: string;
|
||||
|
||||
if (existing && existing.contentHash === hash) {
|
||||
// Same content, update metadata
|
||||
slug = params.slug;
|
||||
} else if (existing) {
|
||||
// Different content, find unique slug
|
||||
slug = findUniqueSlug(params.slug);
|
||||
} else {
|
||||
slug = params.slug;
|
||||
}
|
||||
|
||||
const contentFilename = `${slug}${ext}`;
|
||||
const contentPath = join(SERVE_DIR, contentFilename);
|
||||
copyFileSync(params.path, contentPath);
|
||||
|
||||
const ttl = params.ttl ?? "24h";
|
||||
const ttlMs = parseTtl(ttl);
|
||||
const now = new Date();
|
||||
const expiresAt = ttlMs ? new Date(now.getTime() + ttlMs).toISOString() : null;
|
||||
|
||||
const meta: ServeMetadata = {
|
||||
slug,
|
||||
contentPath: contentFilename,
|
||||
contentHash: hash,
|
||||
contentType,
|
||||
size: content.length,
|
||||
title: params.title,
|
||||
description: params.description,
|
||||
ogImage: params.ogImage ?? null,
|
||||
createdAt: now.toISOString(),
|
||||
expiresAt,
|
||||
ttl,
|
||||
};
|
||||
|
||||
saveMetadata(meta);
|
||||
|
||||
return { url: `${baseUrl}/s/${slug}`, slug };
|
||||
}
|
||||
|
||||
export function serveList(baseUrl: string): ServeMetadata[] {
|
||||
ensureServeDir();
|
||||
const files = readdirSync(SERVE_DIR).filter((f) => f.endsWith(".json"));
|
||||
const items: ServeMetadata[] = [];
|
||||
|
||||
for (const file of files) {
|
||||
const slug = file.replace(/\.json$/, "");
|
||||
const meta = loadMetadata(slug);
|
||||
if (meta && !isExpired(meta)) {
|
||||
items.push(meta);
|
||||
}
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
export function serveDelete(slug: string): boolean {
|
||||
return deleteServedContent(slug);
|
||||
}
|
||||
|
||||
// CSS for rendered pages
|
||||
const CSS = `
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
line-height: 1.6;
|
||||
color: #333;
|
||||
max-width: 700px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
background: #fff;
|
||||
}
|
||||
h1, h2, h3, h4, h5, h6 { margin-top: 1.5em; margin-bottom: 0.5em; }
|
||||
h1 { font-size: 2em; }
|
||||
h2 { font-size: 1.5em; }
|
||||
h3 { font-size: 1.25em; }
|
||||
p { margin: 1em 0; }
|
||||
a { color: #0066cc; }
|
||||
img { max-width: 100%; height: auto; }
|
||||
pre { background: #f5f5f5; padding: 1em; overflow-x: auto; border-radius: 4px; }
|
||||
code { background: #f5f5f5; padding: 0.2em 0.4em; border-radius: 3px; font-size: 0.9em; }
|
||||
pre code { background: none; padding: 0; }
|
||||
blockquote { border-left: 4px solid #ddd; margin: 1em 0; padding-left: 1em; color: #666; }
|
||||
table { border-collapse: collapse; width: 100%; margin: 1em 0; }
|
||||
th, td { border: 1px solid #ddd; padding: 8px; text-align: left; }
|
||||
th { background: #f5f5f5; }
|
||||
`;
|
||||
|
||||
const HIGHLIGHT_CSS = `
|
||||
.hljs{background:#f5f5f5;padding:0}
|
||||
.hljs-keyword,.hljs-selector-tag,.hljs-literal,.hljs-section,.hljs-link{color:#a626a4}
|
||||
.hljs-string,.hljs-title,.hljs-name,.hljs-type,.hljs-attribute,.hljs-symbol,.hljs-bullet,.hljs-addition,.hljs-variable,.hljs-template-tag,.hljs-template-variable{color:#50a14f}
|
||||
.hljs-comment,.hljs-quote,.hljs-deletion,.hljs-meta{color:#a0a1a7}
|
||||
.hljs-number,.hljs-regexp,.hljs-literal,.hljs-bullet,.hljs-link{color:#986801}
|
||||
.hljs-emphasis{font-style:italic}
|
||||
.hljs-strong{font-weight:bold}
|
||||
`;
|
||||
|
||||
function renderHtmlPage(meta: ServeMetadata, bodyHtml: string, baseUrl: string): string {
|
||||
const ogImageTag = meta.ogImage
|
||||
? `<meta property="og:image" content="${baseUrl}/s/${meta.ogImage}">`
|
||||
: "";
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta property="og:title" content="${escapeHtml(meta.title)}">
|
||||
<meta property="og:description" content="${escapeHtml(meta.description)}">
|
||||
${ogImageTag}
|
||||
<meta property="og:type" content="article">
|
||||
<title>${escapeHtml(meta.title)}</title>
|
||||
<style>${CSS}</style>
|
||||
<style>${HIGHLIGHT_CSS}</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
${bodyHtml}
|
||||
</main>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
|
||||
<script>hljs.highlightAll();</script>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
function render404Page(): string {
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Not Found - Clawdis</title>
|
||||
<style>${CSS}</style>
|
||||
</head>
|
||||
<body>
|
||||
<main style="text-align: center; padding-top: 50px;">
|
||||
<h1>Content Not Found</h1>
|
||||
<p>This content may have expired or been removed.</p>
|
||||
</main>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
function escapeHtml(str: string): string {
|
||||
return str
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
export function handleServeRequest(
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
opts: { baseUrl: string },
|
||||
): boolean {
|
||||
const url = new URL(req.url ?? "/", "http://localhost");
|
||||
if (!url.pathname.startsWith("/s/")) return false;
|
||||
|
||||
const slug = url.pathname.slice(3); // Remove "/s/"
|
||||
if (!slug || slug.includes("/")) {
|
||||
res.statusCode = 404;
|
||||
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
||||
res.end(render404Page());
|
||||
return true;
|
||||
}
|
||||
|
||||
const meta = loadMetadata(slug);
|
||||
if (!meta || isExpired(meta)) {
|
||||
if (meta && isExpired(meta)) {
|
||||
deleteServedContent(slug);
|
||||
}
|
||||
res.statusCode = 404;
|
||||
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
||||
res.end(render404Page());
|
||||
return true;
|
||||
}
|
||||
|
||||
const contentPath = join(SERVE_DIR, meta.contentPath);
|
||||
if (!existsSync(contentPath)) {
|
||||
res.statusCode = 404;
|
||||
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
||||
res.end(render404Page());
|
||||
return true;
|
||||
}
|
||||
|
||||
const content = readFileSync(contentPath);
|
||||
|
||||
// Render markdown/text as HTML
|
||||
if (meta.contentType === "text/markdown" || meta.contentType === "text/plain") {
|
||||
const text = content.toString("utf-8");
|
||||
const bodyHtml = meta.contentType === "text/markdown" ? md.render(text) : `<pre>${escapeHtml(text)}</pre>`;
|
||||
const html = renderHtmlPage(meta, bodyHtml, opts.baseUrl);
|
||||
res.statusCode = 200;
|
||||
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
||||
res.end(html);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Serve other files directly
|
||||
res.statusCode = 200;
|
||||
res.setHeader("Content-Type", meta.contentType);
|
||||
res.setHeader("Content-Length", content.length);
|
||||
res.end(content);
|
||||
return true;
|
||||
}
|
||||
@@ -22,7 +22,6 @@ import {
|
||||
resolveHookProvider,
|
||||
} from "./hooks.js";
|
||||
import { applyHookMappings } from "./hooks-mapping.js";
|
||||
import { handleServeRequest } from "./serve.js";
|
||||
|
||||
type SubsystemLogger = ReturnType<typeof createSubsystemLogger>;
|
||||
|
||||
@@ -207,14 +206,12 @@ export function createGatewayHttpServer(opts: {
|
||||
controlUiEnabled: boolean;
|
||||
controlUiBasePath: string;
|
||||
handleHooksRequest: HooksRequestHandler;
|
||||
serveBaseUrl?: string;
|
||||
}): HttpServer {
|
||||
const {
|
||||
canvasHost,
|
||||
controlUiEnabled,
|
||||
controlUiBasePath,
|
||||
handleHooksRequest,
|
||||
serveBaseUrl,
|
||||
} = opts;
|
||||
const httpServer: HttpServer = createHttpServer((req, res) => {
|
||||
// Don't interfere with WebSocket upgrades; ws handles the 'upgrade' event.
|
||||
@@ -222,7 +219,6 @@ export function createGatewayHttpServer(opts: {
|
||||
|
||||
void (async () => {
|
||||
if (await handleHooksRequest(req, res)) return;
|
||||
if (serveBaseUrl && handleServeRequest(req, res, { baseUrl: serveBaseUrl })) return;
|
||||
if (canvasHost) {
|
||||
if (await handleA2uiHttpRequest(req, res)) return;
|
||||
if (await canvasHost.handleHttpRequest(req, res)) return;
|
||||
|
||||
@@ -593,23 +593,11 @@ export async function startGatewayServer(
|
||||
dispatchWakeHook,
|
||||
});
|
||||
|
||||
// Try to use Tailscale funnel URL for serve feature (public URLs)
|
||||
let serveBaseUrl: string;
|
||||
try {
|
||||
const tailnetHost = await getTailnetHostname();
|
||||
serveBaseUrl = `https://${tailnetHost}`;
|
||||
log.info(`Serve base URL (Tailscale): ${serveBaseUrl}`);
|
||||
} catch {
|
||||
const serveHost = bindHost === "0.0.0.0" ? "localhost" : bindHost;
|
||||
serveBaseUrl = `http://${serveHost}:${port}`;
|
||||
log.info(`Serve base URL (local): ${serveBaseUrl}`);
|
||||
}
|
||||
const httpServer: HttpServer = createGatewayHttpServer({
|
||||
canvasHost,
|
||||
controlUiEnabled,
|
||||
controlUiBasePath,
|
||||
handleHooksRequest,
|
||||
serveBaseUrl,
|
||||
});
|
||||
let bonjourStop: (() => Promise<void>) | null = null;
|
||||
let bridge: Awaited<ReturnType<typeof startNodeBridgeServer>> | null = null;
|
||||
|
||||
@@ -23,6 +23,7 @@ export type ResolvedWhatsAppAccount = {
|
||||
groupPolicy?: GroupPolicy;
|
||||
dmPolicy?: DmPolicy;
|
||||
textChunkLimit?: number;
|
||||
mediaMaxMb?: number;
|
||||
blockStreaming?: boolean;
|
||||
groups?: WhatsAppAccountConfig["groups"];
|
||||
};
|
||||
@@ -120,6 +121,7 @@ export function resolveWhatsAppAccount(params: {
|
||||
groupPolicy: accountCfg?.groupPolicy ?? params.cfg.whatsapp?.groupPolicy,
|
||||
textChunkLimit:
|
||||
accountCfg?.textChunkLimit ?? params.cfg.whatsapp?.textChunkLimit,
|
||||
mediaMaxMb: accountCfg?.mediaMaxMb ?? params.cfg.whatsapp?.mediaMaxMb,
|
||||
blockStreaming:
|
||||
accountCfg?.blockStreaming ?? params.cfg.whatsapp?.blockStreaming,
|
||||
groups: accountCfg?.groups ?? params.cfg.whatsapp?.groups,
|
||||
|
||||
@@ -788,6 +788,7 @@ export async function monitorWebProvider(
|
||||
groupAllowFrom: account.groupAllowFrom,
|
||||
groupPolicy: account.groupPolicy,
|
||||
textChunkLimit: account.textChunkLimit,
|
||||
mediaMaxMb: account.mediaMaxMb,
|
||||
blockStreaming: account.blockStreaming,
|
||||
groups: account.groups,
|
||||
},
|
||||
@@ -1305,6 +1306,7 @@ export async function monitorWebProvider(
|
||||
verbose,
|
||||
accountId: account.accountId,
|
||||
authDir: account.authDir,
|
||||
mediaMaxMb: account.mediaMaxMb,
|
||||
onMessage: async (msg) => {
|
||||
handledMessages += 1;
|
||||
lastMessageAt = Date.now();
|
||||
|
||||
@@ -3,12 +3,21 @@ import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
afterAll,
|
||||
beforeAll,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi,
|
||||
} from "vitest";
|
||||
|
||||
const readAllowFromStoreMock = vi.fn().mockResolvedValue([]);
|
||||
const upsertPairingRequestMock = vi
|
||||
.fn()
|
||||
.mockResolvedValue({ code: "PAIRCODE", created: true });
|
||||
const saveMediaBufferSpy = vi.fn();
|
||||
|
||||
vi.mock("../config/config.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../config/config.js")>();
|
||||
@@ -33,6 +42,19 @@ vi.mock("../pairing/pairing-store.js", () => ({
|
||||
upsertPairingRequestMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock("../media/store.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../media/store.js")>();
|
||||
return {
|
||||
...actual,
|
||||
saveMediaBuffer: vi.fn(
|
||||
async (...args: Parameters<typeof actual.saveMediaBuffer>) => {
|
||||
saveMediaBufferSpy(...args);
|
||||
return actual.saveMediaBuffer(...args);
|
||||
},
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
const HOME = path.join(
|
||||
os.tmpdir(),
|
||||
`clawdbot-inbound-media-${crypto.randomUUID()}`,
|
||||
@@ -87,6 +109,10 @@ vi.mock("./session.js", () => {
|
||||
import { monitorWebInbox } from "./inbound.js";
|
||||
|
||||
describe("web inbound media saves with extension", () => {
|
||||
beforeEach(() => {
|
||||
saveMediaBufferSpy.mockClear();
|
||||
});
|
||||
|
||||
beforeAll(async () => {
|
||||
await fs.rm(HOME, { recursive: true, force: true });
|
||||
});
|
||||
@@ -182,4 +208,44 @@ describe("web inbound media saves with extension", () => {
|
||||
|
||||
await listener.close();
|
||||
});
|
||||
|
||||
it("passes mediaMaxMb to saveMediaBuffer", async () => {
|
||||
const onMessage = vi.fn();
|
||||
const listener = await monitorWebInbox({
|
||||
verbose: false,
|
||||
onMessage,
|
||||
mediaMaxMb: 1,
|
||||
});
|
||||
const { createWaSocket } = await import("./session.js");
|
||||
const realSock = await (
|
||||
createWaSocket as unknown as () => Promise<{
|
||||
ev: import("node:events").EventEmitter;
|
||||
}>
|
||||
)();
|
||||
|
||||
const upsert = {
|
||||
type: "notify",
|
||||
messages: [
|
||||
{
|
||||
key: { id: "img3", fromMe: false, remoteJid: "222@s.whatsapp.net" },
|
||||
message: { imageMessage: { mimetype: "image/jpeg" } },
|
||||
messageTimestamp: 1_700_000_003,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
realSock.ev.emit("messages.upsert", upsert);
|
||||
|
||||
for (let i = 0; i < 10; i++) {
|
||||
if (onMessage.mock.calls.length > 0) break;
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
}
|
||||
|
||||
expect(onMessage).toHaveBeenCalledTimes(1);
|
||||
expect(saveMediaBufferSpy).toHaveBeenCalled();
|
||||
const lastCall = saveMediaBufferSpy.mock.calls.at(-1);
|
||||
expect(lastCall?.[3]).toBe(1 * 1024 * 1024);
|
||||
|
||||
await listener.close();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -376,7 +376,11 @@ export async function monitorWebInbox(options: {
|
||||
try {
|
||||
const inboundMedia = await downloadInboundMedia(msg, sock);
|
||||
if (inboundMedia) {
|
||||
const maxBytes = (options.mediaMaxMb ?? 50) * 1024 * 1024;
|
||||
const maxMb =
|
||||
typeof options.mediaMaxMb === "number" && options.mediaMaxMb > 0
|
||||
? options.mediaMaxMb
|
||||
: 50;
|
||||
const maxBytes = maxMb * 1024 * 1024;
|
||||
const saved = await saveMediaBuffer(
|
||||
inboundMedia.buffer,
|
||||
inboundMedia.mimetype,
|
||||
|
||||
Reference in New Issue
Block a user