fix: msteams attachments + plugin prompt hints
Co-authored-by: Christof <10854026+Evizero@users.noreply.github.com>
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
];
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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>>;
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -31,6 +31,7 @@ export type {
|
||||
export type {
|
||||
ChannelAccountSnapshot,
|
||||
ChannelAccountState,
|
||||
ChannelAgentPromptAdapter,
|
||||
ChannelAgentTool,
|
||||
ChannelAgentToolFactory,
|
||||
ChannelCapabilities,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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("---");
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -19,7 +19,6 @@ describe("plugin-sdk exports", () => {
|
||||
"writeConfigFile",
|
||||
"runCommandWithTimeout",
|
||||
"enqueueSystemEvent",
|
||||
"detectMime",
|
||||
"fetchRemoteMedia",
|
||||
"saveMediaBuffer",
|
||||
"formatAgentEnvelope",
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 });
|
||||
|
||||
Reference in New Issue
Block a user