diff --git a/apps/macos/Sources/Clawdbot/ChannelsSettings+ChannelState.swift b/apps/macos/Sources/Clawdbot/ChannelsSettings+ChannelState.swift index 6d7be4e96..0ca4be206 100644 --- a/apps/macos/Sources/Clawdbot/ChannelsSettings+ChannelState.swift +++ b/apps/macos/Sources/Clawdbot/ChannelsSettings+ChannelState.swift @@ -244,7 +244,7 @@ extension ChannelsSettings { } var orderedChannels: [ChannelItem] { - let fallback = ["whatsapp", "telegram", "discord", "slack", "signal", "imessage", "bluebubbles"] + let fallback = ["whatsapp", "telegram", "discord", "slack", "signal", "imessage"] let order = self.store.snapshot?.channelOrder ?? fallback let channels = order.enumerated().map { index, id in ChannelItem( @@ -433,29 +433,17 @@ extension ChannelsSettings { } private func resolveChannelDetailTitle(_ id: String) -> String { - switch id { - case "whatsapp": "WhatsApp Web" - case "telegram": "Telegram Bot" - case "discord": "Discord Bot" - case "slack": "Slack Bot" - case "signal": "Signal REST" - case "imessage": "iMessage" - case "bluebubbles": "BlueBubbles" - default: self.resolveChannelTitle(id) + if let detail = self.store.snapshot?.channelDetailLabels?[id], !detail.isEmpty { + return detail } + return self.resolveChannelTitle(id) } private func resolveChannelSystemImage(_ id: String) -> String { - switch id { - case "whatsapp": "message" - case "telegram": "paperplane" - case "discord": "bubble.left.and.bubble.right" - case "slack": "number" - case "signal": "antenna.radiowaves.left.and.right" - case "imessage": "message.fill" - case "bluebubbles": "bubble.left.and.text.bubble.right" - default: "message" + if let symbol = self.store.snapshot?.channelSystemImages?[id], !symbol.isEmpty { + return symbol } + return "message" } private func channelStatusDictionary(_ id: String) -> [String: AnyCodable]? { diff --git a/apps/macos/Sources/Clawdbot/ChannelsStore.swift b/apps/macos/Sources/Clawdbot/ChannelsStore.swift index 13a68ab2f..a55ac4681 100644 --- a/apps/macos/Sources/Clawdbot/ChannelsStore.swift +++ b/apps/macos/Sources/Clawdbot/ChannelsStore.swift @@ -156,6 +156,8 @@ struct ChannelsStatusSnapshot: Codable { let ts: Double let channelOrder: [String] let channelLabels: [String: String] + let channelDetailLabels: [String: String]? = nil + let channelSystemImages: [String: String]? = nil let channels: [String: AnyCodable] let channelAccounts: [String: [ChannelAccountSnapshot]] let channelDefaultAccountId: [String: String] diff --git a/apps/macos/Sources/Clawdbot/CronJobEditor+Helpers.swift b/apps/macos/Sources/Clawdbot/CronJobEditor+Helpers.swift index 52a18df0d..4bac28c4f 100644 --- a/apps/macos/Sources/Clawdbot/CronJobEditor+Helpers.swift +++ b/apps/macos/Sources/Clawdbot/CronJobEditor+Helpers.swift @@ -42,7 +42,8 @@ extension CronJobEditor { self.thinking = thinking ?? "" self.timeoutSeconds = timeoutSeconds.map(String.init) ?? "" self.deliver = deliver ?? false - self.channel = GatewayAgentChannel(raw: channel) + let trimmed = (channel ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + self.channel = trimmed.isEmpty ? "last" : trimmed self.to = to ?? "" self.bestEffortDeliver = bestEffortDeliver ?? false } @@ -210,7 +211,8 @@ extension CronJobEditor { if let n = Int(self.timeoutSeconds), n > 0 { payload["timeoutSeconds"] = n } payload["deliver"] = self.deliver if self.deliver { - payload["channel"] = self.channel.rawValue + let trimmed = self.channel.trimmingCharacters(in: .whitespacesAndNewlines) + payload["channel"] = trimmed.isEmpty ? "last" : trimmed let to = self.to.trimmingCharacters(in: .whitespacesAndNewlines) if !to.isEmpty { payload["to"] = to } payload["bestEffortDeliver"] = self.bestEffortDeliver diff --git a/apps/macos/Sources/Clawdbot/CronJobEditor+Testing.swift b/apps/macos/Sources/Clawdbot/CronJobEditor+Testing.swift index 603180b45..0d4c46523 100644 --- a/apps/macos/Sources/Clawdbot/CronJobEditor+Testing.swift +++ b/apps/macos/Sources/Clawdbot/CronJobEditor+Testing.swift @@ -14,7 +14,7 @@ extension CronJobEditor { self.payloadKind = .agentTurn self.agentMessage = "Run diagnostic" self.deliver = true - self.channel = .last + self.channel = "last" self.to = "+15551230000" self.thinking = "low" self.timeoutSeconds = "90" diff --git a/apps/macos/Sources/Clawdbot/CronJobEditor.swift b/apps/macos/Sources/Clawdbot/CronJobEditor.swift index 8d5b4a9b3..77d4c5b37 100644 --- a/apps/macos/Sources/Clawdbot/CronJobEditor.swift +++ b/apps/macos/Sources/Clawdbot/CronJobEditor.swift @@ -1,10 +1,12 @@ import ClawdbotProtocol +import Observation import SwiftUI struct CronJobEditor: View { let job: CronJob? @Binding var isSaving: Bool @Binding var error: String? + @Bindable var channelsStore: ChannelsStore let onCancel: () -> Void let onSave: ([String: AnyCodable]) -> Void @@ -45,13 +47,30 @@ struct CronJobEditor: View { @State var systemEventText: String = "" @State var agentMessage: String = "" @State var deliver: Bool = false - @State var channel: GatewayAgentChannel = .last + @State var channel: String = "last" @State var to: String = "" @State var thinking: String = "" @State var timeoutSeconds: String = "" @State var bestEffortDeliver: Bool = false @State var postPrefix: String = "Cron" + var channelOptions: [String] { + let snapshot = self.channelsStore.snapshot + let ordered = snapshot?.channelOrder ?? [] + var options = ["last"] + ordered + let trimmed = self.channel.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmed.isEmpty, !options.contains(trimmed) { + options.append(trimmed) + } + var seen = Set() + return options.filter { seen.insert($0).inserted } + } + + func channelLabel(for id: String) -> String { + if id == "last" { return "last" } + return self.channelsStore.snapshot?.channelLabels[id] ?? id + } + var body: some View { VStack(alignment: .leading, spacing: 16) { VStack(alignment: .leading, spacing: 6) { @@ -333,14 +352,9 @@ struct CronJobEditor: View { GridRow { self.gridLabel("Channel") Picker("", selection: self.$channel) { - Text("last").tag(GatewayAgentChannel.last) - Text("whatsapp").tag(GatewayAgentChannel.whatsapp) - Text("telegram").tag(GatewayAgentChannel.telegram) - Text("discord").tag(GatewayAgentChannel.discord) - Text("slack").tag(GatewayAgentChannel.slack) - Text("signal").tag(GatewayAgentChannel.signal) - Text("imessage").tag(GatewayAgentChannel.imessage) - Text("bluebubbles").tag(GatewayAgentChannel.bluebubbles) + ForEach(self.channelOptions, id: \.self) { channel in + Text(self.channelLabel(for: channel)).tag(channel) + } } .labelsHidden() .pickerStyle(.segmented) diff --git a/apps/macos/Sources/Clawdbot/CronSettings+Layout.swift b/apps/macos/Sources/Clawdbot/CronSettings+Layout.swift index f1891bd3e..11c7c0a0e 100644 --- a/apps/macos/Sources/Clawdbot/CronSettings+Layout.swift +++ b/apps/macos/Sources/Clawdbot/CronSettings+Layout.swift @@ -8,13 +8,20 @@ extension CronSettings { self.content Spacer(minLength: 0) } - .onAppear { self.store.start() } - .onDisappear { self.store.stop() } + .onAppear { + self.store.start() + self.channelsStore.start() + } + .onDisappear { + self.store.stop() + self.channelsStore.stop() + } .sheet(isPresented: self.$showEditor) { CronJobEditor( job: self.editingJob, isSaving: self.$isSaving, error: self.$editorError, + channelsStore: self.channelsStore, onCancel: { self.showEditor = false self.editingJob = nil diff --git a/apps/macos/Sources/Clawdbot/CronSettings+Testing.swift b/apps/macos/Sources/Clawdbot/CronSettings+Testing.swift index 9a767c374..976049f66 100644 --- a/apps/macos/Sources/Clawdbot/CronSettings+Testing.swift +++ b/apps/macos/Sources/Clawdbot/CronSettings+Testing.swift @@ -47,7 +47,7 @@ struct CronSettings_Previews: PreviewProvider { durationMs: 1234, nextRunAtMs: nil), ] - return CronSettings(store: store) + return CronSettings(store: store, channelsStore: ChannelsStore(isPreview: true)) .frame(width: SettingsTab.windowWidth, height: SettingsTab.windowHeight) } } @@ -103,7 +103,7 @@ extension CronSettings { store.selectedJobId = job.id store.runEntries = [run] - let view = CronSettings(store: store) + let view = CronSettings(store: store, channelsStore: ChannelsStore(isPreview: true)) _ = view.body _ = view.jobRow(job) _ = view.jobContextMenu(job) diff --git a/apps/macos/Sources/Clawdbot/CronSettings.swift b/apps/macos/Sources/Clawdbot/CronSettings.swift index d19a419b3..999712a59 100644 --- a/apps/macos/Sources/Clawdbot/CronSettings.swift +++ b/apps/macos/Sources/Clawdbot/CronSettings.swift @@ -3,13 +3,15 @@ import SwiftUI struct CronSettings: View { @Bindable var store: CronJobsStore + @Bindable var channelsStore: ChannelsStore @State var showEditor = false @State var editingJob: CronJob? @State var editorError: String? @State var isSaving = false @State var confirmDelete: CronJob? - init(store: CronJobsStore = .shared) { + init(store: CronJobsStore = .shared, channelsStore: ChannelsStore = .shared) { self.store = store + self.channelsStore = channelsStore } } diff --git a/apps/macos/Sources/ClawdbotProtocol/GatewayModels.swift b/apps/macos/Sources/ClawdbotProtocol/GatewayModels.swift index 0ec8533ef..fb11d9430 100644 --- a/apps/macos/Sources/ClawdbotProtocol/GatewayModels.swift +++ b/apps/macos/Sources/ClawdbotProtocol/GatewayModels.swift @@ -1312,6 +1312,8 @@ public struct ChannelsStatusResult: Codable, Sendable { public let ts: Int public let channelorder: [String] public let channellabels: [String: AnyCodable] + public let channeldetaillabels: [String: AnyCodable]? + public let channelsystemimages: [String: AnyCodable]? public let channels: [String: AnyCodable] public let channelaccounts: [String: AnyCodable] public let channeldefaultaccountid: [String: AnyCodable] @@ -1320,6 +1322,8 @@ public struct ChannelsStatusResult: Codable, Sendable { ts: Int, channelorder: [String], channellabels: [String: AnyCodable], + channeldetaillabels: [String: AnyCodable]?, + channelsystemimages: [String: AnyCodable]?, channels: [String: AnyCodable], channelaccounts: [String: AnyCodable], channeldefaultaccountid: [String: AnyCodable] @@ -1327,6 +1331,8 @@ public struct ChannelsStatusResult: Codable, Sendable { self.ts = ts self.channelorder = channelorder self.channellabels = channellabels + self.channeldetaillabels = channeldetaillabels + self.channelsystemimages = channelsystemimages self.channels = channels self.channelaccounts = channelaccounts self.channeldefaultaccountid = channeldefaultaccountid @@ -1335,6 +1341,8 @@ public struct ChannelsStatusResult: Codable, Sendable { case ts case channelorder = "channelOrder" case channellabels = "channelLabels" + case channeldetaillabels = "channelDetailLabels" + case channelsystemimages = "channelSystemImages" case channels case channelaccounts = "channelAccounts" case channeldefaultaccountid = "channelDefaultAccountId" diff --git a/docs/plugin.md b/docs/plugin.md index 507246b04..34535fa10 100644 --- a/docs/plugin.md +++ b/docs/plugin.md @@ -375,6 +375,8 @@ Notes: - Put config under `channels.` (not `plugins.entries`). - `meta.label` is used for labels in CLI/UI lists. - `meta.aliases` adds alternate ids for normalization and CLI inputs. +- `meta.preferOver` lists channel ids to skip auto-enable when both are configured. +- `meta.detailLabel` and `meta.systemImage` let UIs show richer channel labels/icons. ### Write a new messaging channel (step‑by‑step) @@ -388,6 +390,8 @@ Model provider docs live under `/providers/*`. 2) Define the channel metadata - `meta.label`, `meta.selectionLabel`, `meta.docsPath`, `meta.blurb` control CLI/UI lists. - `meta.docsPath` should point at a docs page like `/channels/`. +- `meta.preferOver` lets a plugin replace another channel (auto-enable prefers it). +- `meta.detailLabel` and `meta.systemImage` are used by UIs for detail text/icons. 3) Implement the required adapters - `config.listAccountIds` + `config.resolveAccount` diff --git a/extensions/bluebubbles/package.json b/extensions/bluebubbles/package.json index af84d53dd..edb7ffca4 100644 --- a/extensions/bluebubbles/package.json +++ b/extensions/bluebubbles/package.json @@ -9,9 +9,17 @@ "id": "bluebubbles", "label": "BlueBubbles", "selectionLabel": "BlueBubbles (macOS app)", + "detailLabel": "BlueBubbles", "docsPath": "/channels/bluebubbles", "docsLabel": "bluebubbles", "blurb": "iMessage via the BlueBubbles mac app + REST API.", + "aliases": [ + "bb" + ], + "preferOver": [ + "imessage" + ], + "systemImage": "bubble.left.and.text.bubble.right", "order": 75 }, "install": { diff --git a/extensions/bluebubbles/src/channel.ts b/extensions/bluebubbles/src/channel.ts index fce596465..b78f41659 100644 --- a/extensions/bluebubbles/src/channel.ts +++ b/extensions/bluebubbles/src/channel.ts @@ -6,10 +6,10 @@ import { DEFAULT_ACCOUNT_ID, deleteAccountFromConfigSection, formatPairingApproveHint, - getChatChannelMeta, migrateBaseNameToDefaultAccount, normalizeAccountId, PAIRING_APPROVED_MESSAGE, + resolveBlueBubblesGroupRequireMention, setAccountEnabledInConfigSection, } from "clawdbot/plugin-sdk"; @@ -32,11 +32,18 @@ import { monitorBlueBubblesProvider, resolveWebhookPathFromConfig } from "./moni import { blueBubblesOnboardingAdapter } from "./onboarding.js"; import { sendBlueBubblesMedia } from "./media-send.js"; -// Use core registry meta for consistency (Gate A: core registry). -// BlueBubbles is positioned before imessage per Gate C preference. const meta = { - ...getChatChannelMeta("bluebubbles"), + id: "bluebubbles", + label: "BlueBubbles", + selectionLabel: "BlueBubbles (macOS app)", + detailLabel: "BlueBubbles", + docsPath: "/channels/bluebubbles", + docsLabel: "bluebubbles", + blurb: "iMessage via the BlueBubbles mac app + REST API.", + systemImage: "bubble.left.and.text.bubble.right", + aliases: ["bb"], order: 75, + preferOver: ["imessage"], }; export const bluebubblesPlugin: ChannelPlugin = { @@ -52,6 +59,16 @@ export const bluebubblesPlugin: ChannelPlugin = { effects: true, groupManagement: true, }, + groups: { + resolveRequireMention: resolveBlueBubblesGroupRequireMention, + }, + threading: { + buildToolContext: ({ context, hasRepliedRef }) => ({ + currentChannelId: context.To?.trim() || undefined, + currentThreadTs: context.ReplyToId, + hasRepliedRef, + }), + }, reload: { configPrefixes: ["channels.bluebubbles"] }, configSchema: buildChannelConfigSchema(BlueBubblesConfigSchema), onboarding: blueBubblesOnboardingAdapter, diff --git a/extensions/zalouser/package.json b/extensions/zalouser/package.json index 46748d350..9f0bb241e 100644 --- a/extensions/zalouser/package.json +++ b/extensions/zalouser/package.json @@ -10,6 +10,24 @@ "clawdbot": { "extensions": [ "./index.ts" - ] + ], + "channel": { + "id": "zalouser", + "label": "Zalo Personal", + "selectionLabel": "Zalo (Personal Account)", + "docsPath": "/channels/zalouser", + "docsLabel": "zalouser", + "blurb": "Zalo personal account via QR code login.", + "aliases": [ + "zlu" + ], + "order": 85, + "quickstartAllowFrom": true + }, + "install": { + "npmSpec": "@clawdbot/zalouser", + "localPath": "extensions/zalouser", + "defaultChoice": "npm" + } } } diff --git a/src/channels/dock.ts b/src/channels/dock.ts index 70ce814b7..92199a0f2 100644 --- a/src/channels/dock.ts +++ b/src/channels/dock.ts @@ -9,7 +9,6 @@ import { resolveWhatsAppAccount } from "../web/accounts.js"; import { normalizeWhatsAppTarget } from "../whatsapp/normalize.js"; import { requireActivePluginRegistry } from "../plugins/runtime.js"; import { - resolveBlueBubblesGroupRequireMention, resolveDiscordGroupRequireMention, resolveIMessageGroupRequireMention, resolveSlackGroupRequireMention, @@ -68,27 +67,6 @@ 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: @@ -288,30 +266,6 @@ 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/catalog.ts b/src/channels/plugins/catalog.ts index 66496aca7..6c65213fc 100644 --- a/src/channels/plugins/catalog.ts +++ b/src/channels/plugins/catalog.ts @@ -33,17 +33,21 @@ function toChannelMeta(params: { const label = params.channel.label?.trim(); if (!label) return null; const selectionLabel = params.channel.selectionLabel?.trim() || label; + const detailLabel = params.channel.detailLabel?.trim(); const docsPath = params.channel.docsPath?.trim() || `/channels/${params.id}`; const blurb = params.channel.blurb?.trim() || ""; + const systemImage = params.channel.systemImage?.trim(); return { id: params.id, label, selectionLabel, + ...(detailLabel ? { detailLabel } : {}), docsPath, docsLabel: params.channel.docsLabel?.trim() || undefined, blurb, ...(params.channel.aliases ? { aliases: params.channel.aliases } : {}), + ...(params.channel.preferOver ? { preferOver: params.channel.preferOver } : {}), ...(params.channel.order !== undefined ? { order: params.channel.order } : {}), ...(params.channel.selectionDocsPrefix ? { selectionDocsPrefix: params.channel.selectionDocsPrefix } @@ -52,6 +56,7 @@ function toChannelMeta(params: { ? { selectionDocsOmitLabel: params.channel.selectionDocsOmitLabel } : {}), ...(params.channel.selectionExtras ? { selectionExtras: params.channel.selectionExtras } : {}), + ...(systemImage ? { systemImage } : {}), ...(params.channel.showConfigured !== undefined ? { showConfigured: params.channel.showConfigured } : {}), diff --git a/src/channels/plugins/types.core.ts b/src/channels/plugins/types.core.ts index 223bfe4f5..3f6600620 100644 --- a/src/channels/plugins/types.core.ts +++ b/src/channels/plugins/types.core.ts @@ -74,10 +74,13 @@ export type ChannelMeta = { selectionDocsPrefix?: string; selectionDocsOmitLabel?: boolean; selectionExtras?: string[]; + detailLabel?: string; + systemImage?: string; showConfigured?: boolean; quickstartAllowFrom?: boolean; forceAccountBinding?: boolean; preferSessionLookupForAnnounceTarget?: boolean; + preferOver?: string[]; }; export type ChannelAccountSnapshot = { diff --git a/src/channels/registry.ts b/src/channels/registry.ts index f642ed6e6..52e7a5f01 100644 --- a/src/channels/registry.ts +++ b/src/channels/registry.ts @@ -4,15 +4,12 @@ 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; @@ -31,9 +28,11 @@ const CHAT_CHANNEL_META: Record = { id: "telegram", label: "Telegram", selectionLabel: "Telegram (Bot API)", + detailLabel: "Telegram Bot", docsPath: "/channels/telegram", docsLabel: "telegram", blurb: "simplest way to get started — register a bot with @BotFather and get going.", + systemImage: "paperplane", selectionDocsPrefix: "", selectionDocsOmitLabel: true, selectionExtras: [WEBSITE_URL], @@ -42,55 +41,56 @@ const CHAT_CHANNEL_META: Record = { id: "whatsapp", label: "WhatsApp", selectionLabel: "WhatsApp (QR link)", + detailLabel: "WhatsApp Web", docsPath: "/channels/whatsapp", docsLabel: "whatsapp", blurb: "works with your own number; recommend a separate phone + eSIM.", + systemImage: "message", }, discord: { id: "discord", label: "Discord", selectionLabel: "Discord (Bot API)", + detailLabel: "Discord Bot", docsPath: "/channels/discord", docsLabel: "discord", blurb: "very well supported right now.", + systemImage: "bubble.left.and.bubble.right", }, slack: { id: "slack", label: "Slack", selectionLabel: "Slack (Socket Mode)", + detailLabel: "Slack Bot", docsPath: "/channels/slack", docsLabel: "slack", blurb: "supported (Socket Mode).", + systemImage: "number", }, signal: { id: "signal", label: "Signal", selectionLabel: "Signal (signal-cli)", + detailLabel: "Signal REST", docsPath: "/channels/signal", 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.", + systemImage: "antenna.radiowaves.left.and.right", }, imessage: { id: "imessage", label: "iMessage", selectionLabel: "iMessage (imsg)", + detailLabel: "iMessage", docsPath: "/channels/imessage", docsLabel: "imessage", blurb: "this is still a work in progress.", + systemImage: "message.fill", }, }; export const CHAT_CHANNEL_ALIASES: Record = { imsg: "imessage", - bb: "bluebubbles", }; const normalizeChannelKey = (raw?: string | null): string | undefined => { diff --git a/src/config/group-policy.ts b/src/config/group-policy.ts index 303a2c901..d52daa4c5 100644 --- a/src/config/group-policy.ts +++ b/src/config/group-policy.ts @@ -1,7 +1,8 @@ +import type { ChannelId } from "../channels/plugins/types.js"; import { normalizeAccountId } from "../routing/session-key.js"; import type { ClawdbotConfig } from "./config.js"; -export type GroupPolicyChannel = "whatsapp" | "telegram" | "imessage" | "bluebubbles"; +export type GroupPolicyChannel = ChannelId; export type ChannelGroupConfig = { requireMention?: boolean; diff --git a/src/config/plugin-auto-enable.test.ts b/src/config/plugin-auto-enable.test.ts index d1e151f13..d553e18b9 100644 --- a/src/config/plugin-auto-enable.test.ts +++ b/src/config/plugin-auto-enable.test.ts @@ -60,7 +60,7 @@ describe("applyPluginAutoEnable", () => { expect(result.changes).toEqual([]); }); - describe("BlueBubbles over imessage prioritization", () => { + describe("preferOver channel prioritization", () => { it("prefers bluebubbles: skips imessage auto-enable when both are configured", () => { const result = applyPluginAutoEnable({ config: { diff --git a/src/config/plugin-auto-enable.ts b/src/config/plugin-auto-enable.ts index 16136b94e..d7bd67d87 100644 --- a/src/config/plugin-auto-enable.ts +++ b/src/config/plugin-auto-enable.ts @@ -1,6 +1,10 @@ import type { ClawdbotConfig } from "./config.js"; +import { getChatChannelMeta, listChatChannels, normalizeChatChannelId } from "../channels/registry.js"; +import { + getChannelPluginCatalogEntry, + listChannelPluginCatalogEntries, +} from "../channels/plugins/catalog.js"; import { normalizeProviderId } from "../agents/model-selection.js"; -import { listChatChannels } from "../channels/registry.js"; import { hasAnyWhatsAppAuth } from "../web/accounts.js"; type PluginEnableChange = { @@ -13,6 +17,13 @@ export type PluginAutoEnableResult = { changes: string[]; }; +const CHANNEL_PLUGIN_IDS = Array.from( + new Set([ + ...listChatChannels().map((meta) => meta.id), + ...listChannelPluginCatalogEntries().map((entry) => entry.id), + ]), +); + const PROVIDER_PLUGIN_IDS: Array<{ pluginId: string; providerId: string }> = [ { pluginId: "google-antigravity-auth", providerId: "google-antigravity" }, { pluginId: "google-gemini-cli-auth", providerId: "google-gemini-cli" }, @@ -226,10 +237,7 @@ function resolveConfiguredPlugins( env: NodeJS.ProcessEnv, ): PluginEnableChange[] { const changes: PluginEnableChange[] = []; - const channelIds = new Set(); - for (const meta of listChatChannels()) { - channelIds.add(meta.id); - } + const channelIds = new Set(CHANNEL_PLUGIN_IDS); const configuredChannels = cfg.channels as Record | undefined; if (configuredChannels && typeof configuredChannels === "object") { for (const key of Object.keys(configuredChannels)) { @@ -267,21 +275,30 @@ 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( +function resolvePreferredOverIds(pluginId: string): string[] { + const normalized = normalizeChatChannelId(pluginId); + if (normalized) { + return getChatChannelMeta(normalized).preferOver ?? []; + } + const catalogEntry = getChannelPluginCatalogEntry(pluginId); + return catalogEntry?.meta.preferOver ?? []; +} + +function shouldSkipPreferredPluginAutoEnable( cfg: ClawdbotConfig, - pluginId: string, + entry: PluginEnableChange, 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"); + for (const other of configured) { + if (other.pluginId === entry.pluginId) continue; + if (isPluginDenied(cfg, other.pluginId)) continue; + if (isPluginExplicitlyDisabled(cfg, other.pluginId)) continue; + const preferOver = resolvePreferredOverIds(other.pluginId); + if (preferOver.includes(entry.pluginId)) { + return true; + } + } + return false; } function ensureAllowlisted(cfg: ClawdbotConfig, pluginId: string): ClawdbotConfig { @@ -334,8 +351,7 @@ 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; + if (shouldSkipPreferredPluginAutoEnable(next, entry, 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/gateway/protocol/schema/channels.ts b/src/gateway/protocol/schema/channels.ts index aa60bbb1f..6f02a7c86 100644 --- a/src/gateway/protocol/schema/channels.ts +++ b/src/gateway/protocol/schema/channels.ts @@ -60,6 +60,8 @@ export const ChannelsStatusResultSchema = Type.Object( ts: Type.Integer({ minimum: 0 }), channelOrder: Type.Array(NonEmptyString), channelLabels: Type.Record(NonEmptyString, NonEmptyString), + channelDetailLabels: Type.Optional(Type.Record(NonEmptyString, NonEmptyString)), + channelSystemImages: Type.Optional(Type.Record(NonEmptyString, NonEmptyString)), channels: Type.Record(NonEmptyString, Type.Unknown()), channelAccounts: Type.Record(NonEmptyString, Type.Array(ChannelAccountSnapshotSchema)), channelDefaultAccountId: Type.Record(NonEmptyString, NonEmptyString), diff --git a/src/gateway/server-methods/channels.ts b/src/gateway/server-methods/channels.ts index e516f1c04..3e7806d6d 100644 --- a/src/gateway/server-methods/channels.ts +++ b/src/gateway/server-methods/channels.ts @@ -188,10 +188,24 @@ export const channelsHandlers: GatewayRequestHandlers = { return { accounts, defaultAccountId, defaultAccount, resolvedAccounts }; }; + const channelLabels = Object.fromEntries(plugins.map((plugin) => [plugin.id, plugin.meta.label])); + const channelDetailLabels = Object.fromEntries( + plugins.map((plugin) => [ + plugin.id, + plugin.meta.detailLabel ?? plugin.meta.selectionLabel ?? plugin.meta.label, + ]), + ); + const channelSystemImages = Object.fromEntries( + plugins.flatMap((plugin) => + plugin.meta.systemImage ? [[plugin.id, plugin.meta.systemImage]] : [], + ), + ); const payload: Record = { ts: Date.now(), channelOrder: plugins.map((plugin) => plugin.id), - channelLabels: Object.fromEntries(plugins.map((plugin) => [plugin.id, plugin.meta.label])), + channelLabels, + channelDetailLabels, + channelSystemImages, channels: {} as Record, channelAccounts: {} as Record, channelDefaultAccountId: {} as Record, diff --git a/src/plugins/manifest.ts b/src/plugins/manifest.ts index 82bc719de..c542ded2c 100644 --- a/src/plugins/manifest.ts +++ b/src/plugins/manifest.ts @@ -95,11 +95,14 @@ export type PluginPackageChannel = { id?: string; label?: string; selectionLabel?: string; + detailLabel?: string; docsPath?: string; docsLabel?: string; blurb?: string; order?: number; aliases?: string[]; + preferOver?: string[]; + systemImage?: string; selectionDocsPrefix?: string; selectionDocsOmitLabel?: boolean; selectionExtras?: string[]; diff --git a/ui/src/ui/app-render.ts b/ui/src/ui/app-render.ts index 66fe68199..66fb1fbf3 100644 --- a/ui/src/ui/app-render.ts +++ b/ui/src/ui/app-render.ts @@ -264,6 +264,8 @@ export function renderApp(state: AppViewState) { error: state.cronError, busy: state.cronBusy, form: state.cronForm, + channels: state.channelsSnapshot?.channelOrder ?? [], + channelLabels: state.channelsSnapshot?.channelLabels ?? {}, runsJobId: state.cronRunsJobId, runs: state.cronRuns, onFormChange: (patch) => (state.cronForm = { ...state.cronForm, ...patch }), diff --git a/ui/src/ui/app-settings.ts b/ui/src/ui/app-settings.ts index 423c8327e..580bb332e 100644 --- a/ui/src/ui/app-settings.ts +++ b/ui/src/ui/app-settings.ts @@ -307,6 +307,7 @@ export async function loadChannelsTab(host: SettingsHost) { export async function loadCron(host: SettingsHost) { await Promise.all([ + loadChannels(host as unknown as ClawdbotApp, false), loadCronStatus(host as unknown as ClawdbotApp), loadCronJobs(host as unknown as ClawdbotApp), ]); diff --git a/ui/src/ui/controllers/cron.ts b/ui/src/ui/controllers/cron.ts index de7c96484..d24e65936 100644 --- a/ui/src/ui/controllers/cron.ts +++ b/ui/src/ui/controllers/cron.ts @@ -73,16 +73,7 @@ export function buildCronPayload(form: CronFormState) { kind: "agentTurn"; message: string; deliver?: boolean; - channel?: - | "last" - | "whatsapp" - | "telegram" - | "discord" - | "slack" - | "signal" - | "imessage" - | "msteams" - | "bluebubbles"; + channel?: string; to?: string; timeoutSeconds?: number; } = { kind: "agentTurn", message }; diff --git a/ui/src/ui/types.ts b/ui/src/ui/types.ts index 3f74f26a5..5244233af 100644 --- a/ui/src/ui/types.ts +++ b/ui/src/ui/types.ts @@ -2,11 +2,15 @@ export type ChannelsStatusSnapshot = { ts: number; channelOrder: string[]; channelLabels: Record; + channelDetailLabels?: Record; + channelSystemImages?: Record; channels: Record; channelAccounts: Record; channelDefaultAccountId: Record; }; +export const CRON_CHANNEL_LAST = "last"; + export type ChannelAccountSnapshot = { accountId: string; name?: string | null; @@ -324,16 +328,8 @@ export type CronPayload = thinking?: string; timeoutSeconds?: number; deliver?: boolean; - provider?: - | "last" - | "whatsapp" - | "telegram" - | "discord" - | "slack" - | "signal" - | "imessage" - | "msteams" - | "bluebubbles"; + channel?: string; + provider?: string; to?: string; bestEffortDeliver?: boolean; }; diff --git a/ui/src/ui/ui-types.ts b/ui/src/ui/ui-types.ts index bc5babba1..428c4c381 100644 --- a/ui/src/ui/ui-types.ts +++ b/ui/src/ui/ui-types.ts @@ -4,6 +4,8 @@ export type ChatQueueItem = { createdAt: number; }; +export const CRON_CHANNEL_LAST = "last"; + export type CronFormState = { name: string; description: string; @@ -20,16 +22,7 @@ export type CronFormState = { payloadKind: "systemEvent" | "agentTurn"; payloadText: string; deliver: boolean; - channel: - | "last" - | "whatsapp" - | "telegram" - | "discord" - | "slack" - | "signal" - | "imessage" - | "msteams" - | "bluebubbles"; + channel: string; to: string; timeoutSeconds: string; postToMainPrefix: string; diff --git a/ui/src/ui/views/channels.ts b/ui/src/ui/views/channels.ts index e3aba97d7..0a0aa7b7f 100644 --- a/ui/src/ui/views/channels.ts +++ b/ui/src/ui/views/channels.ts @@ -88,7 +88,7 @@ function resolveChannelOrder(snapshot: ChannelsStatusSnapshot | null): ChannelKe if (snapshot?.channelOrder?.length) { return snapshot.channelOrder; } - return ["whatsapp", "telegram", "discord", "slack", "signal", "imessage", "bluebubbles"]; + return ["whatsapp", "telegram", "discord", "slack", "signal", "imessage"]; } function renderChannel( diff --git a/ui/src/ui/views/cron.test.ts b/ui/src/ui/views/cron.test.ts index 79553f4fc..620a80a6f 100644 --- a/ui/src/ui/views/cron.test.ts +++ b/ui/src/ui/views/cron.test.ts @@ -27,6 +27,8 @@ function createProps(overrides: Partial = {}): CronProps { error: null, busy: false, form: { ...DEFAULT_CRON_FORM }, + channels: [], + channelLabels: {}, runsJobId: null, runs: [], onFormChange: () => undefined, diff --git a/ui/src/ui/views/cron.ts b/ui/src/ui/views/cron.ts index e34a436cf..dffa865c5 100644 --- a/ui/src/ui/views/cron.ts +++ b/ui/src/ui/views/cron.ts @@ -17,6 +17,8 @@ export type CronProps = { error: string | null; busy: boolean; form: CronFormState; + channels: string[]; + channelLabels?: Record; runsJobId: string | null; runs: CronRunLogEntry[]; onFormChange: (patch: Partial) => void; @@ -28,7 +30,27 @@ export type CronProps = { onLoadRuns: (jobId: string) => void; }; +function buildChannelOptions(props: CronProps): string[] { + const options = ["last", ...props.channels.filter(Boolean)]; + const current = props.form.channel?.trim(); + if (current && !options.includes(current)) { + options.push(current); + } + const seen = new Set(); + return options.filter((value) => { + if (seen.has(value)) return false; + seen.add(value); + return true; + }); +} + +function resolveChannelLabel(props: CronProps, channel: string): string { + if (channel === "last") return "last"; + return props.channelLabels?.[channel] ?? channel; +} + export function renderCron(props: CronProps) { + const channelOptions = buildChannelOptions(props); return html`
@@ -185,21 +207,18 @@ export function renderCron(props: CronProps) {