fix: msteams attachments + plugin prompt hints

Co-authored-by: Christof <10854026+Evizero@users.noreply.github.com>
This commit is contained in:
Peter Steinberger
2026-01-22 03:27:26 +00:00
parent 5fe8c4ab8c
commit 0f7f7bb95f
50 changed files with 2739 additions and 174 deletions

View File

@@ -1,4 +1,6 @@
import { getChannelDock } from "../channels/dock.js";
import { getChannelPlugin, listChannelPlugins } from "../channels/plugins/index.js";
import { normalizeAnyChannelId } from "../channels/registry.js";
import type { ChannelAgentTool, ChannelMessageActionName } from "../channels/plugins/types.js";
import type { ClawdbotConfig } from "../config/config.js";
@@ -46,3 +48,19 @@ export function listChannelAgentTools(params: { cfg?: ClawdbotConfig }): Channel
}
return tools;
}
export function resolveChannelMessageToolHints(params: {
cfg?: ClawdbotConfig;
channel?: string | null;
accountId?: string | null;
}): string[] {
const channelId = normalizeAnyChannelId(params.channel);
if (!channelId) return [];
const dock = getChannelDock(channelId);
const resolve = dock?.agentPrompt?.messageToolHints;
if (!resolve) return [];
const cfg = params.cfg ?? ({} as ClawdbotConfig);
return (resolve({ cfg, accountId: params.accountId }) ?? [])
.map((entry) => entry.trim())
.filter(Boolean);
}

View File

