Merge branch 'fix/media-replies'
commit
ccab950d16
|
|
@ -17,4 +17,4 @@ export function getAgentSpec(kind: AgentKind): AgentSpec {
|
||||||
return specs[kind];
|
return specs[kind];
|
||||||
}
|
}
|
||||||
|
|
||||||
export { AgentKind, AgentMeta, AgentParseResult } from "./types.js";
|
export type { AgentKind, AgentMeta, AgentParseResult } from "./types.js";
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import { z } from "zod";
|
||||||
// Preferred binary name for Claude CLI invocations.
|
// Preferred binary name for Claude CLI invocations.
|
||||||
export const CLAUDE_BIN = "claude";
|
export const CLAUDE_BIN = "claude";
|
||||||
export const CLAUDE_IDENTITY_PREFIX =
|
export const CLAUDE_IDENTITY_PREFIX =
|
||||||
"You are Clawd (Claude) running on the user's Mac via warelay. Your scratchpad is /Users/steipete/clawd; this is your folder and you can add what you like in markdown files and/or images. You don't need to be concise, but WhatsApp replies must stay under ~1500 characters. Media you can send: images ≤6MB, audio/video ≤16MB, documents ≤100MB. The prompt may include a media path and an optional Transcript: section—use them when present. If a prompt is a heartbeat poll and nothing needs attention, reply with exactly HEARTBEAT_OK and nothing else; for any alert, do not include HEARTBEAT_OK.";
|
"You are Clawd (Claude) running on the user's Mac via warelay. Keep WhatsApp replies under ~1500 characters. Your scratchpad is ~/clawd; this is your folder and you can add what you like in markdown files and/or images. You can send media by including MEDIA:/path/to/file.jpg on its own line (no spaces in path). Media limits: images ≤6MB, audio/video ≤16MB, documents ≤100MB. The prompt may include a media path and an optional Transcript: section—use them when present. If a prompt is a heartbeat poll and nothing needs attention, reply with exactly HEARTBEAT_OK and nothing else; for any alert, do not include HEARTBEAT_OK.";
|
||||||
|
|
||||||
function extractClaudeText(payload: unknown): string | undefined {
|
function extractClaudeText(payload: unknown): string | undefined {
|
||||||
// Best-effort walker to find the primary text field in Claude JSON outputs.
|
// Best-effort walker to find the primary text field in Claude JSON outputs.
|
||||||
|
|
|
||||||
|
|
@ -189,7 +189,7 @@ export async function runCommandReply(
|
||||||
systemSent,
|
systemSent,
|
||||||
identityPrefix: agentCfg.identityPrefix,
|
identityPrefix: agentCfg.identityPrefix,
|
||||||
format: agentCfg.format,
|
format: agentCfg.format,
|
||||||
})
|
})
|
||||||
: argv;
|
: argv;
|
||||||
|
|
||||||
logVerbose(
|
logVerbose(
|
||||||
|
|
@ -208,7 +208,7 @@ export async function runCommandReply(
|
||||||
const rpcArgv = (() => {
|
const rpcArgv = (() => {
|
||||||
const copy = [...finalArgv];
|
const copy = [...finalArgv];
|
||||||
copy.splice(bodyIndex, 1);
|
copy.splice(bodyIndex, 1);
|
||||||
const modeIdx = copy.findIndex((a) => a === "--mode");
|
const modeIdx = copy.indexOf("--mode");
|
||||||
if (modeIdx >= 0 && copy[modeIdx + 1]) {
|
if (modeIdx >= 0 && copy[modeIdx + 1]) {
|
||||||
copy.splice(modeIdx, 2, "--mode", "rpc");
|
copy.splice(modeIdx, 2, "--mode", "rpc");
|
||||||
} else if (!copy.includes("--mode")) {
|
} else if (!copy.includes("--mode")) {
|
||||||
|
|
@ -231,7 +231,9 @@ export async function runCommandReply(
|
||||||
queuedMs = waitMs;
|
queuedMs = waitMs;
|
||||||
queuedAhead = ahead;
|
queuedAhead = ahead;
|
||||||
if (isVerbose()) {
|
if (isVerbose()) {
|
||||||
logVerbose(`Command auto-reply queued for ${waitMs}ms (${queuedAhead} ahead)`);
|
logVerbose(
|
||||||
|
`Command auto-reply queued for ${waitMs}ms (${queuedAhead} ahead)`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
@ -266,7 +268,10 @@ export async function runCommandReply(
|
||||||
verboseLog(`Command auto-reply stdout (trimmed): ${trimmed || "<empty>"}`);
|
verboseLog(`Command auto-reply stdout (trimmed): ${trimmed || "<empty>"}`);
|
||||||
const elapsed = Date.now() - started;
|
const elapsed = Date.now() - started;
|
||||||
verboseLog(`Command auto-reply finished in ${elapsed}ms`);
|
verboseLog(`Command auto-reply finished in ${elapsed}ms`);
|
||||||
logger.info({ durationMs: elapsed, agent: agentKind, cwd: reply.cwd }, "command auto-reply finished");
|
logger.info(
|
||||||
|
{ durationMs: elapsed, agent: agentKind, cwd: reply.cwd },
|
||||||
|
"command auto-reply finished",
|
||||||
|
);
|
||||||
if ((code ?? 0) !== 0) {
|
if ((code ?? 0) !== 0) {
|
||||||
console.error(
|
console.error(
|
||||||
`Command auto-reply exited with code ${code ?? "unknown"} (signal: ${signal ?? "none"})`,
|
`Command auto-reply exited with code ${code ?? "unknown"} (signal: ${signal ?? "none"})`,
|
||||||
|
|
@ -357,7 +362,10 @@ export async function runCommandReply(
|
||||||
return { payload, meta };
|
return { payload, meta };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const elapsed = Date.now() - started;
|
const elapsed = Date.now() - started;
|
||||||
logger.info({ durationMs: elapsed, agent: agentKind, cwd: reply.cwd }, "command auto-reply failed");
|
logger.info(
|
||||||
|
{ durationMs: elapsed, agent: agentKind, cwd: reply.cwd },
|
||||||
|
"command auto-reply failed",
|
||||||
|
);
|
||||||
const anyErr = err as { killed?: boolean; signal?: string };
|
const anyErr = err as { killed?: boolean; signal?: string };
|
||||||
const timeoutHit = anyErr.killed === true || anyErr.signal === "SIGKILL";
|
const timeoutHit = anyErr.killed === true || anyErr.signal === "SIGKILL";
|
||||||
const errorObj = err as { stdout?: string; stderr?: string };
|
const errorObj = err as { stdout?: string; stderr?: string };
|
||||||
|
|
|
||||||
|
|
@ -263,7 +263,6 @@ export async function getReplyFromConfig(
|
||||||
};
|
};
|
||||||
sessionStore[sessionKey] = sessionEntry;
|
sessionStore[sessionKey] = sessionEntry;
|
||||||
await saveSessionStore(storePath, sessionStore);
|
await saveSessionStore(storePath, sessionStore);
|
||||||
systemSent = true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const prefixedBody =
|
const prefixedBody =
|
||||||
|
|
|
||||||
|
|
@ -895,7 +895,7 @@ describe("config and templating", () => {
|
||||||
const argv = runSpy.mock.calls[0][0];
|
const argv = runSpy.mock.calls[0][0];
|
||||||
expect(argv[0]).toBe("claude");
|
expect(argv[0]).toBe("claude");
|
||||||
expect(argv.at(-1)).toContain("You are Clawd (Claude)");
|
expect(argv.at(-1)).toContain("You are Clawd (Claude)");
|
||||||
expect(argv.at(-1)).toContain("/Users/steipete/clawd");
|
expect(argv.at(-1)).toContain("scratchpad");
|
||||||
expect(argv.at(-1)).toMatch(/hi$/);
|
expect(argv.at(-1)).toMatch(/hi$/);
|
||||||
// The helper should auto-add print and output format flags without disturbing the prompt position.
|
// The helper should auto-add print and output format flags without disturbing the prompt position.
|
||||||
expect(argv.includes("-p") || argv.includes("--print")).toBe(true);
|
expect(argv.includes("-p") || argv.includes("--print")).toBe(true);
|
||||||
|
|
@ -963,7 +963,7 @@ describe("config and templating", () => {
|
||||||
expect(result?.text).toBe("Sure! What's up?");
|
expect(result?.text).toBe("Sure! What's up?");
|
||||||
const argv = runSpy.mock.calls[0][0];
|
const argv = runSpy.mock.calls[0][0];
|
||||||
expect(argv.at(-1)).toContain("You are Clawd (Claude)");
|
expect(argv.at(-1)).toContain("You are Clawd (Claude)");
|
||||||
expect(argv.at(-1)).toContain("/Users/steipete/clawd");
|
expect(argv.at(-1)).toContain("scratchpad");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("serializes command auto-replies via the queue", async () => {
|
it("serializes command auto-replies via the queue", async () => {
|
||||||
|
|
|
||||||
|
|
@ -7,11 +7,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
import { setVerbose } from "./globals.js";
|
import { setVerbose } from "./globals.js";
|
||||||
import { logDebug, logError, logInfo, logSuccess, logWarn } from "./logger.js";
|
import { logDebug, logError, logInfo, logSuccess, logWarn } from "./logger.js";
|
||||||
import {
|
import { DEFAULT_LOG_DIR, resetLogger, setLoggerOverride } from "./logging.js";
|
||||||
DEFAULT_LOG_DIR,
|
|
||||||
resetLogger,
|
|
||||||
setLoggerOverride,
|
|
||||||
} from "./logging.js";
|
|
||||||
import type { RuntimeEnv } from "./runtime.js";
|
import type { RuntimeEnv } from "./runtime.js";
|
||||||
|
|
||||||
describe("logger helpers", () => {
|
describe("logger helpers", () => {
|
||||||
|
|
|
||||||
|
|
@ -133,7 +133,11 @@ function pruneOldRollingLogs(dir: string): void {
|
||||||
const cutoff = Date.now() - MAX_LOG_AGE_MS;
|
const cutoff = Date.now() - MAX_LOG_AGE_MS;
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
if (!entry.isFile()) continue;
|
if (!entry.isFile()) continue;
|
||||||
if (!entry.name.startsWith(`${LOG_PREFIX}-`) || !entry.name.endsWith(LOG_SUFFIX)) continue;
|
if (
|
||||||
|
!entry.name.startsWith(`${LOG_PREFIX}-`) ||
|
||||||
|
!entry.name.endsWith(LOG_SUFFIX)
|
||||||
|
)
|
||||||
|
continue;
|
||||||
const fullPath = path.join(dir, entry.name);
|
const fullPath = path.join(dir, entry.name);
|
||||||
try {
|
try {
|
||||||
const stat = fs.statSync(fullPath);
|
const stat = fs.statSync(fullPath);
|
||||||
|
|
|
||||||
|
|
@ -54,7 +54,9 @@ describe("media server", () => {
|
||||||
const server = await startMediaServer(0, 5_000);
|
const server = await startMediaServer(0, 5_000);
|
||||||
const port = (server.address() as AddressInfo).port;
|
const port = (server.address() as AddressInfo).port;
|
||||||
// URL-encoded "../" to bypass client-side path normalization
|
// URL-encoded "../" to bypass client-side path normalization
|
||||||
const res = await fetch(`http://localhost:${port}/media/%2e%2e%2fpackage.json`);
|
const res = await fetch(
|
||||||
|
`http://localhost:${port}/media/%2e%2e%2fpackage.json`,
|
||||||
|
);
|
||||||
expect(res.status).toBe(400);
|
expect(res.status).toBe(400);
|
||||||
expect(await res.text()).toBe("invalid path");
|
expect(await res.text()).toBe("invalid path");
|
||||||
await new Promise((r) => server.close(r));
|
await new Promise((r) => server.close(r));
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import path from "node:path";
|
||||||
import express, { type Express } from "express";
|
import express, { type Express } from "express";
|
||||||
import { danger } from "../globals.js";
|
import { danger } from "../globals.js";
|
||||||
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
|
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
|
||||||
|
import { detectMime } from "./mime.js";
|
||||||
import { cleanOldMedia, getMediaDir } from "./store.js";
|
import { cleanOldMedia, getMediaDir } from "./store.js";
|
||||||
|
|
||||||
const DEFAULT_TTL_MS = 2 * 60 * 1000;
|
const DEFAULT_TTL_MS = 2 * 60 * 1000;
|
||||||
|
|
@ -19,7 +20,6 @@ export function attachMediaRoutes(
|
||||||
const id = req.params.id;
|
const id = req.params.id;
|
||||||
const mediaRoot = (await fs.realpath(mediaDir)) + path.sep;
|
const mediaRoot = (await fs.realpath(mediaDir)) + path.sep;
|
||||||
const file = path.resolve(mediaRoot, id);
|
const file = path.resolve(mediaRoot, id);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const lstat = await fs.lstat(file);
|
const lstat = await fs.lstat(file);
|
||||||
if (lstat.isSymbolicLink()) {
|
if (lstat.isSymbolicLink()) {
|
||||||
|
|
@ -37,13 +37,14 @@ export function attachMediaRoutes(
|
||||||
res.status(410).send("expired");
|
res.status(410).send("expired");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
res.sendFile(realPath);
|
const data = await fs.readFile(realPath);
|
||||||
|
const mime = detectMime({ buffer: data, filePath: realPath });
|
||||||
|
if (mime) res.type(mime);
|
||||||
|
res.send(data);
|
||||||
// best-effort single-use cleanup after response ends
|
// best-effort single-use cleanup after response ends
|
||||||
res.on("finish", () => {
|
setTimeout(() => {
|
||||||
setTimeout(() => {
|
fs.rm(realPath).catch(() => {});
|
||||||
fs.rm(realPath).catch(() => {});
|
}, 500);
|
||||||
}, 500);
|
|
||||||
});
|
|
||||||
} catch {
|
} catch {
|
||||||
res.status(404).send("not found");
|
res.status(404).send("not found");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -48,9 +48,21 @@ async function downloadToFile(
|
||||||
url: string,
|
url: string,
|
||||||
dest: string,
|
dest: string,
|
||||||
headers?: Record<string, string>,
|
headers?: Record<string, string>,
|
||||||
|
maxRedirects = 5,
|
||||||
): Promise<{ headerMime?: string; sniffBuffer: Buffer; size: number }> {
|
): Promise<{ headerMime?: string; sniffBuffer: Buffer; size: number }> {
|
||||||
return await new Promise((resolve, reject) => {
|
return await new Promise((resolve, reject) => {
|
||||||
const req = request(url, { headers }, (res) => {
|
const req = request(url, { headers }, (res) => {
|
||||||
|
// Follow redirects
|
||||||
|
if (res.statusCode && res.statusCode >= 300 && res.statusCode < 400) {
|
||||||
|
const location = res.headers.location;
|
||||||
|
if (!location || maxRedirects <= 0) {
|
||||||
|
reject(new Error(`Redirect loop or missing Location header`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const redirectUrl = new URL(location, url).href;
|
||||||
|
resolve(downloadToFile(redirectUrl, dest, headers, maxRedirects - 1));
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!res.statusCode || res.statusCode >= 400) {
|
if (!res.statusCode || res.statusCode >= 400) {
|
||||||
reject(new Error(`HTTP ${res.statusCode ?? "?"} downloading media`));
|
reject(new Error(`HTTP ${res.statusCode ?? "?"} downloading media`));
|
||||||
return;
|
return;
|
||||||
|
|
@ -107,9 +119,9 @@ export async function saveMediaSource(
|
||||||
const dir = subdir ? path.join(MEDIA_DIR, subdir) : MEDIA_DIR;
|
const dir = subdir ? path.join(MEDIA_DIR, subdir) : MEDIA_DIR;
|
||||||
await fs.mkdir(dir, { recursive: true });
|
await fs.mkdir(dir, { recursive: true });
|
||||||
await cleanOldMedia();
|
await cleanOldMedia();
|
||||||
const id = crypto.randomUUID();
|
const baseId = crypto.randomUUID();
|
||||||
if (looksLikeUrl(source)) {
|
if (looksLikeUrl(source)) {
|
||||||
const tempDest = path.join(dir, `${id}.tmp`);
|
const tempDest = path.join(dir, `${baseId}.tmp`);
|
||||||
const { headerMime, sniffBuffer, size } = await downloadToFile(
|
const { headerMime, sniffBuffer, size } = await downloadToFile(
|
||||||
source,
|
source,
|
||||||
tempDest,
|
tempDest,
|
||||||
|
|
@ -122,7 +134,8 @@ export async function saveMediaSource(
|
||||||
});
|
});
|
||||||
const ext =
|
const ext =
|
||||||
extensionForMime(mime) ?? path.extname(new URL(source).pathname);
|
extensionForMime(mime) ?? path.extname(new URL(source).pathname);
|
||||||
const finalDest = path.join(dir, ext ? `${id}${ext}` : id);
|
const id = ext ? `${baseId}${ext}` : baseId;
|
||||||
|
const finalDest = path.join(dir, id);
|
||||||
await fs.rename(tempDest, finalDest);
|
await fs.rename(tempDest, finalDest);
|
||||||
return { id, path: finalDest, size, contentType: mime };
|
return { id, path: finalDest, size, contentType: mime };
|
||||||
}
|
}
|
||||||
|
|
@ -137,7 +150,8 @@ export async function saveMediaSource(
|
||||||
const buffer = await fs.readFile(source);
|
const buffer = await fs.readFile(source);
|
||||||
const mime = detectMime({ buffer, filePath: source });
|
const mime = detectMime({ buffer, filePath: source });
|
||||||
const ext = extensionForMime(mime) ?? path.extname(source);
|
const ext = extensionForMime(mime) ?? path.extname(source);
|
||||||
const dest = path.join(dir, ext ? `${id}${ext}` : id);
|
const id = ext ? `${baseId}${ext}` : baseId;
|
||||||
|
const dest = path.join(dir, id);
|
||||||
await fs.writeFile(dest, buffer);
|
await fs.writeFile(dest, buffer);
|
||||||
return { id, path: dest, size: stat.size, contentType: mime };
|
return { id, path: dest, size: stat.size, contentType: mime };
|
||||||
}
|
}
|
||||||
|
|
@ -152,10 +166,11 @@ export async function saveMediaBuffer(
|
||||||
}
|
}
|
||||||
const dir = path.join(MEDIA_DIR, subdir);
|
const dir = path.join(MEDIA_DIR, subdir);
|
||||||
await fs.mkdir(dir, { recursive: true });
|
await fs.mkdir(dir, { recursive: true });
|
||||||
const id = crypto.randomUUID();
|
const baseId = crypto.randomUUID();
|
||||||
const mime = detectMime({ buffer, headerMime: contentType });
|
const mime = detectMime({ buffer, headerMime: contentType });
|
||||||
const ext = extensionForMime(mime);
|
const ext = extensionForMime(mime);
|
||||||
const dest = path.join(dir, ext ? `${id}${ext}` : id);
|
const id = ext ? `${baseId}${ext}` : baseId;
|
||||||
|
const dest = path.join(dir, id);
|
||||||
await fs.writeFile(dest, buffer);
|
await fs.writeFile(dest, buffer);
|
||||||
return { id, path: dest, size: buffer.byteLength, contentType: mime };
|
return { id, path: dest, size: buffer.byteLength, contentType: mime };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
|
import { type ChildProcessWithoutNullStreams, spawn } from "node:child_process";
|
||||||
import readline from "node:readline";
|
import readline from "node:readline";
|
||||||
|
|
||||||
type TauRpcOptions = {
|
type TauRpcOptions = {
|
||||||
|
|
@ -22,7 +22,10 @@ class TauRpcClient {
|
||||||
}
|
}
|
||||||
| undefined;
|
| undefined;
|
||||||
|
|
||||||
constructor(private readonly argv: string[], private readonly cwd: string | undefined) {}
|
constructor(
|
||||||
|
private readonly argv: string[],
|
||||||
|
private readonly cwd: string | undefined,
|
||||||
|
) {}
|
||||||
|
|
||||||
private ensureChild() {
|
private ensureChild() {
|
||||||
if (this.child) return;
|
if (this.child) return;
|
||||||
|
|
@ -37,7 +40,9 @@ class TauRpcClient {
|
||||||
});
|
});
|
||||||
this.child.on("exit", (code, signal) => {
|
this.child.on("exit", (code, signal) => {
|
||||||
if (this.pending) {
|
if (this.pending) {
|
||||||
this.pending.reject(new Error(`tau rpc exited (code=${code}, signal=${signal})`));
|
this.pending.reject(
|
||||||
|
new Error(`tau rpc exited (code=${code}, signal=${signal})`),
|
||||||
|
);
|
||||||
clearTimeout(this.pending.timer);
|
clearTimeout(this.pending.timer);
|
||||||
this.pending = undefined;
|
this.pending = undefined;
|
||||||
}
|
}
|
||||||
|
|
@ -49,7 +54,10 @@ class TauRpcClient {
|
||||||
if (!this.pending) return;
|
if (!this.pending) return;
|
||||||
this.buffer.push(line);
|
this.buffer.push(line);
|
||||||
// Finish on assistant message_end event to mirror parse logic in piSpec
|
// Finish on assistant message_end event to mirror parse logic in piSpec
|
||||||
if (line.includes('"type":"message_end"') && line.includes('"role":"assistant"')) {
|
if (
|
||||||
|
line.includes('"type":"message_end"') &&
|
||||||
|
line.includes('"role":"assistant"')
|
||||||
|
) {
|
||||||
const out = this.buffer.join("\n");
|
const out = this.buffer.join("\n");
|
||||||
clearTimeout(this.pending.timer);
|
clearTimeout(this.pending.timer);
|
||||||
const pending = this.pending;
|
const pending = this.pending;
|
||||||
|
|
@ -64,13 +72,14 @@ class TauRpcClient {
|
||||||
if (this.pending) {
|
if (this.pending) {
|
||||||
throw new Error("tau rpc already handling a request");
|
throw new Error("tau rpc already handling a request");
|
||||||
}
|
}
|
||||||
const child = this.child!;
|
const child = this.child;
|
||||||
|
if (!child) throw new Error("tau rpc child not initialized");
|
||||||
await new Promise<void>((resolve, reject) => {
|
await new Promise<void>((resolve, reject) => {
|
||||||
const ok = child.stdin.write(
|
const ok = child.stdin.write(
|
||||||
JSON.stringify({
|
`${JSON.stringify({
|
||||||
type: "prompt",
|
type: "prompt",
|
||||||
message: { role: "user", content: [{ type: "text", text: prompt }] },
|
message: { role: "user", content: [{ type: "text", text: prompt }] },
|
||||||
}) + "\n",
|
})}\n`,
|
||||||
(err) => (err ? reject(err) : resolve()),
|
(err) => (err ? reject(err) : resolve()),
|
||||||
);
|
);
|
||||||
if (!ok) child.stdin.once("drain", () => resolve());
|
if (!ok) child.stdin.once("drain", () => resolve());
|
||||||
|
|
|
||||||
|
|
@ -41,7 +41,7 @@ export async function sendMessageWeb(
|
||||||
const mimetype =
|
const mimetype =
|
||||||
media.contentType === "audio/ogg"
|
media.contentType === "audio/ogg"
|
||||||
? "audio/ogg; codecs=opus"
|
? "audio/ogg; codecs=opus"
|
||||||
: media.contentType ?? "application/octet-stream";
|
: (media.contentType ?? "application/octet-stream");
|
||||||
payload = { audio: media.buffer, ptt: true, mimetype };
|
payload = { audio: media.buffer, ptt: true, mimetype };
|
||||||
} else if (media.kind === "video") {
|
} else if (media.kind === "video") {
|
||||||
const mimetype = media.contentType ?? "application/octet-stream";
|
const mimetype = media.contentType ?? "application/octet-stream";
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue