feat: add support for setting group icons in BlueBubbles, enhancing group management capabilities
This commit is contained in:
committed by
Peter Steinberger
parent
574b848863
commit
14a072f5fa
@@ -27,6 +27,7 @@ import { jsonResult, readNumberParam, readStringParam } from "./common.js";
|
||||
const AllMessageActions = CHANNEL_MESSAGE_ACTION_NAMES;
|
||||
const BLUEBUBBLES_GROUP_ACTIONS = new Set<ChannelMessageActionName>([
|
||||
"renameGroup",
|
||||
"setGroupIcon",
|
||||
"addParticipant",
|
||||
"removeParticipant",
|
||||
"leaveGroup",
|
||||
|
||||
@@ -10,6 +10,7 @@ export const CHANNEL_MESSAGE_ACTION_NAMES = [
|
||||
"reply",
|
||||
"sendWithEffect",
|
||||
"renameGroup",
|
||||
"setGroupIcon",
|
||||
"addParticipant",
|
||||
"removeParticipant",
|
||||
"leaveGroup",
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { ClawdbotConfig } from "../../config/config.js";
|
||||
@@ -15,9 +11,13 @@ import { runMessageAction } from "./message-action-runner.js";
|
||||
import { jsonResult } from "../../agents/tools/common.js";
|
||||
import type { ChannelPlugin } from "../../channels/plugins/types.js";
|
||||
|
||||
vi.mock("../../web/media.js", () => ({
|
||||
loadWebMedia: vi.fn(),
|
||||
}));
|
||||
vi.mock("../../web/media.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("../../web/media.js")>("../../web/media.js");
|
||||
return {
|
||||
...actual,
|
||||
loadWebMedia: vi.fn(actual.loadWebMedia),
|
||||
};
|
||||
});
|
||||
|
||||
const slackConfig = {
|
||||
channels: {
|
||||
@@ -76,66 +76,6 @@ describe("runMessageAction context isolation", () => {
|
||||
setActivePluginRegistry(createTestRegistry([]));
|
||||
});
|
||||
|
||||
it("maps sendAttachment media to buffer + filename", async () => {
|
||||
const filePath = path.join(os.tmpdir(), `clawdbot-attachment-${Date.now()}.txt`);
|
||||
await fs.writeFile(filePath, "hello");
|
||||
|
||||
const handleAction = vi.fn(async (ctx) => {
|
||||
return jsonResult({ ok: true, params: ctx.params });
|
||||
});
|
||||
|
||||
const testPlugin: ChannelPlugin = {
|
||||
id: "bluebubbles",
|
||||
meta: {
|
||||
id: "bluebubbles",
|
||||
label: "BlueBubbles",
|
||||
selectionLabel: "BlueBubbles",
|
||||
docsPath: "/channels/bluebubbles",
|
||||
blurb: "BlueBubbles test plugin.",
|
||||
},
|
||||
capabilities: { chatTypes: ["direct", "group"], media: true },
|
||||
config: {
|
||||
listAccountIds: () => [],
|
||||
resolveAccount: () => ({}),
|
||||
},
|
||||
messaging: {
|
||||
targetResolver: {
|
||||
looksLikeId: () => true,
|
||||
hint: "<target>",
|
||||
},
|
||||
normalizeTarget: (raw) => raw.trim(),
|
||||
},
|
||||
actions: {
|
||||
listActions: () => ["sendAttachment"],
|
||||
handleAction: handleAction as NonNullable<ChannelPlugin["actions"]>["handleAction"],
|
||||
},
|
||||
};
|
||||
|
||||
setActivePluginRegistry(
|
||||
createTestRegistry([{ pluginId: "bluebubbles", source: "test", plugin: testPlugin }]),
|
||||
);
|
||||
|
||||
try {
|
||||
const result = await runMessageAction({
|
||||
cfg: { channels: { bluebubbles: {} } } as ClawdbotConfig,
|
||||
action: "sendAttachment",
|
||||
params: {
|
||||
channel: "bluebubbles",
|
||||
target: "chat_guid:TEST",
|
||||
media: filePath,
|
||||
},
|
||||
dryRun: false,
|
||||
});
|
||||
|
||||
expect(result.kind).toBe("action");
|
||||
expect(handleAction).toHaveBeenCalledTimes(1);
|
||||
const params = handleAction.mock.calls[0]?.[0]?.params as Record<string, unknown>;
|
||||
expect(params.filename).toBe(path.basename(filePath));
|
||||
expect(params.buffer).toBe(Buffer.from("hello").toString("base64"));
|
||||
} finally {
|
||||
await fs.unlink(filePath).catch(() => {});
|
||||
}
|
||||
});
|
||||
it("allows send when target matches current channel", async () => {
|
||||
const result = await runMessageAction({
|
||||
cfg: slackConfig,
|
||||
@@ -152,6 +92,21 @@ describe("runMessageAction context isolation", () => {
|
||||
expect(result.kind).toBe("send");
|
||||
});
|
||||
|
||||
it("accepts legacy to parameter for send", async () => {
|
||||
const result = await runMessageAction({
|
||||
cfg: slackConfig,
|
||||
action: "send",
|
||||
params: {
|
||||
channel: "slack",
|
||||
to: "#C12345678",
|
||||
message: "hi",
|
||||
},
|
||||
dryRun: true,
|
||||
});
|
||||
|
||||
expect(result.kind).toBe("send");
|
||||
});
|
||||
|
||||
it("defaults to current channel when target is omitted", async () => {
|
||||
const result = await runMessageAction({
|
||||
cfg: slackConfig,
|
||||
|
||||
@@ -215,9 +215,25 @@ function resolveAttachmentMaxBytes(params: {
|
||||
}
|
||||
const accountId = typeof params.accountId === "string" ? params.accountId.trim() : "";
|
||||
const channelCfg = params.cfg.channels?.bluebubbles;
|
||||
const accountCfg = accountId ? channelCfg?.accounts?.[accountId] : undefined;
|
||||
const channelObj =
|
||||
channelCfg && typeof channelCfg === "object"
|
||||
? (channelCfg as Record<string, unknown>)
|
||||
: undefined;
|
||||
const channelMediaMax =
|
||||
typeof channelObj?.mediaMaxMb === "number" ? channelObj.mediaMaxMb : undefined;
|
||||
const accountsObj =
|
||||
channelObj?.accounts && typeof channelObj.accounts === "object"
|
||||
? (channelObj.accounts as Record<string, unknown>)
|
||||
: undefined;
|
||||
const accountCfg = accountId && accountsObj ? accountsObj[accountId] : undefined;
|
||||
const accountMediaMax =
|
||||
accountCfg && typeof accountCfg === "object"
|
||||
? (accountCfg as Record<string, unknown>).mediaMaxMb
|
||||
: undefined;
|
||||
const limitMb =
|
||||
accountCfg?.mediaMaxMb ?? channelCfg?.mediaMaxMb ?? params.cfg.agents?.defaults?.mediaMaxMb;
|
||||
(typeof accountMediaMax === "number" ? accountMediaMax : undefined) ??
|
||||
channelMediaMax ??
|
||||
params.cfg.agents?.defaults?.mediaMaxMb;
|
||||
return typeof limitMb === "number" ? limitMb * 1024 * 1024 : undefined;
|
||||
}
|
||||
|
||||
@@ -262,6 +278,63 @@ function normalizeBase64Payload(params: { base64?: string; contentType?: string
|
||||
};
|
||||
}
|
||||
|
||||
async function hydrateSetGroupIconParams(params: {
|
||||
cfg: ClawdbotConfig;
|
||||
channel: ChannelId;
|
||||
accountId?: string | null;
|
||||
args: Record<string, unknown>;
|
||||
action: ChannelMessageActionName;
|
||||
dryRun?: boolean;
|
||||
}): Promise<void> {
|
||||
if (params.action !== "setGroupIcon") return;
|
||||
|
||||
const mediaHint = readStringParam(params.args, "media", { trim: false });
|
||||
const fileHint =
|
||||
readStringParam(params.args, "path", { trim: false }) ??
|
||||
readStringParam(params.args, "filePath", { trim: false });
|
||||
const contentTypeParam =
|
||||
readStringParam(params.args, "contentType") ?? readStringParam(params.args, "mimeType");
|
||||
|
||||
const rawBuffer = readStringParam(params.args, "buffer", { trim: false });
|
||||
const normalized = normalizeBase64Payload({
|
||||
base64: rawBuffer,
|
||||
contentType: contentTypeParam ?? undefined,
|
||||
});
|
||||
if (normalized.base64 !== rawBuffer && normalized.base64) {
|
||||
params.args.buffer = normalized.base64;
|
||||
if (normalized.contentType && !contentTypeParam) {
|
||||
params.args.contentType = normalized.contentType;
|
||||
}
|
||||
}
|
||||
|
||||
const filename = readStringParam(params.args, "filename");
|
||||
const mediaSource = mediaHint ?? fileHint;
|
||||
|
||||
if (!params.dryRun && !readStringParam(params.args, "buffer", { trim: false }) && mediaSource) {
|
||||
const maxBytes = resolveAttachmentMaxBytes({
|
||||
cfg: params.cfg,
|
||||
channel: params.channel,
|
||||
accountId: params.accountId,
|
||||
});
|
||||
const media = await loadWebMedia(mediaSource, maxBytes);
|
||||
params.args.buffer = media.buffer.toString("base64");
|
||||
if (!contentTypeParam && media.contentType) {
|
||||
params.args.contentType = media.contentType;
|
||||
}
|
||||
if (!filename) {
|
||||
params.args.filename = inferAttachmentFilename({
|
||||
mediaHint: media.fileName ?? mediaSource,
|
||||
contentType: media.contentType ?? contentTypeParam ?? undefined,
|
||||
});
|
||||
}
|
||||
} else if (!filename) {
|
||||
params.args.filename = inferAttachmentFilename({
|
||||
mediaHint: mediaSource,
|
||||
contentType: contentTypeParam ?? undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function hydrateSendAttachmentParams(params: {
|
||||
cfg: ClawdbotConfig;
|
||||
channel: ChannelId;
|
||||
@@ -666,6 +739,10 @@ export async function runMessageAction(
|
||||
const hasLegacyTarget =
|
||||
(typeof params.to === "string" && params.to.trim().length > 0) ||
|
||||
(typeof params.channelId === "string" && params.channelId.trim().length > 0);
|
||||
if (explicitTarget && hasLegacyTarget) {
|
||||
delete params.to;
|
||||
delete params.channelId;
|
||||
}
|
||||
if (
|
||||
!explicitTarget &&
|
||||
!hasLegacyTarget &&
|
||||
@@ -677,6 +754,16 @@ export async function runMessageAction(
|
||||
params.target = inferredTarget;
|
||||
}
|
||||
}
|
||||
if (!explicitTarget && actionRequiresTarget(action) && hasLegacyTarget) {
|
||||
const legacyTo = typeof params.to === "string" ? params.to.trim() : "";
|
||||
const legacyChannelId = typeof params.channelId === "string" ? params.channelId.trim() : "";
|
||||
const legacyTarget = legacyTo || legacyChannelId;
|
||||
if (legacyTarget) {
|
||||
params.target = legacyTarget;
|
||||
delete params.to;
|
||||
delete params.channelId;
|
||||
}
|
||||
}
|
||||
const explicitChannel = typeof params.channel === "string" ? params.channel.trim() : "";
|
||||
if (!explicitChannel) {
|
||||
const inferredChannel = normalizeMessageChannel(input.toolContext?.currentChannelProvider);
|
||||
@@ -705,6 +792,15 @@ export async function runMessageAction(
|
||||
dryRun,
|
||||
});
|
||||
|
||||
await hydrateSetGroupIconParams({
|
||||
cfg,
|
||||
channel,
|
||||
accountId,
|
||||
args: params,
|
||||
action,
|
||||
dryRun,
|
||||
});
|
||||
|
||||
await resolveActionTarget({
|
||||
cfg,
|
||||
channel,
|
||||
@@ -721,14 +817,6 @@ export async function runMessageAction(
|
||||
cfg,
|
||||
});
|
||||
|
||||
await hydrateSendAttachmentParams({
|
||||
cfg,
|
||||
channel,
|
||||
accountId,
|
||||
args: params,
|
||||
dryRun,
|
||||
});
|
||||
|
||||
const gateway = resolveGateway(input);
|
||||
|
||||
if (action === "send") {
|
||||
|
||||
@@ -15,6 +15,7 @@ export const MESSAGE_ACTION_TARGET_MODE: Record<ChannelMessageActionName, Messag
|
||||
reply: "to",
|
||||
sendWithEffect: "to",
|
||||
renameGroup: "to",
|
||||
setGroupIcon: "to",
|
||||
addParticipant: "to",
|
||||
removeParticipant: "to",
|
||||
leaveGroup: "to",
|
||||
@@ -57,6 +58,7 @@ const ACTION_TARGET_ALIASES: Partial<Record<ChannelMessageActionName, string[]>>
|
||||
unsend: ["messageId"],
|
||||
edit: ["messageId"],
|
||||
renameGroup: ["chatGuid", "chatIdentifier", "chatId"],
|
||||
setGroupIcon: ["chatGuid", "chatIdentifier", "chatId"],
|
||||
addParticipant: ["chatGuid", "chatIdentifier", "chatId"],
|
||||
removeParticipant: ["chatGuid", "chatIdentifier", "chatId"],
|
||||
leaveGroup: ["chatGuid", "chatIdentifier", "chatId"],
|
||||
|
||||
@@ -181,6 +181,19 @@ async function sipsResizeToJpeg(params: {
|
||||
});
|
||||
}
|
||||
|
||||
async function sipsConvertToJpeg(buffer: Buffer): Promise<Buffer> {
|
||||
return await withTempDir(async (dir) => {
|
||||
const input = path.join(dir, "in.heic");
|
||||
const output = path.join(dir, "out.jpg");
|
||||
await fs.writeFile(input, buffer);
|
||||
await runExec("/usr/bin/sips", ["-s", "format", "jpeg", input, "--out", output], {
|
||||
timeoutMs: 20_000,
|
||||
maxBuffer: 1024 * 1024,
|
||||
});
|
||||
return await fs.readFile(output);
|
||||
});
|
||||
}
|
||||
|
||||
export async function getImageMetadata(buffer: Buffer): Promise<ImageMetadata | null> {
|
||||
if (prefersSips()) {
|
||||
return await sipsMetadataFromBuffer(buffer).catch(() => null);
|
||||
@@ -318,6 +331,14 @@ export async function resizeToJpeg(params: {
|
||||
.toBuffer();
|
||||
}
|
||||
|
||||
export async function convertHeicToJpeg(buffer: Buffer): Promise<Buffer> {
|
||||
if (prefersSips()) {
|
||||
return await sipsConvertToJpeg(buffer);
|
||||
}
|
||||
const sharp = await loadSharp();
|
||||
return await sharp(buffer).jpeg({ quality: 90, mozjpeg: true }).toBuffer();
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal sips-only EXIF normalization (no sharp fallback).
|
||||
* Used by resizeToJpeg to normalize before sips resize.
|
||||
|
||||
@@ -5,6 +5,8 @@ import { type MediaKind, mediaKindFromMime } from "./constants.js";
|
||||
|
||||
// Map common mimes to preferred file extensions.
|
||||
const EXT_BY_MIME: Record<string, string> = {
|
||||
"image/heic": ".heic",
|
||||
"image/heif": ".heif",
|
||||
"image/jpeg": ".jpg",
|
||||
"image/png": ".png",
|
||||
"image/webp": ".webp",
|
||||
@@ -137,6 +139,10 @@ export function imageMimeFromFormat(format?: string | null): string | undefined
|
||||
case "jpg":
|
||||
case "jpeg":
|
||||
return "image/jpeg";
|
||||
case "heic":
|
||||
return "image/heic";
|
||||
case "heif":
|
||||
return "image/heif";
|
||||
case "png":
|
||||
return "image/png";
|
||||
case "webp":
|
||||
|
||||
@@ -5,7 +5,7 @@ import { fileURLToPath } from "node:url";
|
||||
import { logVerbose, shouldLogVerbose } from "../globals.js";
|
||||
import { type MediaKind, maxBytesForKind, mediaKindFromMime } from "../media/constants.js";
|
||||
import { fetchRemoteMedia } from "../media/fetch.js";
|
||||
import { resizeToJpeg } from "../media/image-ops.js";
|
||||
import { convertHeicToJpeg, resizeToJpeg } from "../media/image-ops.js";
|
||||
import { detectMime, extensionForMime } from "../media/mime.js";
|
||||
|
||||
type WebMediaResult = {
|
||||
@@ -20,6 +20,15 @@ type WebMediaOptions = {
|
||||
optimizeImages?: boolean;
|
||||
};
|
||||
|
||||
const HEIC_MIME_RE = /^image\/hei[cf]$/i;
|
||||
const HEIC_EXT_RE = /\.(heic|heif)$/i;
|
||||
|
||||
function isHeicSource(opts: { contentType?: string; fileName?: string }): boolean {
|
||||
if (opts.contentType && HEIC_MIME_RE.test(opts.contentType.trim())) return true;
|
||||
if (opts.fileName && HEIC_EXT_RE.test(opts.fileName.trim())) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
async function loadWebMediaInternal(
|
||||
mediaUrl: string,
|
||||
options: WebMediaOptions = {},
|
||||
@@ -34,9 +43,13 @@ async function loadWebMediaInternal(
|
||||
}
|
||||
}
|
||||
|
||||
const optimizeAndClampImage = async (buffer: Buffer, cap: number) => {
|
||||
const optimizeAndClampImage = async (
|
||||
buffer: Buffer,
|
||||
cap: number,
|
||||
meta?: { contentType?: string; fileName?: string },
|
||||
) => {
|
||||
const originalSize = buffer.length;
|
||||
const optimized = await optimizeImageToJpeg(buffer, cap);
|
||||
const optimized = await optimizeImageToJpeg(buffer, cap, meta);
|
||||
if (optimized.optimizedSize < originalSize && shouldLogVerbose()) {
|
||||
logVerbose(
|
||||
`Optimized media from ${(originalSize / (1024 * 1024)).toFixed(2)}MB to ${(optimized.optimizedSize / (1024 * 1024)).toFixed(2)}MB (side≤${optimized.resizeSide}px, q=${optimized.quality})`,
|
||||
@@ -86,7 +99,10 @@ async function loadWebMediaInternal(
|
||||
};
|
||||
}
|
||||
return {
|
||||
...(await optimizeAndClampImage(params.buffer, cap)),
|
||||
...(await optimizeAndClampImage(params.buffer, cap, {
|
||||
contentType: params.contentType,
|
||||
fileName: params.fileName,
|
||||
})),
|
||||
fileName: params.fileName,
|
||||
};
|
||||
}
|
||||
@@ -150,6 +166,7 @@ export async function loadWebMediaRaw(
|
||||
export async function optimizeImageToJpeg(
|
||||
buffer: Buffer,
|
||||
maxBytes: number,
|
||||
opts: { contentType?: string; fileName?: string } = {},
|
||||
): Promise<{
|
||||
buffer: Buffer;
|
||||
optimizedSize: number;
|
||||
@@ -157,6 +174,14 @@ export async function optimizeImageToJpeg(
|
||||
quality: number;
|
||||
}> {
|
||||
// Try a grid of sizes/qualities until under the limit.
|
||||
let source = buffer;
|
||||
if (isHeicSource(opts)) {
|
||||
try {
|
||||
source = await convertHeicToJpeg(buffer);
|
||||
} catch (err) {
|
||||
throw new Error(`HEIC image conversion failed: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
const sides = [2048, 1536, 1280, 1024, 800];
|
||||
const qualities = [80, 70, 60, 50, 40];
|
||||
let smallest: {
|
||||
@@ -170,7 +195,7 @@ export async function optimizeImageToJpeg(
|
||||
for (const quality of qualities) {
|
||||
try {
|
||||
const out = await resizeToJpeg({
|
||||
buffer,
|
||||
buffer: source,
|
||||
maxSide: side,
|
||||
quality,
|
||||
withoutEnlargement: true,
|
||||
|
||||
Reference in New Issue
Block a user