fix: unify upload-file message actions

This commit is contained in:
Peter Steinberger
2026-03-27 01:00:06 +00:00
parent 046a950877
commit ba60154826
13 changed files with 333 additions and 27 deletions

View File

@@ -12,6 +12,7 @@ Docs: https://docs.openclaw.ai
- MiniMax: add image generation provider for `image-01` model, supporting generate and image-to-image editing with aspect ratio control. (#54487) Thanks @liyuan97.
- Slack/tool actions: add an explicit `upload-file` Slack action that routes file uploads through the existing Slack upload transport, with optional filename/title/comment overrides for channels and DMs.
- Message actions/files: start unifying file-first sends on the canonical `upload-file` action by adding explicit support for Microsoft Teams and Google Chat, and by exposing BlueBubbles file sends through `upload-file` while keeping the legacy `sendAttachment` alias.
- Plugins/Matrix TTS: send auto-TTS replies as native Matrix voice bubbles instead of generic audio attachments. (#37080) thanks @Matthew19990919.
- Memory/plugins: move the pre-compaction memory flush plan behind the active memory plugin contract so `memory-core` owns flush prompts and target-path policy instead of hardcoded core logic.
- MiniMax: trim model catalog to M2.7 only, removing legacy M2, M2.1, M2.5, and VL-01 models. (#54487) Thanks @liyuan97.

View File

@@ -266,8 +266,9 @@ Available actions:
- **addParticipant**: Add someone to a group (`chatGuid`, `address`)
- **removeParticipant**: Remove someone from a group (`chatGuid`, `address`)
- **leaveGroup**: Leave a group chat (`chatGuid`)
- **sendAttachment**: Send media/files (`to`, `buffer`, `filename`, `asVoice`)
- **upload-file**: Send media/files (`to`, `buffer`, `filename`, `asVoice`)
- Voice memos: set `asVoice: true` with **MP3** or **CAF** audio to send as an iMessage voice message. BlueBubbles converts MP3 → CAF when sending voice memos.
- Legacy alias: `sendAttachment` still works, but `upload-file` is the canonical action name.
### Message IDs (short vs full)

View File

@@ -201,6 +201,7 @@ Notes:
- Default webhook path is `/googlechat` if `webhookPath` isnt set.
- `dangerouslyAllowNameMatching` re-enables mutable email principal matching for allowlists (break-glass compatibility mode).
- Reactions are available via the `reactions` tool and `channels action` when `actions.reactions` is enabled.
- Message actions expose `send` for text and `upload-file` for explicit attachment sends. `upload-file` accepts `media` / `filePath` / `path` plus optional `message`, `filename`, and thread targeting.
- `typingIndicator` supports `none`, `message` (default), and `reaction` (reaction requires user OAuth).
- Attachments are downloaded through the Chat API and stored in the media pipeline (size capped by `mediaMaxMb`).

View File

@@ -11,7 +11,7 @@ title: "Microsoft Teams"
Updated: 2026-01-21
Status: text + DM attachments are supported; channel/group file sending requires `sharePointSiteId` + Graph permissions (see [Sending files in group chats](#sending-files-in-group-chats)). Polls are sent via Adaptive Cards.
Status: text + DM attachments are supported; channel/group file sending requires `sharePointSiteId` + Graph permissions (see [Sending files in group chats](#sending-files-in-group-chats)). Polls are sent via Adaptive Cards. Message actions expose explicit `upload-file` for file-first sends.
## Plugin required
@@ -527,6 +527,7 @@ Teams recently introduced two channel UI styles over the same underlying data mo
- **DMs:** Images and file attachments work via Teams bot file APIs.
- **Channels/groups:** Attachments live in M365 storage (SharePoint/OneDrive). The webhook payload only includes an HTML stub, not the actual file bytes. **Graph API permissions are required** to download channel attachments.
- For explicit file-first sends, use `action=upload-file` with `media` / `filePath` / `path`; optional `message` becomes the accompanying text/comment, and `filename` overrides the uploaded name.
Without Graph permissions, channel messages with images will be received as text-only (the image content is not accessible to the bot).
By default, OpenClaw only downloads media from Microsoft/Teams hostnames. Override with `channels.msteams.mediaAllowHosts` (use `["*"]` to allow any host).

View File

@@ -135,7 +135,8 @@ describe("bluebubblesMessageActions", () => {
},
};
const actions = describeMessageTool({ cfg })?.actions ?? [];
expect(actions).toContain("sendAttachment");
expect(actions).toContain("upload-file");
expect(actions).not.toContain("sendAttachment");
expect(actions).not.toContain("react");
expect(actions).not.toContain("reply");
expect(actions).not.toContain("sendWithEffect");
@@ -165,6 +166,7 @@ describe("bluebubblesMessageActions", () => {
expect(supportsAction({ action: "removeParticipant" })).toBe(true);
expect(supportsAction({ action: "leaveGroup" })).toBe(true);
expect(supportsAction({ action: "sendAttachment" })).toBe(true);
expect(supportsAction({ action: "upload-file" })).toBe(true);
});
it("returns false for unsupported actions", () => {
@@ -204,6 +206,36 @@ describe("bluebubblesMessageActions", () => {
});
describe("handleAction", () => {
it("maps upload-file to the attachment runtime using canonical naming", async () => {
const result = await callHandleAction({
action: "upload-file",
params: {
to: "+15551234567",
filename: "photo.png",
buffer: Buffer.from("img").toString("base64"),
message: "caption",
contentType: "image/png",
},
cfg: blueBubblesConfig(),
accountId: null,
});
expect(sendBlueBubblesAttachment).toHaveBeenCalledWith(
expect.objectContaining({
to: "+15551234567",
filename: "photo.png",
caption: "caption",
contentType: "image/png",
}),
);
expect(result).toMatchObject({
details: {
ok: true,
messageId: "att-msg-123",
},
});
});
it("throws for unsupported actions", async () => {
const cfg: OpenClawConfig = {
channels: {

View File

@@ -52,7 +52,10 @@ function readMessageText(params: Record<string, unknown>): string | undefined {
}
/** Supported action names for BlueBubbles */
const SUPPORTED_ACTIONS = new Set<ChannelMessageActionName>(BLUEBUBBLES_ACTION_NAMES);
const SUPPORTED_ACTIONS = new Set<ChannelMessageActionName>([
...BLUEBUBBLES_ACTION_NAMES,
"upload-file",
]);
const PRIVATE_API_ACTIONS = new Set<ChannelMessageActionName>([
"react",
"edit",
@@ -107,6 +110,9 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
}
}
}
if (actions.delete("sendAttachment")) {
actions.add("upload-file");
}
return { actions: Array.from(actions) };
},
supportsAction: ({ action }) => SUPPORTED_ACTIONS.has(action),
@@ -428,11 +434,11 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
return jsonResult({ ok: true, left: resolvedChatGuid });
}
// Handle sendAttachment action
if (action === "sendAttachment") {
// Handle sendAttachment action (legacy) and upload-file (canonical)
if (action === "sendAttachment" || action === "upload-file") {
const to = readStringParam(params, "to", { required: true });
const filename = readStringParam(params, "filename", { required: true });
const caption = readStringParam(params, "caption");
const caption = readStringParam(params, "caption") ?? readStringParam(params, "message");
const contentType =
readStringParam(params, "contentType") ?? readStringParam(params, "mimeType");
const asVoice = readBooleanParam(params, "asVoice");
@@ -448,10 +454,10 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
} else if (filePath) {
// Read file from path (will be handled by caller providing buffer)
throw new Error(
"BlueBubbles sendAttachment: filePath not supported in action, provide buffer as base64.",
`BlueBubbles ${action}: filePath not supported in action, provide buffer as base64.`,
);
} else {
throw new Error("BlueBubbles sendAttachment requires buffer (base64) parameter.");
throw new Error(`BlueBubbles ${action} requires buffer (base64) parameter.`);
}
const result = await runtime.sendBlueBubblesAttachment({

View File

@@ -47,7 +47,7 @@ describe("googlechat message actions", () => {
]);
expect(googlechatMessageActions.describeMessageTool?.({ cfg: {} as never })).toEqual({
actions: ["send", "react", "reactions"],
actions: ["send", "upload-file", "react", "reactions"],
});
});
@@ -118,6 +118,77 @@ describe("googlechat message actions", () => {
});
});
it("routes upload-file through the same attachment upload path with filename override", async () => {
const { googlechatMessageActions } = await import("./actions.js");
resolveGoogleChatAccount.mockReturnValue({
credentialSource: "service-account",
config: { mediaMaxMb: 5 },
});
resolveGoogleChatOutboundSpace.mockResolvedValue("spaces/BBB");
const loadWebMedia = vi.fn(async () => ({
buffer: Buffer.from("local-bytes"),
fileName: "local.txt",
contentType: "text/plain",
}));
getGoogleChatRuntime.mockReturnValue({
channel: {
media: {
fetchRemoteMedia: vi.fn(),
},
},
media: {
loadWebMedia,
},
});
uploadGoogleChatAttachment.mockResolvedValue({
attachmentUploadToken: "token-2",
});
sendGoogleChatMessage.mockResolvedValue({
messageName: "spaces/BBB/messages/msg-2",
});
if (!googlechatMessageActions.handleAction) {
throw new Error("Expected googlechatMessageActions.handleAction to be defined");
}
const result = await googlechatMessageActions.handleAction({
action: "upload-file",
params: {
to: "spaces/BBB",
path: "/tmp/local.txt",
message: "notes",
filename: "renamed.txt",
},
cfg: {},
accountId: "default",
mediaLocalRoots: ["/tmp"],
} as never);
expect(loadWebMedia).toHaveBeenCalledWith(
"/tmp/local.txt",
expect.objectContaining({ localRoots: ["/tmp"] }),
);
expect(uploadGoogleChatAttachment).toHaveBeenCalledWith(
expect.objectContaining({
space: "spaces/BBB",
filename: "renamed.txt",
}),
);
expect(sendGoogleChatMessage).toHaveBeenCalledWith(
expect.objectContaining({
space: "spaces/BBB",
text: "notes",
attachments: [{ attachmentUploadToken: "token-2", contentName: "renamed.txt" }],
}),
);
expect(result).toMatchObject({
details: {
ok: true,
to: "spaces/BBB",
},
});
});
it("removes only matching app reactions on react remove", async () => {
const { googlechatMessageActions } = await import("./actions.js");

View File

@@ -50,6 +50,23 @@ function resolveAppUserNames(account: { config: { botUser?: string | null } }) {
return new Set(["users/app", account.config.botUser?.trim()].filter(Boolean) as string[]);
}
async function loadGoogleChatActionMedia(params: {
mediaUrl: string;
maxBytes: number;
mediaLocalRoots?: readonly string[];
}) {
const runtime = getGoogleChatRuntime();
return /^https?:\/\//i.test(params.mediaUrl)
? await runtime.channel.media.fetchRemoteMedia({
url: params.mediaUrl,
maxBytes: params.maxBytes,
})
: await runtime.media.loadWebMedia(params.mediaUrl, {
maxBytes: params.maxBytes,
localRoots: params.mediaLocalRoots?.length ? params.mediaLocalRoots : undefined,
});
}
export const googlechatMessageActions: ChannelMessageActionAdapter = {
describeMessageTool: ({ cfg }) => {
const accounts = listEnabledAccounts(cfg);
@@ -58,6 +75,7 @@ export const googlechatMessageActions: ChannelMessageActionAdapter = {
}
const actions = new Set<ChannelMessageActionName>([]);
actions.add("send");
actions.add("upload-file");
if (isReactionsEnabled(accounts, cfg)) {
actions.add("react");
actions.add("reactions");
@@ -67,7 +85,7 @@ export const googlechatMessageActions: ChannelMessageActionAdapter = {
extractToolSend: ({ args }) => {
return extractToolSend(args, "sendMessage");
},
handleAction: async ({ action, params, cfg, accountId }) => {
handleAction: async ({ action, params, cfg, accountId, mediaLocalRoots }) => {
const account = resolveGoogleChatAccount({
cfg: cfg,
accountId,
@@ -76,24 +94,40 @@ export const googlechatMessageActions: ChannelMessageActionAdapter = {
throw new Error("Google Chat credentials are missing.");
}
if (action === "send") {
if (action === "send" || action === "upload-file") {
const to = readStringParam(params, "to", { required: true });
const content = readStringParam(params, "message", {
required: true,
allowEmpty: true,
});
const mediaUrl = readStringParam(params, "media", { trim: false });
const content =
readStringParam(params, "message", {
required: action === "send",
allowEmpty: true,
}) ??
readStringParam(params, "initialComment", {
allowEmpty: true,
}) ??
"";
const mediaUrl =
readStringParam(params, "media", { trim: false }) ??
readStringParam(params, "filePath", { trim: false }) ??
readStringParam(params, "path", { trim: false });
const threadId = readStringParam(params, "threadId") ?? readStringParam(params, "replyTo");
const space = await resolveGoogleChatOutboundSpace({ account, target: to });
if (mediaUrl) {
const core = getGoogleChatRuntime();
const maxBytes = (account.config.mediaMaxMb ?? 20) * 1024 * 1024;
const loaded = await core.channel.media.fetchRemoteMedia({ url: mediaUrl, maxBytes });
const loaded = await loadGoogleChatActionMedia({
mediaUrl,
maxBytes,
mediaLocalRoots,
});
const uploadFileName =
readStringParam(params, "filename") ??
readStringParam(params, "title") ??
loaded.fileName ??
"attachment";
const upload = await uploadGoogleChatAttachment({
account,
space,
filename: loaded.fileName ?? "attachment",
filename: uploadFileName,
buffer: loaded.buffer,
contentType: loaded.contentType,
});
@@ -106,7 +140,7 @@ export const googlechatMessageActions: ChannelMessageActionAdapter = {
? [
{
attachmentUploadToken: upload.attachmentUploadToken,
contentName: loaded.fileName,
contentName: uploadFileName,
},
]
: undefined,
@@ -114,6 +148,10 @@ export const googlechatMessageActions: ChannelMessageActionAdapter = {
return jsonResult({ ok: true, to: space });
}
if (action === "upload-file") {
throw new Error("upload-file requires media, filePath, or path");
}
await sendGoogleChatMessage({
account,
space,

View File

@@ -9,6 +9,7 @@ const {
reactMessageMSTeamsMock,
searchMessagesMSTeamsMock,
sendAdaptiveCardMSTeamsMock,
sendMessageMSTeamsMock,
unpinMessageMSTeamsMock,
} = vi.hoisted(() => ({
editMessageMSTeamsMock: vi.fn(),
@@ -19,6 +20,7 @@ const {
reactMessageMSTeamsMock: vi.fn(),
searchMessagesMSTeamsMock: vi.fn(),
sendAdaptiveCardMSTeamsMock: vi.fn(),
sendMessageMSTeamsMock: vi.fn(),
unpinMessageMSTeamsMock: vi.fn(),
}));
@@ -32,6 +34,7 @@ vi.mock("./channel.runtime.js", () => ({
reactMessageMSTeams: reactMessageMSTeamsMock,
searchMessagesMSTeams: searchMessagesMSTeamsMock,
sendAdaptiveCardMSTeams: sendAdaptiveCardMSTeamsMock,
sendMessageMSTeams: sendMessageMSTeamsMock,
unpinMessageMSTeams: unpinMessageMSTeamsMock,
},
}));
@@ -47,6 +50,7 @@ const actionMocks = [
reactMessageMSTeamsMock,
searchMessagesMSTeamsMock,
sendAdaptiveCardMSTeamsMock,
sendMessageMSTeamsMock,
unpinMessageMSTeamsMock,
];
const currentChannelId = "conversation:19:ctx@thread.tacv2";
@@ -95,6 +99,7 @@ async function runAction(params: {
cfg?: Record<string, unknown>;
params?: Record<string, unknown>;
toolContext?: Record<string, unknown>;
mediaLocalRoots?: readonly string[];
}) {
const handleAction = requireMSTeamsHandleAction();
return await handleAction({
@@ -102,6 +107,7 @@ async function runAction(params: {
action: params.action,
cfg: params.cfg ?? {},
params: params.params ?? {},
mediaLocalRoots: params.mediaLocalRoots,
toolContext: params.toolContext,
} as any);
}
@@ -159,6 +165,7 @@ async function expectSuccessfulAction(params: {
action: Parameters<typeof runAction>[0]["action"];
actionParams?: Parameters<typeof runAction>[0]["params"];
toolContext?: Parameters<typeof runAction>[0]["toolContext"];
mediaLocalRoots?: Parameters<typeof runAction>[0]["mediaLocalRoots"];
runtimeParams: Record<string, unknown>;
details: Record<string, unknown>;
contentDetails?: Record<string, unknown>;
@@ -167,6 +174,7 @@ async function expectSuccessfulAction(params: {
const result = await runAction({
action: params.action,
params: params.actionParams,
mediaLocalRoots: params.mediaLocalRoots,
toolContext: params.toolContext,
});
expectActionRuntimeCall(params.mockFn, params.runtimeParams);
@@ -207,6 +215,59 @@ describe("msteamsPlugin message actions", () => {
});
});
it("advertises upload-file in the message tool surface", () => {
expect(
msteamsPlugin.actions?.describeMessageTool?.({
cfg: {
channels: {
msteams: {
appId: "app-id",
appPassword: "secret",
tenantId: "tenant-id",
},
},
} as any,
})?.actions,
).toContain("upload-file");
});
it("routes upload-file through sendMessageMSTeams with filename override", async () => {
await expectSuccessfulAction({
mockFn: sendMessageMSTeamsMock,
mockResult: {
messageId: "msg-upload-1",
conversationId: "conv-upload-1",
},
action: "upload-file",
actionParams: {
target: padded(targetChannelId),
path: " /tmp/report.pdf ",
message: "Quarterly report",
filename: "Q1-report.pdf",
},
mediaLocalRoots: ["/tmp"],
runtimeParams: {
to: targetChannelId,
text: "Quarterly report",
mediaUrl: " /tmp/report.pdf ",
filename: "Q1-report.pdf",
mediaLocalRoots: ["/tmp"],
},
details: {
ok: true,
channel: "msteams",
messageId: "msg-upload-1",
},
contentDetails: {
ok: true,
channel: "msteams",
action: "upload-file",
messageId: "msg-upload-1",
conversationId: "conv-upload-1",
},
});
});
it("accepts target as an alias for pin actions", async () => {
await expectSuccessfulAction({
mockFn: pinMessageMSTeamsMock,

View File

@@ -198,6 +198,25 @@ function resolveActionContent(params: Record<string, unknown>): string {
: "";
}
function readOptionalTrimmedString(
params: Record<string, unknown>,
key: string,
): string | undefined {
return typeof params[key] === "string" ? params[key].trim() || undefined : undefined;
}
function resolveActionUploadFilePath(params: Record<string, unknown>): string | undefined {
for (const key of ["filePath", "path", "media"] as const) {
if (typeof params[key] === "string") {
const value = params[key];
if (value.trim()) {
return value;
}
}
}
return undefined;
}
function resolveRequiredActionTarget(params: {
actionLabel: string;
toolParams: Record<string, unknown>;
@@ -298,6 +317,7 @@ function describeMSTeamsMessageTool({
return {
actions: enabled
? ([
"upload-file",
"poll",
"edit",
"delete",
@@ -579,6 +599,46 @@ export const msteamsPlugin: ChannelPlugin<ResolvedMSTeamsAccount, ProbeMSTeamsRe
},
});
}
if (ctx.action === "upload-file") {
const mediaUrl = resolveActionUploadFilePath(ctx.params);
if (!mediaUrl) {
return actionError("Upload-file requires media, filePath, or path.");
}
return await runWithRequiredActionTarget({
actionLabel: "Upload-file",
toolParams: ctx.params,
currentChannelId: ctx.toolContext?.currentChannelId,
run: async (to) => {
const { sendMessageMSTeams } = await loadMSTeamsChannelRuntime();
const result = await sendMessageMSTeams({
cfg: ctx.cfg,
to,
text: resolveActionContent(ctx.params),
mediaUrl,
filename:
readOptionalTrimmedString(ctx.params, "filename") ??
readOptionalTrimmedString(ctx.params, "title"),
mediaLocalRoots: ctx.mediaLocalRoots,
});
return jsonActionResultWithDetails(
{
ok: true,
channel: "msteams",
action: "upload-file",
messageId: result.messageId,
conversationId: result.conversationId,
...(result.pendingUploadId ? { pendingUploadId: result.pendingUploadId } : {}),
},
{
ok: true,
channel: "msteams",
messageId: result.messageId,
...(result.pendingUploadId ? { pendingUploadId: result.pendingUploadId } : {}),
},
);
},
});
}
if (ctx.action === "edit") {
const content = resolveActionContent(ctx.params);
if (!content) {

View File

@@ -28,6 +28,8 @@ export type SendMSTeamsMessageParams = {
text: string;
/** Optional media URL */
mediaUrl?: string;
/** Optional filename override for uploaded media/files */
filename?: string;
mediaLocalRoots?: readonly string[];
};
@@ -94,7 +96,7 @@ export type SendMSTeamsCardResult = {
export async function sendMessageMSTeams(
params: SendMSTeamsMessageParams,
): Promise<SendMSTeamsMessageResult> {
const { cfg, to, text, mediaUrl, mediaLocalRoots } = params;
const { cfg, to, text, mediaUrl, filename, mediaLocalRoots } = params;
const tableMode = getMSTeamsRuntime().channel.text.resolveMarkdownTableMode({
cfg,
channel: "msteams",
@@ -129,7 +131,7 @@ export async function sendMessageMSTeams(
const isLargeFile = media.buffer.length >= FILE_CONSENT_THRESHOLD_BYTES;
const isImage = media.contentType?.startsWith("image/") ?? false;
const fallbackFileName = await extractFilename(mediaUrl);
const fileName = media.fileName ?? fallbackFileName;
const fileName = filename?.trim() || media.fileName || fallbackFileName;
log.debug?.("processing media", {
fileName,

View File

@@ -297,7 +297,13 @@ export async function hydrateAttachmentParamsForAction(params: {
dryRun?: boolean;
mediaPolicy: AttachmentMediaPolicy;
}): Promise<void> {
if (params.action !== "sendAttachment" && params.action !== "setGroupIcon") {
const shouldHydrateBlueBubblesUploadFile =
params.action === "upload-file" && params.channel === "bluebubbles";
if (
params.action !== "sendAttachment" &&
params.action !== "setGroupIcon" &&
!shouldHydrateBlueBubblesUploadFile
) {
return;
}
await hydrateAttachmentActionPayload({
@@ -307,7 +313,8 @@ export async function hydrateAttachmentParamsForAction(params: {
args: params.args,
dryRun: params.dryRun,
mediaPolicy: params.mediaPolicy,
allowMessageCaptionFallback: params.action === "sendAttachment",
allowMessageCaptionFallback:
params.action === "sendAttachment" || shouldHydrateBlueBubblesUploadFile,
});
}

View File

@@ -155,8 +155,9 @@ describe("runMessageAction media behavior", () => {
isConfigured: () => true,
},
actions: {
describeMessageTool: () => ({ actions: ["sendAttachment", "setGroupIcon"] }),
supportsAction: ({ action }) => action === "sendAttachment" || action === "setGroupIcon",
describeMessageTool: () => ({ actions: ["sendAttachment", "upload-file", "setGroupIcon"] }),
supportsAction: ({ action }) =>
action === "sendAttachment" || action === "upload-file" || action === "setGroupIcon",
handleAction: async ({ params }) =>
jsonResult({
ok: true,
@@ -267,6 +268,30 @@ describe("runMessageAction media behavior", () => {
);
});
it("hydrates buffer and filename from media for bluebubbles upload-file", async () => {
const result = await runMessageAction({
cfg,
action: "upload-file",
params: {
channel: "bluebubbles",
target: "+15551234567",
media: "https://example.com/pic.png",
message: "caption",
},
});
expect(result.kind).toBe("action");
expect(result.payload).toMatchObject({
ok: true,
filename: "pic.png",
caption: "caption",
contentType: "image/png",
});
expect((result.payload as { buffer?: string }).buffer).toBe(
Buffer.from("hello").toString("base64"),
);
});
it("enforces sandboxed attachment paths for attachment actions", async () => {
for (const testCase of [
{