@@ -5,7 +5,7 @@ import { createAgentSession, SessionManager, SettingsManager } from "@mariozechn
import { resolveHeartbeatPrompt } from "../../auto-reply/heartbeat.js";
import type { ReasoningLevel, ThinkLevel } from "../../auto-reply/thinking.js";
import { listChannelSupportedActions } from "../channel-tools.js";
import { listChannelSupportedActions, resolveChannelMessageToolHints } from "../channel-tools.js";
import { resolveChannelCapabilities } from "../../config/channel-capabilities.js";
import type { ClawdbotConfig } from "../../config/config.js";
import { getMachineDisplayName } from "../../infra/machine-name.js";
@@ -245,6 +245,13 @@ export async function compactEmbeddedPiSession(params: {
channel: runtimeChannel,
})
: undefined;
const messageToolHints = runtimeChannel
? resolveChannelMessageToolHints({
cfg: params.config,
channel: runtimeChannel,
accountId: params.agentAccountId,
})
: undefined;
const runtimeInfo = {
host: machineName,
@@ -287,6 +294,7 @@ export async function compactEmbeddedPiSession(params: {
docsPath: docsPath ?? undefined,
promptMode,
runtimeInfo,
messageToolHints,
sandboxInfo,
tools,
modelAliasLines: buildModelAliasLines(params.config),

View File

@@ -7,7 +7,10 @@ import { streamSimple } from "@mariozechner/pi-ai";
import { createAgentSession, SessionManager, SettingsManager } from "@mariozechner/pi-coding-agent";
import { resolveHeartbeatPrompt } from "../../../auto-reply/heartbeat.js";
import { listChannelSupportedActions } from "../../channel-tools.js";
import {
listChannelSupportedActions,
resolveChannelMessageToolHints,
} from "../../channel-tools.js";
import { resolveChannelCapabilities } from "../../../config/channel-capabilities.js";
import { getMachineDisplayName } from "../../../infra/machine-name.js";
import { resolveTelegramInlineButtonsScope } from "../../../telegram/inline-buttons.js";
@@ -260,6 +263,13 @@ export async function runEmbeddedAttempt(
channel: runtimeChannel,
})
: undefined;
const messageToolHints = runtimeChannel
? resolveChannelMessageToolHints({
cfg: params.config,
channel: runtimeChannel,
accountId: params.agentAccountId,
})
: undefined;
const defaultModelRef = resolveDefaultModelForAgent({
cfg: params.config ?? {},
@@ -305,6 +315,7 @@ export async function runEmbeddedAttempt(
reactionGuidance,
promptMode,
runtimeInfo,
messageToolHints,
sandboxInfo,
tools,
modelAliasLines: buildModelAliasLines(params.config),

View File

@@ -35,6 +35,7 @@ export function buildEmbeddedSystemPrompt(params: {
/** Supported message actions for the current channel (e.g., react, edit, unsend) */
channelActions?: string[];
};
messageToolHints?: string[];
sandboxInfo?: EmbeddedSandboxInfo;
tools: AgentTool[];
modelAliasLines: string[];
@@ -56,6 +57,7 @@ export function buildEmbeddedSystemPrompt(params: {
reactionGuidance: params.reactionGuidance,
promptMode: params.promptMode,
runtimeInfo: params.runtimeInfo,
messageToolHints: params.messageToolHints,
sandboxInfo: params.sandboxInfo,
toolNames: params.tools.map((tool) => tool.name),
toolSummaries: buildToolSummaryMap(params.tools),

View File

@@ -85,6 +85,7 @@ function buildMessagingSection(params: {
messageChannelOptions: string;
inlineButtonsEnabled: boolean;
runtimeChannel?: string;
messageToolHints?: string[];
}) {
if (params.isMinimal) return [];
return [
@@ -105,6 +106,7 @@ function buildMessagingSection(params: {
: params.runtimeChannel
? `- Inline buttons not enabled for ${params.runtimeChannel}. If you need them, ask to set ${params.runtimeChannel}.capabilities.inlineButtons ("dm"|"group"|"all"|"allowlist").`
: "",
...(params.messageToolHints ?? []),
]
.filter(Boolean)
.join("\n")
@@ -159,6 +161,7 @@ export function buildAgentSystemPrompt(params: {
channel?: string;
capabilities?: string[];
};
messageToolHints?: string[];
sandboxInfo?: {
enabled: boolean;
workspaceDir?: string;
@@ -468,6 +471,7 @@ export function buildAgentSystemPrompt(params: {
messageChannelOptions,
inlineButtonsEnabled,
runtimeChannel,
messageToolHints: params.messageToolHints,
}),
];

View File

@@ -2,6 +2,7 @@ import { Type } from "@sinclair/typebox";
import {
listChannelMessageActions,
supportsChannelMessageButtons,
supportsChannelMessageCards,
} from "../../channels/plugins/message-actions.js";
import {
CHANNEL_MESSAGE_ACTION_NAMES,
@@ -36,7 +37,7 @@ function buildRoutingSchema() {
};
}
function buildSendSchema(options: { includeButtons: boolean }) {
function buildSendSchema(options: { includeButtons: boolean; includeCards: boolean }) {
const props: Record<string, unknown> = {
message: Type.Optional(Type.String()),
effectId: Type.Optional(
@@ -77,8 +78,18 @@ function buildSendSchema(options: { includeButtons: boolean }) {
},
),
),
card: Type.Optional(
Type.Object(
{},
{
additionalProperties: true,
description: "Adaptive Card JSON object (when supported by the channel)",
},
),
),
};
if (!options.includeButtons) delete props.buttons;
if (!options.includeCards) delete props.card;
return props;
}
@@ -192,7 +203,7 @@ function buildChannelManagementSchema() {
};
}
function buildMessageToolSchemaProps(options: { includeButtons: boolean }) {
function buildMessageToolSchemaProps(options: { includeButtons: boolean; includeCards: boolean }) {
return {
...buildRoutingSchema(),
...buildSendSchema(options),
@@ -211,7 +222,7 @@ function buildMessageToolSchemaProps(options: { includeButtons: boolean }) {
function buildMessageToolSchemaFromActions(
actions: readonly string[],
options: { includeButtons: boolean },
options: { includeButtons: boolean; includeCards: boolean },
) {
const props = buildMessageToolSchemaProps(options);
return Type.Object({
@@ -222,6 +233,7 @@ function buildMessageToolSchemaFromActions(
const MessageToolSchema = buildMessageToolSchemaFromActions(AllMessageActions, {
includeButtons: true,
includeCards: true,
});
type MessageToolOptions = {
@@ -238,8 +250,10 @@ type MessageToolOptions = {
function buildMessageToolSchema(cfg: ClawdbotConfig) {
const actions = listChannelMessageActions(cfg);
const includeButtons = supportsChannelMessageButtons(cfg);
const includeCards = supportsChannelMessageCards(cfg);
return buildMessageToolSchemaFromActions(actions.length > 0 ? actions : ["send"], {
includeButtons,
includeCards,
});
}

View File

@@ -1,7 +1,7 @@
import type { NormalizedUsage } from "../../agents/usage.js";
import { getChannelDock } from "../../channels/dock.js";
import type { ChannelId, ChannelThreadingToolContext } from "../../channels/plugins/types.js";
import { normalizeChannelId } from "../../channels/registry.js";
import { normalizeAnyChannelId, normalizeChannelId } from "../../channels/registry.js";
import type { ClawdbotConfig } from "../../config/config.js";
import { isReasoningTagProvider } from "../../utils/provider-utils.js";
import { estimateUsageCost, formatTokenCount, formatUsd } from "../../utils/usage-format.js";
@@ -23,7 +23,7 @@ export function buildThreadingToolContext(params: {
if (!config) return {};
const rawProvider = sessionCtx.Provider?.trim().toLowerCase();
if (!rawProvider) return {};
const provider = normalizeChannelId(rawProvider);
const provider = normalizeChannelId(rawProvider) ?? normalizeAnyChannelId(rawProvider);
// Fallback for unrecognized/plugin channels (e.g., BlueBubbles before plugin registry init)
const dock = provider ? getChannelDock(provider) : undefined;
if (!dock?.threading?.buildToolContext) {

View File

@@ -22,6 +22,7 @@ import type {
ChannelElevatedAdapter,
ChannelGroupAdapter,
ChannelId,
ChannelAgentPromptAdapter,
ChannelMentionAdapter,
ChannelPlugin,
ChannelThreadingAdapter,
@@ -51,6 +52,7 @@ export type ChannelDock = {
groups?: ChannelGroupAdapter;
mentions?: ChannelMentionAdapter;
threading?: ChannelThreadingAdapter;
agentPrompt?: ChannelAgentPromptAdapter;
};
type ChannelDockStreaming = {
@@ -319,6 +321,7 @@ function buildDockFromPlugin(plugin: ChannelPlugin): ChannelDock {
groups: plugin.groups,
mentions: plugin.mentions,
threading: plugin.threading,
agentPrompt: plugin.agentPrompt,
};
}

View File

@@ -21,6 +21,13 @@ export function supportsChannelMessageButtons(cfg: ClawdbotConfig): boolean {
return false;
}
export function supportsChannelMessageCards(cfg: ClawdbotConfig): boolean {
for (const plugin of listChannelPlugins()) {
if (plugin.actions?.supportsCards?.({ cfg })) return true;
}
return false;
}
export async function dispatchChannelMessageAction(
ctx: ChannelMessageActionContext,
): Promise<AgentToolResult<unknown> | null> {

View File

@@ -240,6 +240,10 @@ export type ChannelMessagingAdapter = {
}) => string;
};
export type ChannelAgentPromptAdapter = {
messageToolHints?: (params: { cfg: ClawdbotConfig; accountId?: string | null }) => string[];
};
export type ChannelDirectoryEntryKind = "user" | "group" | "channel";
export type ChannelDirectoryEntry = {
@@ -281,6 +285,7 @@ export type ChannelMessageActionAdapter = {
listActions?: (params: { cfg: ClawdbotConfig }) => ChannelMessageActionName[];
supportsAction?: (params: { action: ChannelMessageActionName }) => boolean;
supportsButtons?: (params: { cfg: ClawdbotConfig }) => boolean;
supportsCards?: (params: { cfg: ClawdbotConfig }) => boolean;
extractToolSend?: (params: { args: Record<string, unknown> }) => ChannelToolSend | null;
handleAction?: (ctx: ChannelMessageActionContext) => Promise<AgentToolResult<unknown>>;
};

View File

@@ -20,6 +20,7 @@ import type {
ChannelAgentToolFactory,
ChannelCapabilities,
ChannelId,
ChannelAgentPromptAdapter,
ChannelMentionAdapter,
ChannelMessageActionAdapter,
ChannelMessagingAdapter,
@@ -73,6 +74,7 @@ export type ChannelPlugin<ResolvedAccount = any> = {
streaming?: ChannelStreamingAdapter;
threading?: ChannelThreadingAdapter;
messaging?: ChannelMessagingAdapter;
agentPrompt?: ChannelAgentPromptAdapter;
directory?: ChannelDirectoryAdapter;
resolver?: ChannelResolverAdapter;
actions?: ChannelMessageActionAdapter;

View File

@@ -31,6 +31,7 @@ export type {
export type {
ChannelAccountSnapshot,
ChannelAccountState,
ChannelAgentPromptAdapter,
ChannelAgentTool,
ChannelAgentToolFactory,
ChannelCapabilities,

View File

@@ -5,6 +5,7 @@ import { CHANNEL_TARGET_DESCRIPTION } from "../../../infra/outbound/channel-targ
import { defaultRuntime } from "../../../runtime.js";
import { createDefaultDeps } from "../../deps.js";
import { runCommandWithRuntime } from "../../cli-utils.js";
import { ensurePluginRegistryLoaded } from "../../plugin-registry.js";
export type MessageCliHelpers = {
withMessageBase: (command: Command) => Command;
@@ -32,6 +33,7 @@ export function createMessageCliHelpers(
const runMessageAction = async (action: string, opts: Record<string, unknown>) => {
setVerbose(Boolean(opts.verbose));
ensurePluginRegistryLoaded();
const deps = createDefaultDeps();
await runCommandWithRuntime(
defaultRuntime,

View File

@@ -19,6 +19,7 @@ export function registerMessageSendCommand(message: Command, helpers: MessageCli
"--buttons <json>",
"Telegram inline keyboard buttons as JSON (array of button rows)",
)
.option("--card <json>", "Adaptive Card JSON object (when supported by the channel)")
.option("--reply-to <id>", "Reply-to message id")
.option("--thread-id <id>", "Thread id (Telegram forum thread)")
.option("--gif-playback", "Treat video media as GIF playback (WhatsApp only).", false),

View File

@@ -78,4 +78,8 @@ export type MSTeamsConfig = {
replyStyle?: MSTeamsReplyStyle;
/** Per-team config. Key is team ID (from the /team/ URL path segment). */
teams?: Record<string, MSTeamsTeamConfig>;
/** Max media size in MB (default: 100MB for OneDrive upload support). */
mediaMaxMb?: number;
/** SharePoint site ID for file uploads in group chats/channels (e.g., "contoso.sharepoint.com,guid1,guid2"). */
sharePointSiteId?: string;
};

View File

@@ -599,6 +599,10 @@ export const MSTeamsConfigSchema = z
dms: z.record(z.string(), DmConfigSchema.optional()).optional(),
replyStyle: MSTeamsReplyStyleSchema.optional(),
teams: z.record(z.string(), MSTeamsTeamSchema.optional()).optional(),
/** Max media size in MB (default: 100MB for OneDrive upload support). */
mediaMaxMb: z.number().positive().optional(),
/** SharePoint site ID for file uploads in group chats/channels (e.g., "contoso.sharepoint.com,guid1,guid2") */
sharePointSiteId: z.string().optional(),
})
.strict()
.superRefine((value, ctx) => {

View File

@@ -191,6 +191,12 @@ export const ClawdbotSchema = z
bindings: BindingsSchema,
broadcast: BroadcastSchema,
audio: AudioSchema,
media: z
.object({
preserveFilenames: z.boolean().optional(),
})
.strict()
.optional(),
messages: MessagesSchema,
commands: CommandsSchema,
session: SessionSchema,

View File

@@ -410,6 +410,21 @@ function parseButtonsParam(params: Record<string, unknown>): void {
}
}
function parseCardParam(params: Record<string, unknown>): void {
const raw = params.card;
if (typeof raw !== "string") return;
const trimmed = raw.trim();
if (!trimmed) {
delete params.card;
return;
}
try {
params.card = JSON.parse(trimmed) as unknown;
} catch {
throw new Error("--card must be valid JSON");
}
}
async function resolveChannel(cfg: ClawdbotConfig, params: Record<string, unknown>) {
const channelHint = readStringParam(params, "channel");
const selection = await resolveMessageChannelSelection({
@@ -558,10 +573,15 @@ async function handleSendAction(ctx: ResolvedActionContext): Promise<MessageActi
const { cfg, params, channel, accountId, dryRun, gateway, input } = ctx;
const action: ChannelMessageActionName = "send";
const to = readStringParam(params, "to", { required: true });
const mediaHint = readStringParam(params, "media", { trim: false });
// Support media, path, and filePath parameters for attachments
const mediaHint =
readStringParam(params, "media", { trim: false }) ??
readStringParam(params, "path", { trim: false }) ??
readStringParam(params, "filePath", { trim: false });
const hasCard = params.card != null && typeof params.card === "object";
let message =
readStringParam(params, "message", {
required: !mediaHint,
required: !mediaHint && !hasCard,
allowEmpty: true,
}) ?? "";
@@ -570,7 +590,8 @@ async function handleSendAction(ctx: ResolvedActionContext): Promise<MessageActi
params.message = message;
if (!params.replyTo && parsed.replyToId) params.replyTo = parsed.replyToId;
if (!params.media) {
params.media = parsed.mediaUrls?.[0] || parsed.mediaUrl || undefined;
// Use path/filePath if media not set, then fall back to parsed directives
params.media = mediaHint || parsed.mediaUrls?.[0] || parsed.mediaUrl || undefined;
}
message = await maybeApplyCrossContextMarker({
@@ -729,6 +750,7 @@ export async function runMessageAction(
const cfg = input.cfg;
const params = { ...input.params };
parseButtonsParam(params);
parseCardParam(params);
const action = input.action;
if (action === "broadcast") {

View File

@@ -32,9 +32,11 @@ const EXT_BY_MIME: Record<string, string> = {
"text/markdown": ".md",
};
const MIME_BY_EXT: Record<string, string> = Object.fromEntries(
Object.entries(EXT_BY_MIME).map(([mime, ext]) => [ext, mime]),
);
const MIME_BY_EXT: Record<string, string> = {
...Object.fromEntries(Object.entries(EXT_BY_MIME).map(([mime, ext]) => [ext, mime])),
// Additional extension aliases
".jpeg": "image/jpeg",
};
const AUDIO_FILE_EXTENSIONS = new Set([
".aac",

View File

@@ -161,4 +161,114 @@ describe("media store", () => {
expect(path.extname(saved.path)).toBe(".xlsx");
});
});
describe("extractOriginalFilename", () => {
it("extracts original filename from embedded pattern", async () => {
await withTempStore(async (store) => {
// Pattern: {original}---{uuid}.{ext}
const filename = "report---a1b2c3d4-e5f6-7890-abcd-ef1234567890.pdf";
const result = store.extractOriginalFilename(`/path/to/${filename}`);
expect(result).toBe("report.pdf");
});
});
it("handles uppercase UUID pattern", async () => {
await withTempStore(async (store) => {
const filename = "Document---A1B2C3D4-E5F6-7890-ABCD-EF1234567890.docx";
const result = store.extractOriginalFilename(`/media/inbound/${filename}`);
expect(result).toBe("Document.docx");
});
});
it("falls back to basename for non-matching patterns", async () => {
await withTempStore(async (store) => {
// UUID-only filename (legacy format)
const uuidOnly = "a1b2c3d4-e5f6-7890-abcd-ef1234567890.pdf";
expect(store.extractOriginalFilename(`/path/${uuidOnly}`)).toBe(uuidOnly);
// Regular filename without embedded pattern
expect(store.extractOriginalFilename("/path/to/regular.txt")).toBe("regular.txt");
// Filename with --- but invalid UUID part
expect(store.extractOriginalFilename("/path/to/foo---bar.txt")).toBe("foo---bar.txt");
});
});
it("preserves original name with special characters", async () => {
await withTempStore(async (store) => {
const filename = "报告_2024---a1b2c3d4-e5f6-7890-abcd-ef1234567890.pdf";
const result = store.extractOriginalFilename(`/media/${filename}`);
expect(result).toBe("报告_2024.pdf");
});
});
});
describe("saveMediaBuffer with originalFilename", () => {
it("embeds original filename in stored path when provided", async () => {
await withTempStore(async (store) => {
const buf = Buffer.from("test content");
const saved = await store.saveMediaBuffer(
buf,
"text/plain",
"inbound",
5 * 1024 * 1024,
"report.txt",
);
// Should contain the original name and a UUID pattern
expect(saved.id).toMatch(/^report---[a-f0-9-]{36}\.txt$/);
expect(saved.path).toContain("report---");
// Should be able to extract original name
const extracted = store.extractOriginalFilename(saved.path);
expect(extracted).toBe("report.txt");
});
});
it("sanitizes unsafe characters in original filename", async () => {
await withTempStore(async (store) => {
const buf = Buffer.from("test");
// Filename with unsafe chars: < > : " / \ | ? *
const saved = await store.saveMediaBuffer(
buf,
"text/plain",
"inbound",
5 * 1024 * 1024,
"my<file>:test.txt",
);
// Unsafe chars should be replaced with underscores
expect(saved.id).toMatch(/^my_file_test---[a-f0-9-]{36}\.txt$/);
});
});
it("truncates long original filenames", async () => {
await withTempStore(async (store) => {
const buf = Buffer.from("test");
const longName = "a".repeat(100) + ".txt";
const saved = await store.saveMediaBuffer(
buf,
"text/plain",
"inbound",
5 * 1024 * 1024,
longName,
);
// Original name should be truncated to 60 chars
const baseName = path.parse(saved.id).name.split("---")[0];
expect(baseName.length).toBeLessThanOrEqual(60);
});
});
it("falls back to UUID-only when originalFilename not provided", async () => {
await withTempStore(async (store) => {
const buf = Buffer.from("test");
const saved = await store.saveMediaBuffer(buf, "text/plain", "inbound");
// Should be UUID-only pattern (legacy behavior)
expect(saved.id).toMatch(/^[a-f0-9-]{36}\.txt$/);
expect(saved.id).not.toContain("---");
});
});
});
});

View File

@@ -11,6 +11,43 @@ const resolveMediaDir = () => path.join(resolveConfigDir(), "media");
const MAX_BYTES = 5 * 1024 * 1024; // 5MB default
const DEFAULT_TTL_MS = 2 * 60 * 1000; // 2 minutes
/**
* Sanitize a filename for cross-platform safety.
* Removes chars unsafe on Windows/SharePoint/all platforms.
* Keeps: alphanumeric, dots, hyphens, underscores, Unicode letters/numbers.
*/
function sanitizeFilename(name: string): string {
// Remove: < > : " / \ | ? * and control chars (U+0000-U+001F)
// oxlint-disable-next-line no-control-regex -- Intentionally matching control chars
const unsafe = /[<>:"/\\|?*\x00-\x1f]/g;
const sanitized = name.trim().replace(unsafe, "_").replace(/\s+/g, "_"); // Replace whitespace runs with underscore
// Collapse multiple underscores, trim leading/trailing, limit length
return sanitized.replace(/_+/g, "_").replace(/^_|_$/g, "").slice(0, 60);
}
/**
* Extract original filename from path if it matches the embedded format.
* Pattern: {original}---{uuid}.{ext} → returns "{original}.{ext}"
* Falls back to basename if no pattern match, or "file.bin" if empty.
*/
export function extractOriginalFilename(filePath: string): string {
const basename = path.basename(filePath);
if (!basename) return "file.bin"; // Fallback for empty input
const ext = path.extname(basename);
const nameWithoutExt = path.basename(basename, ext);
// Check for ---{uuid} pattern (36 chars: 8-4-4-4-12 with hyphens)
const match = nameWithoutExt.match(
/^(.+)---[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i,
);
if (match?.[1]) {
return `${match[1]}${ext}`;
}
return basename; // Fallback: use as-is
}
export function getMediaDir() {
return resolveMediaDir();
}
@@ -152,17 +189,29 @@ export async function saveMediaBuffer(
contentType?: string,
subdir = "inbound",
maxBytes = MAX_BYTES,
originalFilename?: string,
): Promise<SavedMedia> {
if (buffer.byteLength > maxBytes) {
throw new Error(`Media exceeds ${(maxBytes / (1024 * 1024)).toFixed(0)}MB limit`);
}
const dir = path.join(resolveMediaDir(), subdir);
await fs.mkdir(dir, { recursive: true });
const baseId = crypto.randomUUID();
const uuid = crypto.randomUUID();
const headerExt = extensionForMime(contentType?.split(";")[0]?.trim() ?? undefined);
const mime = await detectMime({ buffer, headerMime: contentType });
const ext = headerExt ?? extensionForMime(mime);
const id = ext ? `${baseId}${ext}` : baseId;
const ext = headerExt ?? extensionForMime(mime) ?? "";
let id: string;
if (originalFilename) {
// Embed original name: {sanitized}---{uuid}.ext
const base = path.parse(originalFilename).name;
const sanitized = sanitizeFilename(base);
id = sanitized ? `${sanitized}---${uuid}${ext}` : `${uuid}${ext}`;
} else {
// Legacy: just UUID
id = ext ? `${uuid}${ext}` : uuid;
}
const dest = path.join(dir, id);
await fs.writeFile(dest, buffer);
return { id, path: dest, size: buffer.byteLength, contentType: mime };

View File

@@ -19,7 +19,6 @@ describe("plugin-sdk exports", () => {
"writeConfigFile",
"runCommandWithTimeout",
"enqueueSystemEvent",
"detectMime",
"fetchRemoteMedia",
"saveMediaBuffer",
"formatAgentEnvelope",

View File

@@ -206,6 +206,8 @@ export type {
DiagnosticWebhookProcessedEvent,
DiagnosticWebhookReceivedEvent,
} from "../infra/diagnostic-events.js";
export { detectMime, extensionForMime, getFileExtension } from "../media/mime.js";
export { extractOriginalFilename } from "../media/store.js";
// Channel: Discord
export {
@@ -282,3 +284,6 @@ export { collectWhatsAppStatusIssues } from "../channels/plugins/status-issues/w
// Channel: BlueBubbles
export { collectBlueBubblesStatusIssues } from "../channels/plugins/status-issues/bluebubbles.js";
// Media utilities
export { loadWebMedia, type WebMediaResult } from "../web/media.js";

View File

@@ -4,11 +4,12 @@ import { fileURLToPath } from "node:url";
import { logVerbose, shouldLogVerbose } from "../globals.js";
import { type MediaKind, maxBytesForKind, mediaKindFromMime } from "../media/constants.js";
import { resolveUserPath } from "../utils.js";
import { fetchRemoteMedia } from "../media/fetch.js";
import { convertHeicToJpeg, resizeToJpeg } from "../media/image-ops.js";
import { detectMime, extensionForMime } from "../media/mime.js";
type WebMediaResult = {
export type WebMediaResult = {
buffer: Buffer;
contentType?: string;
kind: MediaKind;
@@ -89,10 +90,9 @@ async function loadWebMediaInternal(
kind: MediaKind;
fileName?: string;
}): Promise<WebMediaResult> => {
const cap =
maxBytes !== undefined
? Math.min(maxBytes, maxBytesForKind(params.kind))
: maxBytesForKind(params.kind);
// If caller explicitly provides maxBytes, trust it (for channels that handle large files).
// Otherwise fall back to per-kind defaults.
const cap = maxBytes !== undefined ? maxBytes : maxBytesForKind(params.kind);
if (params.kind === "image") {
const isGif = params.contentType === "image/gif";
if (isGif || !optimizeImages) {
@@ -141,6 +141,11 @@ async function loadWebMediaInternal(
return await clampAndFinalize({ buffer, contentType, kind, fileName });
}
// Expand tilde paths to absolute paths (e.g., ~/Downloads/photo.jpg)
if (mediaUrl.startsWith("~")) {
mediaUrl = resolveUserPath(mediaUrl);
}
// Local path
const data = await fs.readFile(mediaUrl);
const mime = await detectMime({ buffer: data, filePath: mediaUrl });