From 1eab8fa9b03c984b9e03bc081ac6f214fc58fd7d Mon Sep 17 00:00:00 2001 From: Tyler Yust Date: Mon, 19 Jan 2026 20:16:14 -0800 Subject: [PATCH] Step 5 + Review --- extensions/bluebubbles/src/channel.ts | 10 +-- src/channels/dock.ts | 46 +++++++++++ src/channels/plugins/message-action-names.ts | 8 ++ src/channels/registry.ts | 12 +++ src/commands/message.ts | 12 ++- src/config/plugin-auto-enable.test.ts | 80 ++++++++++++++++++++ src/config/plugin-auto-enable.ts | 19 +++++ src/infra/outbound/message-action-runner.ts | 7 +- src/infra/outbound/message-action-spec.ts | 34 +++++++++ src/infra/outbound/outbound-policy.ts | 6 ++ 10 files changed, 219 insertions(+), 15 deletions(-) diff --git a/extensions/bluebubbles/src/channel.ts b/extensions/bluebubbles/src/channel.ts index 2d487f6b6..2eaf21fcd 100644 --- a/extensions/bluebubbles/src/channel.ts +++ b/extensions/bluebubbles/src/channel.ts @@ -9,6 +9,7 @@ import { DEFAULT_ACCOUNT_ID, deleteAccountFromConfigSection, formatPairingApproveHint, + getChatChannelMeta, migrateBaseNameToDefaultAccount, normalizeAccountId, PAIRING_APPROVED_MESSAGE, @@ -31,13 +32,10 @@ import { monitorBlueBubblesProvider, resolveWebhookPathFromConfig } from "./moni import { blueBubblesOnboardingAdapter } from "./onboarding.js"; import { getBlueBubblesRuntime } from "./runtime.js"; +// Use core registry meta for consistency (Gate A: core registry). +// BlueBubbles is positioned before imessage per Gate C preference. const meta = { - id: "bluebubbles", - label: "BlueBubbles", - selectionLabel: "BlueBubbles (macOS app)", - docsPath: "/channels/bluebubbles", - docsLabel: "bluebubbles", - blurb: "iMessage via the BlueBubbles mac app + REST API.", + ...getChatChannelMeta("bluebubbles"), order: 75, }; diff --git a/src/channels/dock.ts b/src/channels/dock.ts index 92199a0f2..70ce814b7 100644 --- a/src/channels/dock.ts +++ b/src/channels/dock.ts @@ -9,6 +9,7 @@ import { resolveWhatsAppAccount } from "../web/accounts.js"; import { normalizeWhatsAppTarget } from "../whatsapp/normalize.js"; import { requireActivePluginRegistry } from "../plugins/runtime.js"; import { + resolveBlueBubblesGroupRequireMention, resolveDiscordGroupRequireMention, resolveIMessageGroupRequireMention, resolveSlackGroupRequireMention, @@ -67,6 +68,27 @@ const formatLower = (allowFrom: Array) => const escapeRegExp = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +// Helper to delegate config operations to a plugin at runtime. +// Used for BlueBubbles which is in CHAT_CHANNEL_ORDER but implemented as a plugin. +function getPluginConfigAdapter(channelId: string) { + return { + resolveAllowFrom: (params: { cfg: ClawdbotConfig; accountId?: string | null }) => { + const registry = requireActivePluginRegistry(); + const entry = registry.channels.find((e) => e.plugin.id === channelId); + return entry?.plugin.config?.resolveAllowFrom?.(params) ?? []; + }, + formatAllowFrom: (params: { + cfg: ClawdbotConfig; + accountId?: string | null; + allowFrom: Array; + }) => { + const registry = requireActivePluginRegistry(); + const entry = registry.channels.find((e) => e.plugin.id === channelId); + return entry?.plugin.config?.formatAllowFrom?.(params) ?? params.allowFrom.map(String); + }, + }; +} + // Channel docks: lightweight channel metadata/behavior for shared code paths. // // Rules: @@ -266,6 +288,30 @@ const DOCKS: Record = { }), }, }, + // BlueBubbles is in CHAT_CHANNEL_ORDER (Gate A: core registry) but implemented as a plugin. + // Config operations are delegated to the plugin at runtime. + // Note: Additional capabilities (edit, unsend, reply, effects, groupManagement) are exposed + // via the plugin's capabilities, not the dock's ChannelCapabilities type. + bluebubbles: { + id: "bluebubbles", + capabilities: { + chatTypes: ["direct", "group"], + reactions: true, + media: true, + }, + outbound: { textChunkLimit: 4000 }, + config: getPluginConfigAdapter("bluebubbles"), + groups: { + resolveRequireMention: resolveBlueBubblesGroupRequireMention, + }, + threading: { + buildToolContext: ({ context, hasRepliedRef }) => ({ + currentChannelId: context.To?.trim() || undefined, + currentThreadTs: context.ReplyToId, + hasRepliedRef, + }), + }, + }, imessage: { id: "imessage", capabilities: { diff --git a/src/channels/plugins/message-action-names.ts b/src/channels/plugins/message-action-names.ts index 3ed40b9ec..874731537 100644 --- a/src/channels/plugins/message-action-names.ts +++ b/src/channels/plugins/message-action-names.ts @@ -6,6 +6,14 @@ export const CHANNEL_MESSAGE_ACTION_NAMES = [ "reactions", "read", "edit", + "unsend", + "reply", + "sendWithEffect", + "renameGroup", + "addParticipant", + "removeParticipant", + "leaveGroup", + "sendAttachment", "delete", "pin", "unpin", diff --git a/src/channels/registry.ts b/src/channels/registry.ts index 92a3c9f9f..f642ed6e6 100644 --- a/src/channels/registry.ts +++ b/src/channels/registry.ts @@ -4,12 +4,15 @@ import { requireActivePluginRegistry } from "../plugins/runtime.js"; // Channel docking: add new core channels here (order + meta + aliases), then // register the plugin in its extension entrypoint and keep protocol IDs in sync. +// BlueBubbles placed before imessage per Gate C decision: prefer BlueBubbles +// for iMessage use cases when both are available. export const CHAT_CHANNEL_ORDER = [ "telegram", "whatsapp", "discord", "slack", "signal", + "bluebubbles", "imessage", ] as const; @@ -67,6 +70,14 @@ const CHAT_CHANNEL_META: Record = { docsLabel: "signal", blurb: 'signal-cli linked device; more setup (David Reagans: "Hop on Discord.").', }, + bluebubbles: { + id: "bluebubbles", + label: "BlueBubbles", + selectionLabel: "BlueBubbles (macOS app)", + docsPath: "/channels/bluebubbles", + docsLabel: "bluebubbles", + blurb: "recommended for iMessage — uses the BlueBubbles mac app + REST API.", + }, imessage: { id: "imessage", label: "iMessage", @@ -79,6 +90,7 @@ const CHAT_CHANNEL_META: Record = { export const CHAT_CHANNEL_ALIASES: Record = { imsg: "imessage", + bb: "bluebubbles", }; const normalizeChannelKey = (raw?: string | null): string | undefined => { diff --git a/src/commands/message.ts b/src/commands/message.ts index eaff66e4a..caf7e6d63 100644 --- a/src/commands/message.ts +++ b/src/commands/message.ts @@ -17,11 +17,15 @@ export async function messageCommand( runtime: RuntimeEnv, ) { const cfg = loadConfig(); - const rawAction = typeof opts.action === "string" ? opts.action.trim().toLowerCase() : ""; - const action = (rawAction || "send") as ChannelMessageActionName; - if (!(CHANNEL_MESSAGE_ACTION_NAMES as readonly string[]).includes(action)) { - throw new Error(`Unknown message action: ${action}`); + const rawAction = typeof opts.action === "string" ? opts.action.trim() : ""; + const actionInput = rawAction || "send"; + const actionMatch = (CHANNEL_MESSAGE_ACTION_NAMES as readonly string[]).find( + (name) => name.toLowerCase() === actionInput.toLowerCase(), + ); + if (!actionMatch) { + throw new Error(`Unknown message action: ${actionInput}`); } + const action = actionMatch as ChannelMessageActionName; const outboundDeps: OutboundSendDeps = createOutboundSendDeps(deps); diff --git a/src/config/plugin-auto-enable.test.ts b/src/config/plugin-auto-enable.test.ts index 62ca47f47..d1e151f13 100644 --- a/src/config/plugin-auto-enable.test.ts +++ b/src/config/plugin-auto-enable.test.ts @@ -59,4 +59,84 @@ describe("applyPluginAutoEnable", () => { expect(result.config.plugins?.entries?.slack?.enabled).toBeUndefined(); expect(result.changes).toEqual([]); }); + + describe("BlueBubbles over imessage prioritization", () => { + it("prefers bluebubbles: skips imessage auto-enable when both are configured", () => { + const result = applyPluginAutoEnable({ + config: { + channels: { + bluebubbles: { serverUrl: "http://localhost:1234", password: "x" }, + imessage: { cliPath: "/usr/local/bin/imsg" }, + }, + }, + env: {}, + }); + + expect(result.config.plugins?.entries?.bluebubbles?.enabled).toBe(true); + expect(result.config.plugins?.entries?.imessage?.enabled).toBeUndefined(); + expect(result.changes.join("\n")).toContain('Enabled plugin "bluebubbles"'); + expect(result.changes.join("\n")).not.toContain('Enabled plugin "imessage"'); + }); + + it("keeps imessage enabled if already explicitly enabled (non-destructive)", () => { + const result = applyPluginAutoEnable({ + config: { + channels: { + bluebubbles: { serverUrl: "http://localhost:1234", password: "x" }, + imessage: { cliPath: "/usr/local/bin/imsg" }, + }, + plugins: { entries: { imessage: { enabled: true } } }, + }, + env: {}, + }); + + expect(result.config.plugins?.entries?.bluebubbles?.enabled).toBe(true); + expect(result.config.plugins?.entries?.imessage?.enabled).toBe(true); + }); + + it("allows imessage auto-enable when bluebubbles is explicitly disabled", () => { + const result = applyPluginAutoEnable({ + config: { + channels: { + bluebubbles: { serverUrl: "http://localhost:1234", password: "x" }, + imessage: { cliPath: "/usr/local/bin/imsg" }, + }, + plugins: { entries: { bluebubbles: { enabled: false } } }, + }, + env: {}, + }); + + expect(result.config.plugins?.entries?.bluebubbles?.enabled).toBe(false); + expect(result.config.plugins?.entries?.imessage?.enabled).toBe(true); + expect(result.changes.join("\n")).toContain('Enabled plugin "imessage"'); + }); + + it("allows imessage auto-enable when bluebubbles is in deny list", () => { + const result = applyPluginAutoEnable({ + config: { + channels: { + bluebubbles: { serverUrl: "http://localhost:1234", password: "x" }, + imessage: { cliPath: "/usr/local/bin/imsg" }, + }, + plugins: { deny: ["bluebubbles"] }, + }, + env: {}, + }); + + expect(result.config.plugins?.entries?.bluebubbles?.enabled).toBeUndefined(); + expect(result.config.plugins?.entries?.imessage?.enabled).toBe(true); + }); + + it("enables imessage normally when only imessage is configured", () => { + const result = applyPluginAutoEnable({ + config: { + channels: { imessage: { cliPath: "/usr/local/bin/imsg" } }, + }, + env: {}, + }); + + expect(result.config.plugins?.entries?.imessage?.enabled).toBe(true); + expect(result.changes.join("\n")).toContain('Enabled plugin "imessage"'); + }); + }); }); diff --git a/src/config/plugin-auto-enable.ts b/src/config/plugin-auto-enable.ts index ce8398606..16136b94e 100644 --- a/src/config/plugin-auto-enable.ts +++ b/src/config/plugin-auto-enable.ts @@ -267,6 +267,23 @@ function isPluginDenied(cfg: ClawdbotConfig, pluginId: string): boolean { return Array.isArray(deny) && deny.includes(pluginId); } +/** + * When both BlueBubbles and iMessage are configured, prefer BlueBubbles: + * skip auto-enabling iMessage unless BlueBubbles is explicitly disabled/denied. + * This is non-destructive: if iMessage is already enabled, it won't be touched. + */ +function shouldSkipImsgForBlueBubbles( + cfg: ClawdbotConfig, + pluginId: string, + configured: PluginEnableChange[], +): boolean { + if (pluginId !== "imessage") return false; + const blueBubblesConfigured = configured.some((e) => e.pluginId === "bluebubbles"); + if (!blueBubblesConfigured) return false; + // Skip imessage auto-enable if bluebubbles is configured and not blocked + return !isPluginExplicitlyDisabled(cfg, "bluebubbles") && !isPluginDenied(cfg, "bluebubbles"); +} + function ensureAllowlisted(cfg: ClawdbotConfig, pluginId: string): ClawdbotConfig { const allow = cfg.plugins?.allow; if (!Array.isArray(allow) || allow.includes(pluginId)) return cfg; @@ -317,6 +334,8 @@ export function applyPluginAutoEnable(params: { for (const entry of configured) { if (isPluginDenied(next, entry.pluginId)) continue; if (isPluginExplicitlyDisabled(next, entry.pluginId)) continue; + // Prefer BlueBubbles over imessage: skip imsg auto-enable if bluebubbles is configured + if (shouldSkipImsgForBlueBubbles(next, entry.pluginId, configured)) continue; const allow = next.plugins?.allow; const allowMissing = Array.isArray(allow) && !allow.includes(entry.pluginId); const alreadyEnabled = next.plugins?.entries?.[entry.pluginId]?.enabled === true; diff --git a/src/infra/outbound/message-action-runner.ts b/src/infra/outbound/message-action-runner.ts index 50d7c36f6..8db2d1e5b 100644 --- a/src/infra/outbound/message-action-runner.ts +++ b/src/infra/outbound/message-action-runner.ts @@ -28,7 +28,7 @@ import { shouldApplyCrossContextMarker, } from "./outbound-policy.js"; import { executePollAction, executeSendAction } from "./outbound-send-service.js"; -import { actionRequiresTarget } from "./message-action-spec.js"; +import { actionHasTarget, actionRequiresTarget } from "./message-action-spec.js"; import { resolveChannelTarget } from "./target-resolver.js"; export type MessageActionRunnerGateway = { @@ -536,10 +536,7 @@ export async function runMessageAction( applyTargetToParams({ action, args: params }); if (actionRequiresTarget(action)) { - const hasTarget = - (typeof params.to === "string" && params.to.trim()) || - (typeof params.channelId === "string" && params.channelId.trim()); - if (!hasTarget) { + if (!actionHasTarget(action, params)) { throw new Error(`Action ${action} requires a target.`); } } diff --git a/src/infra/outbound/message-action-spec.ts b/src/infra/outbound/message-action-spec.ts index de4220236..fea750390 100644 --- a/src/infra/outbound/message-action-spec.ts +++ b/src/infra/outbound/message-action-spec.ts @@ -11,6 +11,14 @@ export const MESSAGE_ACTION_TARGET_MODE: Record> = { + unsend: ["messageId"], + renameGroup: ["chatGuid", "chatIdentifier", "chatId"], + addParticipant: ["chatGuid", "chatIdentifier", "chatId"], + removeParticipant: ["chatGuid", "chatIdentifier", "chatId"], + leaveGroup: ["chatGuid", "chatIdentifier", "chatId"], +}; + export function actionRequiresTarget(action: ChannelMessageActionName): boolean { return MESSAGE_ACTION_TARGET_MODE[action] !== "none"; } + +export function actionHasTarget( + action: ChannelMessageActionName, + params: Record, +): boolean { + const to = typeof params.to === "string" ? params.to.trim() : ""; + if (to) return true; + const channelId = typeof params.channelId === "string" ? params.channelId.trim() : ""; + if (channelId) return true; + const aliases = ACTION_TARGET_ALIASES[action]; + if (!aliases) return false; + return aliases.some((alias) => { + const value = params[alias]; + if (typeof value === "string") return value.trim().length > 0; + if (typeof value === "number") return Number.isFinite(value); + return false; + }); +} diff --git a/src/infra/outbound/outbound-policy.ts b/src/infra/outbound/outbound-policy.ts index 9c0ecb027..e1f654109 100644 --- a/src/infra/outbound/outbound-policy.ts +++ b/src/infra/outbound/outbound-policy.ts @@ -17,6 +17,9 @@ export type CrossContextDecoration = { const CONTEXT_GUARDED_ACTIONS = new Set([ "send", "poll", + "reply", + "sendWithEffect", + "sendAttachment", "thread-create", "thread-reply", "sticker", @@ -25,6 +28,9 @@ const CONTEXT_GUARDED_ACTIONS = new Set([ const CONTEXT_MARKER_ACTIONS = new Set([ "send", "poll", + "reply", + "sendWithEffect", + "sendAttachment", "thread-reply", "sticker", ]);