mirror of
https://github.com/moltbot/moltbot.git
synced 2026-04-24 07:01:49 +00:00
fix: unify upload-file message actions
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -201,6 +201,7 @@ Notes:
|
||||
- Default webhook path is `/googlechat` if `webhookPath` isn’t 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`).
|
||||
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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 [
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user