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
@@ -11,6 +11,7 @@ Status: bundled plugin that talks to the BlueBubbles macOS server over HTTP. **R
|
|||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
- Runs on macOS via the BlueBubbles helper app ([bluebubbles.app](https://bluebubbles.app)).
|
- Runs on macOS via the BlueBubbles helper app ([bluebubbles.app](https://bluebubbles.app)).
|
||||||
|
- Recommended/tested: macOS Sequoia (15). macOS Tahoe (26) works; edit is currently broken on Tahoe, but other actions (reactions, effects, unsend, etc.) still work.
|
||||||
- Clawdbot talks to it through its REST API (`GET /api/v1/ping`, `POST /message/text`, `POST /chat/:id/*`).
|
- Clawdbot talks to it through its REST API (`GET /api/v1/ping`, `POST /message/text`, `POST /chat/:id/*`).
|
||||||
- Incoming messages arrive via webhooks; outgoing replies, typing indicators, read receipts, and tapbacks are REST calls.
|
- Incoming messages arrive via webhooks; outgoing replies, typing indicators, read receipts, and tapbacks are REST calls.
|
||||||
- Attachments and stickers are ingested as inbound media (and surfaced to the agent when possible).
|
- Attachments and stickers are ingested as inbound media (and surfaced to the agent when possible).
|
||||||
@@ -119,11 +120,12 @@ BlueBubbles supports advanced message actions when enabled in config:
|
|||||||
bluebubbles: {
|
bluebubbles: {
|
||||||
actions: {
|
actions: {
|
||||||
reactions: true, // tapbacks (default: true)
|
reactions: true, // tapbacks (default: true)
|
||||||
edit: true, // edit sent messages (macOS 13+)
|
edit: true, // edit sent messages (macOS 13+, broken on macOS 26 Tahoe)
|
||||||
unsend: true, // unsend messages (macOS 13+)
|
unsend: true, // unsend messages (macOS 13+)
|
||||||
reply: true, // reply threading by message GUID
|
reply: true, // reply threading by message GUID
|
||||||
sendWithEffect: true, // message effects (slam, loud, etc.)
|
sendWithEffect: true, // message effects (slam, loud, etc.)
|
||||||
renameGroup: true, // rename group chats
|
renameGroup: true, // rename group chats
|
||||||
|
setGroupIcon: true, // set group chat icon/photo
|
||||||
addParticipant: true, // add participants to groups
|
addParticipant: true, // add participants to groups
|
||||||
removeParticipant: true, // remove participants from groups
|
removeParticipant: true, // remove participants from groups
|
||||||
leaveGroup: true, // leave group chats
|
leaveGroup: true, // leave group chats
|
||||||
@@ -141,6 +143,7 @@ Available actions:
|
|||||||
- **reply**: Reply to a specific message (`messageId`, `text`, `to`)
|
- **reply**: Reply to a specific message (`messageId`, `text`, `to`)
|
||||||
- **sendWithEffect**: Send with iMessage effect (`text`, `to`, `effectId`)
|
- **sendWithEffect**: Send with iMessage effect (`text`, `to`, `effectId`)
|
||||||
- **renameGroup**: Rename a group chat (`chatGuid`, `displayName`)
|
- **renameGroup**: Rename a group chat (`chatGuid`, `displayName`)
|
||||||
|
- **setGroupIcon**: Set a group chat's icon/photo (`chatGuid`, `media`)
|
||||||
- **addParticipant**: Add someone to a group (`chatGuid`, `address`)
|
- **addParticipant**: Add someone to a group (`chatGuid`, `address`)
|
||||||
- **removeParticipant**: Remove someone from a group (`chatGuid`, `address`)
|
- **removeParticipant**: Remove someone from a group (`chatGuid`, `address`)
|
||||||
- **leaveGroup**: Leave a group chat (`chatGuid`)
|
- **leaveGroup**: Leave a group chat (`chatGuid`)
|
||||||
@@ -205,7 +208,7 @@ Prefer `chat_guid` for stable routing:
|
|||||||
- If typing/read events stop working, check the BlueBubbles webhook logs and verify the gateway path matches `channels.bluebubbles.webhookPath`.
|
- If typing/read events stop working, check the BlueBubbles webhook logs and verify the gateway path matches `channels.bluebubbles.webhookPath`.
|
||||||
- Pairing codes expire after one hour; use `clawdbot pairing list bluebubbles` and `clawdbot pairing approve bluebubbles <code>`.
|
- Pairing codes expire after one hour; use `clawdbot pairing list bluebubbles` and `clawdbot pairing approve bluebubbles <code>`.
|
||||||
- Reactions require the BlueBubbles private API (`POST /api/v1/message/react`); ensure the server version exposes it.
|
- Reactions require the BlueBubbles private API (`POST /api/v1/message/react`); ensure the server version exposes it.
|
||||||
- Edit/unsend require macOS 13+ and a compatible BlueBubbles server version.
|
- Edit/unsend require macOS 13+ and a compatible BlueBubbles server version. On macOS 26 (Tahoe), edit is currently broken due to private API changes; other actions still work.
|
||||||
- For status/health info: `clawdbot status --all` or `clawdbot status --deep`.
|
- For status/health info: `clawdbot status --all` or `clawdbot status --deep`.
|
||||||
|
|
||||||
For general channel workflow reference, see [Channels](/channels) and the [Plugins](/plugins) guide.
|
For general channel workflow reference, see [Channels](/channels) and the [Plugins](/plugins) guide.
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ Text is supported everywhere; media and reactions vary by channel.
|
|||||||
- [Discord](/channels/discord) — Discord Bot API + Gateway; supports servers, channels, and DMs.
|
- [Discord](/channels/discord) — Discord Bot API + Gateway; supports servers, channels, and DMs.
|
||||||
- [Slack](/channels/slack) — Bolt SDK; workspace apps.
|
- [Slack](/channels/slack) — Bolt SDK; workspace apps.
|
||||||
- [Signal](/channels/signal) — signal-cli; privacy-focused.
|
- [Signal](/channels/signal) — signal-cli; privacy-focused.
|
||||||
- [BlueBubbles](/channels/bluebubbles) — **Recommended for iMessage**; uses the BlueBubbles macOS server REST API with full feature support (edit, unsend, effects, reactions, group management).
|
- [BlueBubbles](/channels/bluebubbles) — **Recommended for iMessage**; uses the BlueBubbles macOS server REST API with full feature support (edit, unsend, effects, reactions, group management — edit currently broken on macOS 26 Tahoe).
|
||||||
- [iMessage](/channels/imessage) — macOS only; native integration via imsg (legacy, consider BlueBubbles for new setups).
|
- [iMessage](/channels/imessage) — macOS only; native integration via imsg (legacy, consider BlueBubbles for new setups).
|
||||||
- [Microsoft Teams](/channels/msteams) — Bot Framework; enterprise support (plugin, installed separately).
|
- [Microsoft Teams](/channels/msteams) — Bot Framework; enterprise support (plugin, installed separately).
|
||||||
- [Nextcloud Talk](/channels/nextcloud-talk) — Self-hosted chat via Nextcloud Talk (plugin, installed separately).
|
- [Nextcloud Talk](/channels/nextcloud-talk) — Self-hosted chat via Nextcloud Talk (plugin, installed separately).
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ vi.mock("./chat.js", () => ({
|
|||||||
editBlueBubblesMessage: vi.fn().mockResolvedValue(undefined),
|
editBlueBubblesMessage: vi.fn().mockResolvedValue(undefined),
|
||||||
unsendBlueBubblesMessage: vi.fn().mockResolvedValue(undefined),
|
unsendBlueBubblesMessage: vi.fn().mockResolvedValue(undefined),
|
||||||
renameBlueBubblesChat: vi.fn().mockResolvedValue(undefined),
|
renameBlueBubblesChat: vi.fn().mockResolvedValue(undefined),
|
||||||
|
setGroupIconBlueBubbles: vi.fn().mockResolvedValue(undefined),
|
||||||
addBlueBubblesParticipant: vi.fn().mockResolvedValue(undefined),
|
addBlueBubblesParticipant: vi.fn().mockResolvedValue(undefined),
|
||||||
removeBlueBubblesParticipant: vi.fn().mockResolvedValue(undefined),
|
removeBlueBubblesParticipant: vi.fn().mockResolvedValue(undefined),
|
||||||
leaveBlueBubblesChat: vi.fn().mockResolvedValue(undefined),
|
leaveBlueBubblesChat: vi.fn().mockResolvedValue(undefined),
|
||||||
@@ -103,6 +104,7 @@ describe("bluebubblesMessageActions", () => {
|
|||||||
expect(bluebubblesMessageActions.supportsAction({ action: "reply" })).toBe(true);
|
expect(bluebubblesMessageActions.supportsAction({ action: "reply" })).toBe(true);
|
||||||
expect(bluebubblesMessageActions.supportsAction({ action: "sendWithEffect" })).toBe(true);
|
expect(bluebubblesMessageActions.supportsAction({ action: "sendWithEffect" })).toBe(true);
|
||||||
expect(bluebubblesMessageActions.supportsAction({ action: "renameGroup" })).toBe(true);
|
expect(bluebubblesMessageActions.supportsAction({ action: "renameGroup" })).toBe(true);
|
||||||
|
expect(bluebubblesMessageActions.supportsAction({ action: "setGroupIcon" })).toBe(true);
|
||||||
expect(bluebubblesMessageActions.supportsAction({ action: "addParticipant" })).toBe(true);
|
expect(bluebubblesMessageActions.supportsAction({ action: "addParticipant" })).toBe(true);
|
||||||
expect(bluebubblesMessageActions.supportsAction({ action: "removeParticipant" })).toBe(true);
|
expect(bluebubblesMessageActions.supportsAction({ action: "removeParticipant" })).toBe(true);
|
||||||
expect(bluebubblesMessageActions.supportsAction({ action: "leaveGroup" })).toBe(true);
|
expect(bluebubblesMessageActions.supportsAction({ action: "leaveGroup" })).toBe(true);
|
||||||
@@ -414,5 +416,96 @@ describe("bluebubblesMessageActions", () => {
|
|||||||
details: { ok: true, messageId: "msg-123", effect: "invisible ink" },
|
details: { ok: true, messageId: "msg-123", effect: "invisible ink" },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("throws when buffer is missing for setGroupIcon", async () => {
|
||||||
|
const cfg: ClawdbotConfig = {
|
||||||
|
channels: {
|
||||||
|
bluebubbles: {
|
||||||
|
serverUrl: "http://localhost:1234",
|
||||||
|
password: "test-password",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
bluebubblesMessageActions.handleAction({
|
||||||
|
action: "setGroupIcon",
|
||||||
|
params: { chatGuid: "iMessage;-;chat-guid" },
|
||||||
|
cfg,
|
||||||
|
accountId: null,
|
||||||
|
}),
|
||||||
|
).rejects.toThrow(/requires an image/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sets group icon successfully with chatGuid and buffer", async () => {
|
||||||
|
const { setGroupIconBlueBubbles } = await import("./chat.js");
|
||||||
|
|
||||||
|
const cfg: ClawdbotConfig = {
|
||||||
|
channels: {
|
||||||
|
bluebubbles: {
|
||||||
|
serverUrl: "http://localhost:1234",
|
||||||
|
password: "test-password",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Base64 encode a simple test buffer
|
||||||
|
const testBuffer = Buffer.from("fake-image-data");
|
||||||
|
const base64Buffer = testBuffer.toString("base64");
|
||||||
|
|
||||||
|
const result = await bluebubblesMessageActions.handleAction({
|
||||||
|
action: "setGroupIcon",
|
||||||
|
params: {
|
||||||
|
chatGuid: "iMessage;-;chat-guid",
|
||||||
|
buffer: base64Buffer,
|
||||||
|
filename: "group-icon.png",
|
||||||
|
contentType: "image/png",
|
||||||
|
},
|
||||||
|
cfg,
|
||||||
|
accountId: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(setGroupIconBlueBubbles).toHaveBeenCalledWith(
|
||||||
|
"iMessage;-;chat-guid",
|
||||||
|
expect.any(Uint8Array),
|
||||||
|
"group-icon.png",
|
||||||
|
expect.objectContaining({ contentType: "image/png" }),
|
||||||
|
);
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
details: { ok: true, chatGuid: "iMessage;-;chat-guid", iconSet: true },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses default filename when not provided for setGroupIcon", async () => {
|
||||||
|
const { setGroupIconBlueBubbles } = await import("./chat.js");
|
||||||
|
|
||||||
|
const cfg: ClawdbotConfig = {
|
||||||
|
channels: {
|
||||||
|
bluebubbles: {
|
||||||
|
serverUrl: "http://localhost:1234",
|
||||||
|
password: "test-password",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const base64Buffer = Buffer.from("test").toString("base64");
|
||||||
|
|
||||||
|
await bluebubblesMessageActions.handleAction({
|
||||||
|
action: "setGroupIcon",
|
||||||
|
params: {
|
||||||
|
chatGuid: "iMessage;-;chat-guid",
|
||||||
|
buffer: base64Buffer,
|
||||||
|
},
|
||||||
|
cfg,
|
||||||
|
accountId: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(setGroupIconBlueBubbles).toHaveBeenCalledWith(
|
||||||
|
"iMessage;-;chat-guid",
|
||||||
|
expect.any(Uint8Array),
|
||||||
|
"icon.png",
|
||||||
|
expect.anything(),
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
editBlueBubblesMessage,
|
editBlueBubblesMessage,
|
||||||
unsendBlueBubblesMessage,
|
unsendBlueBubblesMessage,
|
||||||
renameBlueBubblesChat,
|
renameBlueBubblesChat,
|
||||||
|
setGroupIconBlueBubbles,
|
||||||
addBlueBubblesParticipant,
|
addBlueBubblesParticipant,
|
||||||
removeBlueBubblesParticipant,
|
removeBlueBubblesParticipant,
|
||||||
leaveBlueBubblesChat,
|
leaveBlueBubblesChat,
|
||||||
@@ -54,6 +55,7 @@ const SUPPORTED_ACTIONS = new Set<ChannelMessageActionName>([
|
|||||||
"reply",
|
"reply",
|
||||||
"sendWithEffect",
|
"sendWithEffect",
|
||||||
"renameGroup",
|
"renameGroup",
|
||||||
|
"setGroupIcon",
|
||||||
"addParticipant",
|
"addParticipant",
|
||||||
"removeParticipant",
|
"removeParticipant",
|
||||||
"leaveGroup",
|
"leaveGroup",
|
||||||
@@ -72,6 +74,7 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
|
|||||||
if (gate("reply")) actions.add("reply");
|
if (gate("reply")) actions.add("reply");
|
||||||
if (gate("sendWithEffect")) actions.add("sendWithEffect");
|
if (gate("sendWithEffect")) actions.add("sendWithEffect");
|
||||||
if (gate("renameGroup")) actions.add("renameGroup");
|
if (gate("renameGroup")) actions.add("renameGroup");
|
||||||
|
if (gate("setGroupIcon")) actions.add("setGroupIcon");
|
||||||
if (gate("addParticipant")) actions.add("addParticipant");
|
if (gate("addParticipant")) actions.add("addParticipant");
|
||||||
if (gate("removeParticipant")) actions.add("removeParticipant");
|
if (gate("removeParticipant")) actions.add("removeParticipant");
|
||||||
if (gate("leaveGroup")) actions.add("leaveGroup");
|
if (gate("leaveGroup")) actions.add("leaveGroup");
|
||||||
@@ -275,6 +278,35 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
|
|||||||
return jsonResult({ ok: true, renamed: resolvedChatGuid, displayName });
|
return jsonResult({ ok: true, renamed: resolvedChatGuid, displayName });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle setGroupIcon action
|
||||||
|
if (action === "setGroupIcon") {
|
||||||
|
const resolvedChatGuid = await resolveChatGuid();
|
||||||
|
const base64Buffer = readStringParam(params, "buffer");
|
||||||
|
const filename =
|
||||||
|
readStringParam(params, "filename") ??
|
||||||
|
readStringParam(params, "name") ??
|
||||||
|
"icon.png";
|
||||||
|
const contentType =
|
||||||
|
readStringParam(params, "contentType") ?? readStringParam(params, "mimeType");
|
||||||
|
|
||||||
|
if (!base64Buffer) {
|
||||||
|
throw new Error(
|
||||||
|
"BlueBubbles setGroupIcon requires an image. " +
|
||||||
|
"Use action=setGroupIcon with media=<image_url> or path=<local_file_path> to set the group icon.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decode base64 to buffer
|
||||||
|
const buffer = Uint8Array.from(atob(base64Buffer), (c) => c.charCodeAt(0));
|
||||||
|
|
||||||
|
await setGroupIconBlueBubbles(resolvedChatGuid, buffer, filename, {
|
||||||
|
...opts,
|
||||||
|
contentType: contentType ?? undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
return jsonResult({ ok: true, chatGuid: resolvedChatGuid, iconSet: true });
|
||||||
|
}
|
||||||
|
|
||||||
// Handle addParticipant action
|
// Handle addParticipant action
|
||||||
if (action === "addParticipant") {
|
if (action === "addParticipant") {
|
||||||
const resolvedChatGuid = await resolveChatGuid();
|
const resolvedChatGuid = await resolveChatGuid();
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
||||||
|
|
||||||
import { markBlueBubblesChatRead, sendBlueBubblesTyping } from "./chat.js";
|
import { markBlueBubblesChatRead, sendBlueBubblesTyping, setGroupIconBlueBubbles } from "./chat.js";
|
||||||
|
|
||||||
vi.mock("./accounts.js", () => ({
|
vi.mock("./accounts.js", () => ({
|
||||||
resolveBlueBubblesAccount: vi.fn(({ cfg, accountId }) => {
|
resolveBlueBubblesAccount: vi.fn(({ cfg, accountId }) => {
|
||||||
@@ -315,4 +315,148 @@ describe("chat", () => {
|
|||||||
expect(mockFetch.mock.calls[1][1].method).toBe("DELETE");
|
expect(mockFetch.mock.calls[1][1].method).toBe("DELETE");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("setGroupIconBlueBubbles", () => {
|
||||||
|
it("throws when chatGuid is empty", async () => {
|
||||||
|
await expect(
|
||||||
|
setGroupIconBlueBubbles("", new Uint8Array([1, 2, 3]), "icon.png", {
|
||||||
|
serverUrl: "http://localhost:1234",
|
||||||
|
password: "test",
|
||||||
|
}),
|
||||||
|
).rejects.toThrow("chatGuid");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws when buffer is empty", async () => {
|
||||||
|
await expect(
|
||||||
|
setGroupIconBlueBubbles("chat-guid", new Uint8Array(0), "icon.png", {
|
||||||
|
serverUrl: "http://localhost:1234",
|
||||||
|
password: "test",
|
||||||
|
}),
|
||||||
|
).rejects.toThrow("image buffer");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws when serverUrl is missing", async () => {
|
||||||
|
await expect(
|
||||||
|
setGroupIconBlueBubbles("chat-guid", new Uint8Array([1, 2, 3]), "icon.png", {}),
|
||||||
|
).rejects.toThrow("serverUrl is required");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws when password is missing", async () => {
|
||||||
|
await expect(
|
||||||
|
setGroupIconBlueBubbles("chat-guid", new Uint8Array([1, 2, 3]), "icon.png", {
|
||||||
|
serverUrl: "http://localhost:1234",
|
||||||
|
}),
|
||||||
|
).rejects.toThrow("password is required");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sets group icon successfully", async () => {
|
||||||
|
mockFetch.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
text: () => Promise.resolve(""),
|
||||||
|
});
|
||||||
|
|
||||||
|
const buffer = new Uint8Array([0x89, 0x50, 0x4e, 0x47]); // PNG magic bytes
|
||||||
|
await setGroupIconBlueBubbles("iMessage;-;chat-guid", buffer, "icon.png", {
|
||||||
|
serverUrl: "http://localhost:1234",
|
||||||
|
password: "test-password",
|
||||||
|
contentType: "image/png",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockFetch).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining("/api/v1/chat/iMessage%3B-%3Bchat-guid/icon"),
|
||||||
|
expect.objectContaining({
|
||||||
|
method: "POST",
|
||||||
|
headers: expect.objectContaining({
|
||||||
|
"Content-Type": expect.stringContaining("multipart/form-data"),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes password in URL query", async () => {
|
||||||
|
mockFetch.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
text: () => Promise.resolve(""),
|
||||||
|
});
|
||||||
|
|
||||||
|
await setGroupIconBlueBubbles("chat-123", new Uint8Array([1, 2, 3]), "icon.png", {
|
||||||
|
serverUrl: "http://localhost:1234",
|
||||||
|
password: "my-secret",
|
||||||
|
});
|
||||||
|
|
||||||
|
const calledUrl = mockFetch.mock.calls[0][0] as string;
|
||||||
|
expect(calledUrl).toContain("password=my-secret");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws on non-ok response", async () => {
|
||||||
|
mockFetch.mockResolvedValueOnce({
|
||||||
|
ok: false,
|
||||||
|
status: 500,
|
||||||
|
text: () => Promise.resolve("Internal error"),
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
setGroupIconBlueBubbles("chat-123", new Uint8Array([1, 2, 3]), "icon.png", {
|
||||||
|
serverUrl: "http://localhost:1234",
|
||||||
|
password: "test",
|
||||||
|
}),
|
||||||
|
).rejects.toThrow("setGroupIcon failed (500): Internal error");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("trims chatGuid before using", async () => {
|
||||||
|
mockFetch.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
text: () => Promise.resolve(""),
|
||||||
|
});
|
||||||
|
|
||||||
|
await setGroupIconBlueBubbles(" chat-with-spaces ", new Uint8Array([1]), "icon.png", {
|
||||||
|
serverUrl: "http://localhost:1234",
|
||||||
|
password: "test",
|
||||||
|
});
|
||||||
|
|
||||||
|
const calledUrl = mockFetch.mock.calls[0][0] as string;
|
||||||
|
expect(calledUrl).toContain("/api/v1/chat/chat-with-spaces/icon");
|
||||||
|
expect(calledUrl).not.toContain("%20chat");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves credentials from config", async () => {
|
||||||
|
mockFetch.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
text: () => Promise.resolve(""),
|
||||||
|
});
|
||||||
|
|
||||||
|
await setGroupIconBlueBubbles("chat-123", new Uint8Array([1]), "icon.png", {
|
||||||
|
cfg: {
|
||||||
|
channels: {
|
||||||
|
bluebubbles: {
|
||||||
|
serverUrl: "http://config-server:9999",
|
||||||
|
password: "config-pass",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const calledUrl = mockFetch.mock.calls[0][0] as string;
|
||||||
|
expect(calledUrl).toContain("config-server:9999");
|
||||||
|
expect(calledUrl).toContain("password=config-pass");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes filename in multipart body", async () => {
|
||||||
|
mockFetch.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
text: () => Promise.resolve(""),
|
||||||
|
});
|
||||||
|
|
||||||
|
await setGroupIconBlueBubbles("chat-123", new Uint8Array([1, 2, 3]), "custom-icon.jpg", {
|
||||||
|
serverUrl: "http://localhost:1234",
|
||||||
|
password: "test",
|
||||||
|
contentType: "image/jpeg",
|
||||||
|
});
|
||||||
|
|
||||||
|
const body = mockFetch.mock.calls[0][1].body as Uint8Array;
|
||||||
|
const bodyString = new TextDecoder().decode(body);
|
||||||
|
expect(bodyString).toContain('filename="custom-icon.jpg"');
|
||||||
|
expect(bodyString).toContain("image/jpeg");
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import crypto from "node:crypto";
|
||||||
import { resolveBlueBubblesAccount } from "./accounts.js";
|
import { resolveBlueBubblesAccount } from "./accounts.js";
|
||||||
import type { ClawdbotConfig } from "clawdbot/plugin-sdk";
|
import type { ClawdbotConfig } from "clawdbot/plugin-sdk";
|
||||||
import { blueBubblesFetchWithTimeout, buildBlueBubblesApiUrl } from "./types.js";
|
import { blueBubblesFetchWithTimeout, buildBlueBubblesApiUrl } from "./types.js";
|
||||||
@@ -280,3 +281,74 @@ export async function leaveBlueBubblesChat(
|
|||||||
throw new Error(`BlueBubbles leaveChat failed (${res.status}): ${errorText || "unknown"}`);
|
throw new Error(`BlueBubbles leaveChat failed (${res.status}): ${errorText || "unknown"}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set a group chat's icon/photo via BlueBubbles API.
|
||||||
|
* Requires Private API to be enabled.
|
||||||
|
*/
|
||||||
|
export async function setGroupIconBlueBubbles(
|
||||||
|
chatGuid: string,
|
||||||
|
buffer: Uint8Array,
|
||||||
|
filename: string,
|
||||||
|
opts: BlueBubblesChatOpts & { contentType?: string } = {},
|
||||||
|
): Promise<void> {
|
||||||
|
const trimmedGuid = chatGuid.trim();
|
||||||
|
if (!trimmedGuid) throw new Error("BlueBubbles setGroupIcon requires chatGuid");
|
||||||
|
if (!buffer || buffer.length === 0) {
|
||||||
|
throw new Error("BlueBubbles setGroupIcon requires image buffer");
|
||||||
|
}
|
||||||
|
|
||||||
|
const { baseUrl, password } = resolveAccount(opts);
|
||||||
|
const url = buildBlueBubblesApiUrl({
|
||||||
|
baseUrl,
|
||||||
|
path: `/api/v1/chat/${encodeURIComponent(trimmedGuid)}/icon`,
|
||||||
|
password,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Build multipart form-data
|
||||||
|
const boundary = `----BlueBubblesFormBoundary${crypto.randomUUID().replace(/-/g, "")}`;
|
||||||
|
const parts: Uint8Array[] = [];
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
|
||||||
|
// Add file field named "icon" as per API spec
|
||||||
|
parts.push(encoder.encode(`--${boundary}\r\n`));
|
||||||
|
parts.push(
|
||||||
|
encoder.encode(
|
||||||
|
`Content-Disposition: form-data; name="icon"; filename="${filename}"\r\n`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
parts.push(
|
||||||
|
encoder.encode(`Content-Type: ${opts.contentType ?? "application/octet-stream"}\r\n\r\n`),
|
||||||
|
);
|
||||||
|
parts.push(buffer);
|
||||||
|
parts.push(encoder.encode("\r\n"));
|
||||||
|
|
||||||
|
// Close multipart body
|
||||||
|
parts.push(encoder.encode(`--${boundary}--\r\n`));
|
||||||
|
|
||||||
|
// Combine into single buffer
|
||||||
|
const totalLength = parts.reduce((acc, part) => acc + part.length, 0);
|
||||||
|
const body = new Uint8Array(totalLength);
|
||||||
|
let offset = 0;
|
||||||
|
for (const part of parts) {
|
||||||
|
body.set(part, offset);
|
||||||
|
offset += part.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await blueBubblesFetchWithTimeout(
|
||||||
|
url,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": `multipart/form-data; boundary=${boundary}`,
|
||||||
|
},
|
||||||
|
body,
|
||||||
|
},
|
||||||
|
opts.timeoutMs ?? 60_000, // longer timeout for file uploads
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const errorText = await res.text().catch(() => "");
|
||||||
|
throw new Error(`BlueBubbles setGroupIcon failed (${res.status}): ${errorText || "unknown"}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,16 +4,17 @@ const allowFromEntry = z.union([z.string(), z.number()]);
|
|||||||
|
|
||||||
const bluebubblesActionSchema = z
|
const bluebubblesActionSchema = z
|
||||||
.object({
|
.object({
|
||||||
reactions: z.boolean().optional(),
|
reactions: z.boolean().default(true),
|
||||||
edit: z.boolean().optional(),
|
edit: z.boolean().default(true),
|
||||||
unsend: z.boolean().optional(),
|
unsend: z.boolean().default(true),
|
||||||
reply: z.boolean().optional(),
|
reply: z.boolean().default(true),
|
||||||
sendWithEffect: z.boolean().optional(),
|
sendWithEffect: z.boolean().default(true),
|
||||||
renameGroup: z.boolean().optional(),
|
renameGroup: z.boolean().default(true),
|
||||||
addParticipant: z.boolean().optional(),
|
setGroupIcon: z.boolean().default(true),
|
||||||
removeParticipant: z.boolean().optional(),
|
addParticipant: z.boolean().default(true),
|
||||||
leaveGroup: z.boolean().optional(),
|
removeParticipant: z.boolean().default(true),
|
||||||
sendAttachment: z.boolean().optional(),
|
leaveGroup: z.boolean().default(true),
|
||||||
|
sendAttachment: z.boolean().default(true),
|
||||||
})
|
})
|
||||||
.optional();
|
.optional();
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
import { looksLikeBlueBubblesTargetId, normalizeBlueBubblesMessagingTarget } from "./targets.js";
|
import {
|
||||||
|
looksLikeBlueBubblesTargetId,
|
||||||
|
normalizeBlueBubblesMessagingTarget,
|
||||||
|
parseBlueBubblesTarget,
|
||||||
|
parseBlueBubblesAllowTarget,
|
||||||
|
} from "./targets.js";
|
||||||
|
|
||||||
describe("normalizeBlueBubblesMessagingTarget", () => {
|
describe("normalizeBlueBubblesMessagingTarget", () => {
|
||||||
it("normalizes chat_guid targets", () => {
|
it("normalizes chat_guid targets", () => {
|
||||||
@@ -37,6 +42,21 @@ describe("normalizeBlueBubblesMessagingTarget", () => {
|
|||||||
"chat_guid:iMessage;+;chat123456789",
|
"chat_guid:iMessage;+;chat123456789",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("normalizes raw chat_guid values", () => {
|
||||||
|
expect(normalizeBlueBubblesMessagingTarget("iMessage;+;chat660250192681427962")).toBe(
|
||||||
|
"chat_guid:iMessage;+;chat660250192681427962",
|
||||||
|
);
|
||||||
|
expect(normalizeBlueBubblesMessagingTarget("iMessage;-;+19257864429")).toBe("+19257864429");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("normalizes chat<digits> pattern to chat_id format", () => {
|
||||||
|
expect(normalizeBlueBubblesMessagingTarget("chat660250192681427962")).toBe(
|
||||||
|
"chat_id:660250192681427962",
|
||||||
|
);
|
||||||
|
expect(normalizeBlueBubblesMessagingTarget("chat123")).toBe("chat_id:123");
|
||||||
|
expect(normalizeBlueBubblesMessagingTarget("Chat456789")).toBe("chat_id:456789");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("looksLikeBlueBubblesTargetId", () => {
|
describe("looksLikeBlueBubblesTargetId", () => {
|
||||||
@@ -52,7 +72,68 @@ describe("looksLikeBlueBubblesTargetId", () => {
|
|||||||
expect(looksLikeBlueBubblesTargetId("+1 (555) 123-4567")).toBe(true);
|
expect(looksLikeBlueBubblesTargetId("+1 (555) 123-4567")).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("accepts raw chat_guid values", () => {
|
||||||
|
expect(looksLikeBlueBubblesTargetId("iMessage;+;chat660250192681427962")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts chat<digits> pattern as chat_id", () => {
|
||||||
|
expect(looksLikeBlueBubblesTargetId("chat660250192681427962")).toBe(true);
|
||||||
|
expect(looksLikeBlueBubblesTargetId("chat123")).toBe(true);
|
||||||
|
expect(looksLikeBlueBubblesTargetId("Chat456789")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
it("rejects display names", () => {
|
it("rejects display names", () => {
|
||||||
expect(looksLikeBlueBubblesTargetId("Jane Doe")).toBe(false);
|
expect(looksLikeBlueBubblesTargetId("Jane Doe")).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("parseBlueBubblesTarget", () => {
|
||||||
|
it("parses chat<digits> pattern as chat_id", () => {
|
||||||
|
expect(parseBlueBubblesTarget("chat660250192681427962")).toEqual({
|
||||||
|
kind: "chat_id",
|
||||||
|
chatId: 660250192681427962,
|
||||||
|
});
|
||||||
|
expect(parseBlueBubblesTarget("chat123")).toEqual({ kind: "chat_id", chatId: 123 });
|
||||||
|
expect(parseBlueBubblesTarget("Chat456789")).toEqual({ kind: "chat_id", chatId: 456789 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses explicit chat_id: prefix", () => {
|
||||||
|
expect(parseBlueBubblesTarget("chat_id:123")).toEqual({ kind: "chat_id", chatId: 123 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses phone numbers as handles", () => {
|
||||||
|
expect(parseBlueBubblesTarget("+19257864429")).toEqual({
|
||||||
|
kind: "handle",
|
||||||
|
to: "+19257864429",
|
||||||
|
service: "auto",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses raw chat_guid format", () => {
|
||||||
|
expect(parseBlueBubblesTarget("iMessage;+;chat660250192681427962")).toEqual({
|
||||||
|
kind: "chat_guid",
|
||||||
|
chatGuid: "iMessage;+;chat660250192681427962",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("parseBlueBubblesAllowTarget", () => {
|
||||||
|
it("parses chat<digits> pattern as chat_id", () => {
|
||||||
|
expect(parseBlueBubblesAllowTarget("chat660250192681427962")).toEqual({
|
||||||
|
kind: "chat_id",
|
||||||
|
chatId: 660250192681427962,
|
||||||
|
});
|
||||||
|
expect(parseBlueBubblesAllowTarget("chat123")).toEqual({ kind: "chat_id", chatId: 123 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses explicit chat_id: prefix", () => {
|
||||||
|
expect(parseBlueBubblesAllowTarget("chat_id:456")).toEqual({ kind: "chat_id", chatId: 456 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses phone numbers as handles", () => {
|
||||||
|
expect(parseBlueBubblesAllowTarget("+19257864429")).toEqual({
|
||||||
|
kind: "handle",
|
||||||
|
handle: "+19257864429",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -21,6 +21,19 @@ const SERVICE_PREFIXES: Array<{ prefix: string; service: BlueBubblesService }> =
|
|||||||
{ prefix: "auto:", service: "auto" },
|
{ prefix: "auto:", service: "auto" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
function parseRawChatGuid(value: string): string | null {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (!trimmed) return null;
|
||||||
|
const parts = trimmed.split(";");
|
||||||
|
if (parts.length !== 3) return null;
|
||||||
|
const service = parts[0]?.trim();
|
||||||
|
const separator = parts[1]?.trim();
|
||||||
|
const identifier = parts[2]?.trim();
|
||||||
|
if (!service || !identifier) return null;
|
||||||
|
if (separator !== "+" && separator !== "-") return null;
|
||||||
|
return `${service};${separator};${identifier}`;
|
||||||
|
}
|
||||||
|
|
||||||
function stripPrefix(value: string, prefix: string): string {
|
function stripPrefix(value: string, prefix: string): string {
|
||||||
return value.slice(prefix.length).trim();
|
return value.slice(prefix.length).trim();
|
||||||
}
|
}
|
||||||
@@ -88,6 +101,7 @@ export function looksLikeBlueBubblesTargetId(raw: string, normalized?: string):
|
|||||||
if (!trimmed) return false;
|
if (!trimmed) return false;
|
||||||
const candidate = stripBlueBubblesPrefix(trimmed);
|
const candidate = stripBlueBubblesPrefix(trimmed);
|
||||||
if (!candidate) return false;
|
if (!candidate) return false;
|
||||||
|
if (parseRawChatGuid(candidate)) return true;
|
||||||
const lowered = candidate.toLowerCase();
|
const lowered = candidate.toLowerCase();
|
||||||
if (/^(imessage|sms|auto):/.test(lowered)) return true;
|
if (/^(imessage|sms|auto):/.test(lowered)) return true;
|
||||||
if (
|
if (
|
||||||
@@ -97,6 +111,8 @@ export function looksLikeBlueBubblesTargetId(raw: string, normalized?: string):
|
|||||||
) {
|
) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
// Recognize chat<digits> patterns (e.g., "chat660250192681427962") as chat IDs
|
||||||
|
if (/^chat\d+$/i.test(candidate)) return true;
|
||||||
if (candidate.includes("@")) return true;
|
if (candidate.includes("@")) return true;
|
||||||
const digitsOnly = candidate.replace(/[\s().-]/g, "");
|
const digitsOnly = candidate.replace(/[\s().-]/g, "");
|
||||||
if (/^\+?\d{3,}$/.test(digitsOnly)) return true;
|
if (/^\+?\d{3,}$/.test(digitsOnly)) return true;
|
||||||
@@ -115,7 +131,7 @@ export function looksLikeBlueBubblesTargetId(raw: string, normalized?: string):
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function parseBlueBubblesTarget(raw: string): BlueBubblesTarget {
|
export function parseBlueBubblesTarget(raw: string): BlueBubblesTarget {
|
||||||
const trimmed = raw.trim();
|
const trimmed = stripBlueBubblesPrefix(raw);
|
||||||
if (!trimmed) throw new Error("BlueBubbles target is required");
|
if (!trimmed) throw new Error("BlueBubbles target is required");
|
||||||
const lower = trimmed.toLowerCase();
|
const lower = trimmed.toLowerCase();
|
||||||
|
|
||||||
@@ -173,6 +189,17 @@ export function parseBlueBubblesTarget(raw: string): BlueBubblesTarget {
|
|||||||
return { kind: "chat_guid", chatGuid: value };
|
return { kind: "chat_guid", chatGuid: value };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const rawChatGuid = parseRawChatGuid(trimmed);
|
||||||
|
if (rawChatGuid) {
|
||||||
|
return { kind: "chat_guid", chatGuid: rawChatGuid };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle chat<digits> pattern (e.g., "chat660250192681427962") as chat_identifier
|
||||||
|
// These are BlueBubbles chat identifiers (the third part of a chat GUID), not numeric IDs
|
||||||
|
if (/^chat\d+$/i.test(trimmed)) {
|
||||||
|
return { kind: "chat_identifier", chatIdentifier: trimmed };
|
||||||
|
}
|
||||||
|
|
||||||
return { kind: "handle", to: trimmed, service: "auto" };
|
return { kind: "handle", to: trimmed, service: "auto" };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -218,6 +245,13 @@ export function parseBlueBubblesAllowTarget(raw: string): BlueBubblesAllowTarget
|
|||||||
if (value) return { kind: "chat_guid", chatGuid: value };
|
if (value) return { kind: "chat_guid", chatGuid: value };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle chat<digits> pattern (e.g., "chat660250192681427962") as chat_id
|
||||||
|
const chatDigitsMatch = /^chat(\d+)$/i.exec(trimmed);
|
||||||
|
if (chatDigitsMatch) {
|
||||||
|
const chatId = Number.parseInt(chatDigitsMatch[1], 10);
|
||||||
|
if (Number.isFinite(chatId)) return { kind: "chat_id", chatId };
|
||||||
|
}
|
||||||
|
|
||||||
return { kind: "handle", handle: normalizeBlueBubblesHandle(trimmed) };
|
return { kind: "handle", handle: normalizeBlueBubblesHandle(trimmed) };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import { jsonResult, readNumberParam, readStringParam } from "./common.js";
|
|||||||
const AllMessageActions = CHANNEL_MESSAGE_ACTION_NAMES;
|
const AllMessageActions = CHANNEL_MESSAGE_ACTION_NAMES;
|
||||||
const BLUEBUBBLES_GROUP_ACTIONS = new Set<ChannelMessageActionName>([
|
const BLUEBUBBLES_GROUP_ACTIONS = new Set<ChannelMessageActionName>([
|
||||||
"renameGroup",
|
"renameGroup",
|
||||||
|
"setGroupIcon",
|
||||||
"addParticipant",
|
"addParticipant",
|
||||||
"removeParticipant",
|
"removeParticipant",
|
||||||
"leaveGroup",
|
"leaveGroup",
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export const CHANNEL_MESSAGE_ACTION_NAMES = [
|
|||||||
"reply",
|
"reply",
|
||||||
"sendWithEffect",
|
"sendWithEffect",
|
||||||
"renameGroup",
|
"renameGroup",
|
||||||
|
"setGroupIcon",
|
||||||
"addParticipant",
|
"addParticipant",
|
||||||
"removeParticipant",
|
"removeParticipant",
|
||||||
"leaveGroup",
|
"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 { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
import type { ClawdbotConfig } from "../../config/config.js";
|
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 { jsonResult } from "../../agents/tools/common.js";
|
||||||
import type { ChannelPlugin } from "../../channels/plugins/types.js";
|
import type { ChannelPlugin } from "../../channels/plugins/types.js";
|
||||||
|
|
||||||
vi.mock("../../web/media.js", () => ({
|
vi.mock("../../web/media.js", async () => {
|
||||||
loadWebMedia: vi.fn(),
|
const actual = await vi.importActual<typeof import("../../web/media.js")>("../../web/media.js");
|
||||||
}));
|
return {
|
||||||
|
...actual,
|
||||||
|
loadWebMedia: vi.fn(actual.loadWebMedia),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
const slackConfig = {
|
const slackConfig = {
|
||||||
channels: {
|
channels: {
|
||||||
@@ -76,66 +76,6 @@ describe("runMessageAction context isolation", () => {
|
|||||||
setActivePluginRegistry(createTestRegistry([]));
|
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 () => {
|
it("allows send when target matches current channel", async () => {
|
||||||
const result = await runMessageAction({
|
const result = await runMessageAction({
|
||||||
cfg: slackConfig,
|
cfg: slackConfig,
|
||||||
@@ -152,6 +92,21 @@ describe("runMessageAction context isolation", () => {
|
|||||||
expect(result.kind).toBe("send");
|
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 () => {
|
it("defaults to current channel when target is omitted", async () => {
|
||||||
const result = await runMessageAction({
|
const result = await runMessageAction({
|
||||||
cfg: slackConfig,
|
cfg: slackConfig,
|
||||||
|
|||||||
@@ -215,9 +215,25 @@ function resolveAttachmentMaxBytes(params: {
|
|||||||
}
|
}
|
||||||
const accountId = typeof params.accountId === "string" ? params.accountId.trim() : "";
|
const accountId = typeof params.accountId === "string" ? params.accountId.trim() : "";
|
||||||
const channelCfg = params.cfg.channels?.bluebubbles;
|
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 =
|
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;
|
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: {
|
async function hydrateSendAttachmentParams(params: {
|
||||||
cfg: ClawdbotConfig;
|
cfg: ClawdbotConfig;
|
||||||
channel: ChannelId;
|
channel: ChannelId;
|
||||||
@@ -666,6 +739,10 @@ export async function runMessageAction(
|
|||||||
const hasLegacyTarget =
|
const hasLegacyTarget =
|
||||||
(typeof params.to === "string" && params.to.trim().length > 0) ||
|
(typeof params.to === "string" && params.to.trim().length > 0) ||
|
||||||
(typeof params.channelId === "string" && params.channelId.trim().length > 0);
|
(typeof params.channelId === "string" && params.channelId.trim().length > 0);
|
||||||
|
if (explicitTarget && hasLegacyTarget) {
|
||||||
|
delete params.to;
|
||||||
|
delete params.channelId;
|
||||||
|
}
|
||||||
if (
|
if (
|
||||||
!explicitTarget &&
|
!explicitTarget &&
|
||||||
!hasLegacyTarget &&
|
!hasLegacyTarget &&
|
||||||
@@ -677,6 +754,16 @@ export async function runMessageAction(
|
|||||||
params.target = inferredTarget;
|
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() : "";
|
const explicitChannel = typeof params.channel === "string" ? params.channel.trim() : "";
|
||||||
if (!explicitChannel) {
|
if (!explicitChannel) {
|
||||||
const inferredChannel = normalizeMessageChannel(input.toolContext?.currentChannelProvider);
|
const inferredChannel = normalizeMessageChannel(input.toolContext?.currentChannelProvider);
|
||||||
@@ -705,6 +792,15 @@ export async function runMessageAction(
|
|||||||
dryRun,
|
dryRun,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await hydrateSetGroupIconParams({
|
||||||
|
cfg,
|
||||||
|
channel,
|
||||||
|
accountId,
|
||||||
|
args: params,
|
||||||
|
action,
|
||||||
|
dryRun,
|
||||||
|
});
|
||||||
|
|
||||||
await resolveActionTarget({
|
await resolveActionTarget({
|
||||||
cfg,
|
cfg,
|
||||||
channel,
|
channel,
|
||||||
@@ -721,14 +817,6 @@ export async function runMessageAction(
|
|||||||
cfg,
|
cfg,
|
||||||
});
|
});
|
||||||
|
|
||||||
await hydrateSendAttachmentParams({
|
|
||||||
cfg,
|
|
||||||
channel,
|
|
||||||
accountId,
|
|
||||||
args: params,
|
|
||||||
dryRun,
|
|
||||||
});
|
|
||||||
|
|
||||||
const gateway = resolveGateway(input);
|
const gateway = resolveGateway(input);
|
||||||
|
|
||||||
if (action === "send") {
|
if (action === "send") {
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export const MESSAGE_ACTION_TARGET_MODE: Record<ChannelMessageActionName, Messag
|
|||||||
reply: "to",
|
reply: "to",
|
||||||
sendWithEffect: "to",
|
sendWithEffect: "to",
|
||||||
renameGroup: "to",
|
renameGroup: "to",
|
||||||
|
setGroupIcon: "to",
|
||||||
addParticipant: "to",
|
addParticipant: "to",
|
||||||
removeParticipant: "to",
|
removeParticipant: "to",
|
||||||
leaveGroup: "to",
|
leaveGroup: "to",
|
||||||
@@ -57,6 +58,7 @@ const ACTION_TARGET_ALIASES: Partial<Record<ChannelMessageActionName, string[]>>
|
|||||||
unsend: ["messageId"],
|
unsend: ["messageId"],
|
||||||
edit: ["messageId"],
|
edit: ["messageId"],
|
||||||
renameGroup: ["chatGuid", "chatIdentifier", "chatId"],
|
renameGroup: ["chatGuid", "chatIdentifier", "chatId"],
|
||||||
|
setGroupIcon: ["chatGuid", "chatIdentifier", "chatId"],
|
||||||
addParticipant: ["chatGuid", "chatIdentifier", "chatId"],
|
addParticipant: ["chatGuid", "chatIdentifier", "chatId"],
|
||||||
removeParticipant: ["chatGuid", "chatIdentifier", "chatId"],
|
removeParticipant: ["chatGuid", "chatIdentifier", "chatId"],
|
||||||
leaveGroup: ["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> {
|
export async function getImageMetadata(buffer: Buffer): Promise<ImageMetadata | null> {
|
||||||
if (prefersSips()) {
|
if (prefersSips()) {
|
||||||
return await sipsMetadataFromBuffer(buffer).catch(() => null);
|
return await sipsMetadataFromBuffer(buffer).catch(() => null);
|
||||||
@@ -318,6 +331,14 @@ export async function resizeToJpeg(params: {
|
|||||||
.toBuffer();
|
.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).
|
* Internal sips-only EXIF normalization (no sharp fallback).
|
||||||
* Used by resizeToJpeg to normalize before sips resize.
|
* 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.
|
// Map common mimes to preferred file extensions.
|
||||||
const EXT_BY_MIME: Record<string, string> = {
|
const EXT_BY_MIME: Record<string, string> = {
|
||||||
|
"image/heic": ".heic",
|
||||||
|
"image/heif": ".heif",
|
||||||
"image/jpeg": ".jpg",
|
"image/jpeg": ".jpg",
|
||||||
"image/png": ".png",
|
"image/png": ".png",
|
||||||
"image/webp": ".webp",
|
"image/webp": ".webp",
|
||||||
@@ -137,6 +139,10 @@ export function imageMimeFromFormat(format?: string | null): string | undefined
|
|||||||
case "jpg":
|
case "jpg":
|
||||||
case "jpeg":
|
case "jpeg":
|
||||||
return "image/jpeg";
|
return "image/jpeg";
|
||||||
|
case "heic":
|
||||||
|
return "image/heic";
|
||||||
|
case "heif":
|
||||||
|
return "image/heif";
|
||||||
case "png":
|
case "png":
|
||||||
return "image/png";
|
return "image/png";
|
||||||
case "webp":
|
case "webp":
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { fileURLToPath } from "node:url";
|
|||||||
import { logVerbose, shouldLogVerbose } from "../globals.js";
|
import { logVerbose, shouldLogVerbose } from "../globals.js";
|
||||||
import { type MediaKind, maxBytesForKind, mediaKindFromMime } from "../media/constants.js";
|
import { type MediaKind, maxBytesForKind, mediaKindFromMime } from "../media/constants.js";
|
||||||
import { fetchRemoteMedia } from "../media/fetch.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";
|
import { detectMime, extensionForMime } from "../media/mime.js";
|
||||||
|
|
||||||
type WebMediaResult = {
|
type WebMediaResult = {
|
||||||
@@ -20,6 +20,15 @@ type WebMediaOptions = {
|
|||||||
optimizeImages?: boolean;
|
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(
|
async function loadWebMediaInternal(
|
||||||
mediaUrl: string,
|
mediaUrl: string,
|
||||||
options: WebMediaOptions = {},
|
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 originalSize = buffer.length;
|
||||||
const optimized = await optimizeImageToJpeg(buffer, cap);
|
const optimized = await optimizeImageToJpeg(buffer, cap, meta);
|
||||||
if (optimized.optimizedSize < originalSize && shouldLogVerbose()) {
|
if (optimized.optimizedSize < originalSize && shouldLogVerbose()) {
|
||||||
logVerbose(
|
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})`,
|
`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 {
|
return {
|
||||||
...(await optimizeAndClampImage(params.buffer, cap)),
|
...(await optimizeAndClampImage(params.buffer, cap, {
|
||||||
|
contentType: params.contentType,
|
||||||
|
fileName: params.fileName,
|
||||||
|
})),
|
||||||
fileName: params.fileName,
|
fileName: params.fileName,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -150,6 +166,7 @@ export async function loadWebMediaRaw(
|
|||||||
export async function optimizeImageToJpeg(
|
export async function optimizeImageToJpeg(
|
||||||
buffer: Buffer,
|
buffer: Buffer,
|
||||||
maxBytes: number,
|
maxBytes: number,
|
||||||
|
opts: { contentType?: string; fileName?: string } = {},
|
||||||
): Promise<{
|
): Promise<{
|
||||||
buffer: Buffer;
|
buffer: Buffer;
|
||||||
optimizedSize: number;
|
optimizedSize: number;
|
||||||
@@ -157,6 +174,14 @@ export async function optimizeImageToJpeg(
|
|||||||
quality: number;
|
quality: number;
|
||||||
}> {
|
}> {
|
||||||
// Try a grid of sizes/qualities until under the limit.
|
// 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 sides = [2048, 1536, 1280, 1024, 800];
|
||||||
const qualities = [80, 70, 60, 50, 40];
|
const qualities = [80, 70, 60, 50, 40];
|
||||||
let smallest: {
|
let smallest: {
|
||||||
@@ -170,7 +195,7 @@ export async function optimizeImageToJpeg(
|
|||||||
for (const quality of qualities) {
|
for (const quality of qualities) {
|
||||||
try {
|
try {
|
||||||
const out = await resizeToJpeg({
|
const out = await resizeToJpeg({
|
||||||
buffer,
|
buffer: source,
|
||||||
maxSide: side,
|
maxSide: side,
|
||||||
quality,
|
quality,
|
||||||
withoutEnlargement: true,
|
withoutEnlargement: true,
|
||||||
|
|||||||
@@ -197,7 +197,32 @@ function renderGenericChannelCard(
|
|||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const RECENT_ACTIVITY_THRESHOLD_MS = 10 * 60 * 1000; // 10 minutes
|
||||||
|
|
||||||
|
function hasRecentActivity(account: ChannelAccountSnapshot): boolean {
|
||||||
|
if (!account.lastInboundAt) return false;
|
||||||
|
return Date.now() - account.lastInboundAt < RECENT_ACTIVITY_THRESHOLD_MS;
|
||||||
|
}
|
||||||
|
|
||||||
|
function deriveRunningStatus(account: ChannelAccountSnapshot): "Yes" | "No" | "Active" {
|
||||||
|
if (account.running) return "Yes";
|
||||||
|
// If we have recent inbound activity, the channel is effectively running
|
||||||
|
if (hasRecentActivity(account)) return "Active";
|
||||||
|
return "No";
|
||||||
|
}
|
||||||
|
|
||||||
|
function deriveConnectedStatus(account: ChannelAccountSnapshot): "Yes" | "No" | "Active" | "n/a" {
|
||||||
|
if (account.connected === true) return "Yes";
|
||||||
|
if (account.connected === false) return "No";
|
||||||
|
// If connected is null/undefined but we have recent activity, show as active
|
||||||
|
if (hasRecentActivity(account)) return "Active";
|
||||||
|
return "n/a";
|
||||||
|
}
|
||||||
|
|
||||||
function renderGenericAccount(account: ChannelAccountSnapshot) {
|
function renderGenericAccount(account: ChannelAccountSnapshot) {
|
||||||
|
const runningStatus = deriveRunningStatus(account);
|
||||||
|
const connectedStatus = deriveConnectedStatus(account);
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<div class="account-card">
|
<div class="account-card">
|
||||||
<div class="account-card-header">
|
<div class="account-card-header">
|
||||||
@@ -207,7 +232,7 @@ function renderGenericAccount(account: ChannelAccountSnapshot) {
|
|||||||
<div class="status-list account-card-status">
|
<div class="status-list account-card-status">
|
||||||
<div>
|
<div>
|
||||||
<span class="label">Running</span>
|
<span class="label">Running</span>
|
||||||
<span>${account.running ? "Yes" : "No"}</span>
|
<span>${runningStatus}</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span class="label">Configured</span>
|
<span class="label">Configured</span>
|
||||||
@@ -215,9 +240,7 @@ function renderGenericAccount(account: ChannelAccountSnapshot) {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span class="label">Connected</span>
|
<span class="label">Connected</span>
|
||||||
<span>
|
<span>${connectedStatus}</span>
|
||||||
${account.connected == null ? "n/a" : account.connected ? "Yes" : "No"}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span class="label">Last inbound</span>
|
<span class="label">Last inbound</span>
|
||||||
|
|||||||
Reference in New Issue
Block a user