Step 3 + Review

This commit is contained in:
Tyler Yust
2026-01-19 18:39:56 -08:00
committed by Peter Steinberger
parent e9d691d472
commit 7870ce8177
11 changed files with 1462 additions and 17 deletions

View File

@@ -244,7 +244,7 @@ extension ChannelsSettings {
} }
var orderedChannels: [ChannelItem] { var orderedChannels: [ChannelItem] {
let fallback = ["whatsapp", "telegram", "discord", "slack", "signal", "imessage"] let fallback = ["whatsapp", "telegram", "discord", "slack", "signal", "imessage", "bluebubbles"]
let order = self.store.snapshot?.channelOrder ?? fallback let order = self.store.snapshot?.channelOrder ?? fallback
let channels = order.enumerated().map { index, id in let channels = order.enumerated().map { index, id in
ChannelItem( ChannelItem(
@@ -440,6 +440,7 @@ extension ChannelsSettings {
case "slack": "Slack Bot" case "slack": "Slack Bot"
case "signal": "Signal REST" case "signal": "Signal REST"
case "imessage": "iMessage" case "imessage": "iMessage"
case "bluebubbles": "BlueBubbles"
default: self.resolveChannelTitle(id) default: self.resolveChannelTitle(id)
} }
} }
@@ -452,6 +453,7 @@ extension ChannelsSettings {
case "slack": "number" case "slack": "number"
case "signal": "antenna.radiowaves.left.and.right" case "signal": "antenna.radiowaves.left.and.right"
case "imessage": "message.fill" case "imessage": "message.fill"
case "bluebubbles": "bubble.left.and.text.bubble.right"
default: "message" default: "message"
} }
} }

View File

@@ -340,6 +340,7 @@ struct CronJobEditor: View {
Text("slack").tag(GatewayAgentChannel.slack) Text("slack").tag(GatewayAgentChannel.slack)
Text("signal").tag(GatewayAgentChannel.signal) Text("signal").tag(GatewayAgentChannel.signal)
Text("imessage").tag(GatewayAgentChannel.imessage) Text("imessage").tag(GatewayAgentChannel.imessage)
Text("bluebubbles").tag(GatewayAgentChannel.bluebubbles)
} }
.labelsHidden() .labelsHidden()
.pickerStyle(.segmented) .pickerStyle(.segmented)

View File

@@ -15,6 +15,7 @@ enum GatewayAgentChannel: String, Codable, CaseIterable, Sendable {
case signal case signal
case imessage case imessage
case msteams case msteams
case bluebubbles
case webchat case webchat
init(raw: String?) { init(raw: String?) {

View File

@@ -11,6 +11,7 @@ import Testing
#expect(GatewayAgentChannel.last.shouldDeliver(true) == true) #expect(GatewayAgentChannel.last.shouldDeliver(true) == true)
#expect(GatewayAgentChannel.whatsapp.shouldDeliver(true) == true) #expect(GatewayAgentChannel.whatsapp.shouldDeliver(true) == true)
#expect(GatewayAgentChannel.telegram.shouldDeliver(true) == true) #expect(GatewayAgentChannel.telegram.shouldDeliver(true) == true)
#expect(GatewayAgentChannel.bluebubbles.shouldDeliver(true) == true)
#expect(GatewayAgentChannel.last.shouldDeliver(false) == false) #expect(GatewayAgentChannel.last.shouldDeliver(false) == false)
} }
@@ -18,6 +19,7 @@ import Testing
#expect(GatewayAgentChannel(raw: nil) == .last) #expect(GatewayAgentChannel(raw: nil) == .last)
#expect(GatewayAgentChannel(raw: " ") == .last) #expect(GatewayAgentChannel(raw: " ") == .last)
#expect(GatewayAgentChannel(raw: "WEBCHAT") == .webchat) #expect(GatewayAgentChannel(raw: "WEBCHAT") == .webchat)
#expect(GatewayAgentChannel(raw: "BLUEBUBBLES") == .bluebubbles)
#expect(GatewayAgentChannel(raw: "unknown") == .last) #expect(GatewayAgentChannel(raw: "unknown") == .last)
} }
} }

