Step 3 + Review
This commit is contained in:
committed by
Peter Steinberger
parent
e9d691d472
commit
7870ce8177
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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?) {
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
1357
extensions/bluebubbles/src/monitor.test.ts
Normal file
1357
extensions/bluebubbles/src/monitor.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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 };
|
||||||
|
|||||||
@@ -332,7 +332,8 @@ export type CronPayload =
|
|||||||
| "slack"
|
| "slack"
|
||||||
| "signal"
|
| "signal"
|
||||||
| "imessage"
|
| "imessage"
|
||||||
| "msteams";
|
| "msteams"
|
||||||
|
| "bluebubbles";
|
||||||
to?: string;
|
to?: string;
|
||||||
bestEffortDeliver?: boolean;
|
bestEffortDeliver?: boolean;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
Reference in New Issue
Block a user