View File

@@ -1,3 +1,6 @@
import path from "node:path";
import { fileURLToPath } from "node:url";
import type { ChannelAccountSnapshot, ChannelPlugin, ClawdbotConfig } from "clawdbot/plugin-sdk"; import type { ChannelAccountSnapshot, ChannelPlugin, ClawdbotConfig } from "clawdbot/plugin-sdk";
import { import {
applyAccountNameToChannelSection, applyAccountNameToChannelSection,
@@ -25,6 +28,7 @@ import { normalizeBlueBubblesHandle } from "./targets.js";
import { bluebubblesMessageActions } from "./actions.js"; import { bluebubblesMessageActions } from "./actions.js";
import { monitorBlueBubblesProvider, resolveWebhookPathFromConfig } from "./monitor.js"; import { monitorBlueBubblesProvider, resolveWebhookPathFromConfig } from "./monitor.js";
import { blueBubblesOnboardingAdapter } from "./onboarding.js"; import { blueBubblesOnboardingAdapter } from "./onboarding.js";
import { getBlueBubblesRuntime } from "./runtime.js";
const meta = { const meta = {
id: "bluebubbles", id: "bluebubbles",
@@ -36,6 +40,37 @@ const meta = {
order: 75, order: 75,
}; };
const HTTP_URL_RE = /^https?:\/\//i;
function resolveLocalMediaPath(source: string): string {
if (!source.startsWith("file://")) return source;
try {
return fileURLToPath(source);
} catch {
throw new Error(`Invalid file:// URL: ${source}`);
}
}
function resolveFilenameFromSource(source?: string): string | undefined {
if (!source) return undefined;
if (source.startsWith("file://")) {
try {
return path.basename(fileURLToPath(source)) || undefined;
} catch {
return undefined;
}
}
if (HTTP_URL_RE.test(source)) {
try {
return path.basename(new URL(source).pathname) || undefined;
} catch {
return undefined;
}
}
const base = path.basename(source);
return base || undefined;
}
export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount> = { export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount> = {
id: "bluebubbles", id: "bluebubbles",
meta, meta,
@@ -216,27 +251,69 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount> = {
}); });
return { channel: "bluebubbles", ...result }; return { channel: "bluebubbles", ...result };
}, },
sendMedia: async ({ cfg, to, mediaPath, mediaBuffer, contentType, filename, caption, accountId }) => { sendMedia: async (ctx) => {
// Prefer buffer if provided, otherwise read from path const { cfg, to, text, mediaUrl, accountId } = ctx;
const { mediaPath, mediaBuffer, contentType, filename, caption } = ctx as {
mediaPath?: string;
mediaBuffer?: Uint8Array;
contentType?: string;
filename?: string;
caption?: string;
};
const core = getBlueBubblesRuntime();
const resolvedCaption = caption ?? text;
let buffer: Uint8Array; let buffer: Uint8Array;
let resolvedContentType = contentType ?? undefined;
let resolvedFilename = filename ?? undefined;
if (mediaBuffer) { if (mediaBuffer) {
buffer = mediaBuffer; buffer = mediaBuffer;
} else if (mediaPath) { if (!resolvedContentType) {
const fs = await import("node:fs/promises"); const hint = mediaPath ?? mediaUrl;
buffer = new Uint8Array(await fs.readFile(mediaPath)); const detected = await core.media.detectMime({
buffer: Buffer.isBuffer(mediaBuffer) ? mediaBuffer : Buffer.from(mediaBuffer),
filePath: hint,
});
resolvedContentType = detected ?? undefined;
}
if (!resolvedFilename) {
resolvedFilename = resolveFilenameFromSource(mediaPath ?? mediaUrl);
}
} else { } else {
throw new Error("BlueBubbles media delivery requires mediaPath or mediaBuffer."); const source = mediaPath ?? mediaUrl;
if (!source) {
throw new Error("BlueBubbles media delivery requires mediaUrl, mediaPath, or mediaBuffer.");
}
if (HTTP_URL_RE.test(source)) {
const fetched = await core.channel.media.fetchRemoteMedia({ url: source });
buffer = fetched.buffer;
resolvedContentType = resolvedContentType ?? fetched.contentType ?? undefined;
resolvedFilename = resolvedFilename ?? fetched.fileName;
} else {
const localPath = resolveLocalMediaPath(source);
const fs = await import("node:fs/promises");
const data = await fs.readFile(localPath);
buffer = new Uint8Array(data);
if (!resolvedContentType) {
const detected = await core.media.detectMime({
buffer: data,
filePath: localPath,
});
resolvedContentType = detected ?? undefined;
}
if (!resolvedFilename) {
resolvedFilename = resolveFilenameFromSource(localPath);
}
}
} }
// Resolve filename from path if not provided
const resolvedFilename = filename ?? (mediaPath ? mediaPath.split("/").pop() ?? "attachment" : "attachment");
const result = await sendBlueBubblesAttachment({ const result = await sendBlueBubblesAttachment({
to, to,
buffer, buffer,
filename: resolvedFilename, filename: resolvedFilename ?? "attachment",
contentType: contentType ?? undefined, contentType: resolvedContentType ?? undefined,
caption: caption ?? undefined, caption: resolvedCaption ?? undefined,
opts: { opts: {
cfg: cfg as ClawdbotConfig, cfg: cfg as ClawdbotConfig,
accountId: accountId ?? undefined, accountId: accountId ?? undefined,

File diff suppressed because it is too large Load Diff

View File

@@ -80,7 +80,9 @@ export function buildCronPayload(form: CronFormState) {
| "discord" | "discord"
| "slack" | "slack"
| "signal" | "signal"
| "imessage"; | "imessage"
| "msteams"
| "bluebubbles";
to?: string; to?: string;
timeoutSeconds?: number; timeoutSeconds?: number;
} = { kind: "agentTurn", message }; } = { kind: "agentTurn", message };

View File

@@ -332,7 +332,8 @@ export type CronPayload =
| "slack" | "slack"
| "signal" | "signal"
| "imessage" | "imessage"
| "msteams"; | "msteams"
| "bluebubbles";
to?: string; to?: string;
bestEffortDeliver?: boolean; bestEffortDeliver?: boolean;
}; };

View File

@@ -28,7 +28,8 @@ export type CronFormState = {
| "slack" | "slack"
| "signal" | "signal"
| "imessage" | "imessage"
| "msteams"; | "msteams"
| "bluebubbles";
to: string; to: string;
timeoutSeconds: string; timeoutSeconds: string;
postToMainPrefix: string; postToMainPrefix: string;

View File

@@ -88,7 +88,7 @@ function resolveChannelOrder(snapshot: ChannelsStatusSnapshot | null): ChannelKe
if (snapshot?.channelOrder?.length) { if (snapshot?.channelOrder?.length) {
return snapshot.channelOrder; return snapshot.channelOrder;
} }
return ["whatsapp", "telegram", "discord", "slack", "signal", "imessage"]; return ["whatsapp", "telegram", "discord", "slack", "signal", "imessage", "bluebubbles"];
} }
function renderChannel( function renderChannel(

View File

@@ -199,6 +199,7 @@ export function renderCron(props: CronProps) {
<option value="signal">Signal</option> <option value="signal">Signal</option>
<option value="imessage">iMessage</option> <option value="imessage">iMessage</option>
<option value="msteams">MS Teams</option> <option value="msteams">MS Teams</option>
<option value="bluebubbles">BlueBubbles</option>
</select> </select>
</label> </label>
<label class="field"> <label class="field">