From ac2fcfe96a0a455191a0b34bdd7d1751bd7b410f Mon Sep 17 00:00:00 2001 From: Tyler Yust Date: Mon, 19 Jan 2026 18:06:30 -0800 Subject: [PATCH] Phase 0 + Review --- extensions/bluebubbles/src/channel.ts | 4 + extensions/bluebubbles/src/config-schema.ts | 7 + extensions/bluebubbles/src/monitor.ts | 87 ++++- extensions/bluebubbles/src/onboarding.ts | 334 ++++++++++++++++++++ extensions/bluebubbles/src/types.ts | 9 + src/channels/plugins/group-mentions.ts | 9 + src/channels/plugins/types.core.ts | 1 + src/cli/channels-cli.ts | 2 + src/commands/channels/add-mutators.ts | 2 + src/commands/channels/add.ts | 3 + src/config/group-policy.ts | 2 +- src/plugin-sdk/index.ts | 1 + 12 files changed, 459 insertions(+), 2 deletions(-) create mode 100644 extensions/bluebubbles/src/onboarding.ts diff --git a/extensions/bluebubbles/src/channel.ts b/extensions/bluebubbles/src/channel.ts index 9df199158..f9735d208 100644 --- a/extensions/bluebubbles/src/channel.ts +++ b/extensions/bluebubbles/src/channel.ts @@ -23,6 +23,7 @@ import { sendMessageBlueBubbles } from "./send.js"; import { normalizeBlueBubblesHandle } from "./targets.js"; import { bluebubblesMessageActions } from "./actions.js"; import { monitorBlueBubblesProvider, resolveWebhookPathFromConfig } from "./monitor.js"; +import { blueBubblesOnboardingAdapter } from "./onboarding.js"; const meta = { id: "bluebubbles", @@ -44,6 +45,7 @@ export const bluebubblesPlugin: ChannelPlugin = { }, reload: { configPrefixes: ["channels.bluebubbles"] }, configSchema: buildChannelConfigSchema(BlueBubblesConfigSchema), + onboarding: blueBubblesOnboardingAdapter, config: { listAccountIds: (cfg) => listBlueBubblesAccountIds(cfg as ClawdbotConfig), resolveAccount: (cfg, accountId) => @@ -152,6 +154,7 @@ export const bluebubblesPlugin: ChannelPlugin = { enabled: true, ...(input.httpUrl ? { serverUrl: input.httpUrl } : {}), ...(input.password ? { password: input.password } : {}), + ...(input.webhookPath ? { webhookPath: input.webhookPath } : {}), }, }, } as ClawdbotConfig; @@ -170,6 +173,7 @@ export const bluebubblesPlugin: ChannelPlugin = { enabled: true, ...(input.httpUrl ? { serverUrl: input.httpUrl } : {}), ...(input.password ? { password: input.password } : {}), + ...(input.webhookPath ? { webhookPath: input.webhookPath } : {}), }, }, }, diff --git a/extensions/bluebubbles/src/config-schema.ts b/extensions/bluebubbles/src/config-schema.ts index a5bf8f9e3..34a8def34 100644 --- a/extensions/bluebubbles/src/config-schema.ts +++ b/extensions/bluebubbles/src/config-schema.ts @@ -8,6 +8,10 @@ const bluebubblesActionSchema = z }) .optional(); +const bluebubblesGroupConfigSchema = z.object({ + requireMention: z.boolean().optional(), +}); + const bluebubblesAccountSchema = z.object({ name: z.string().optional(), enabled: z.boolean().optional(), @@ -22,6 +26,9 @@ const bluebubblesAccountSchema = z.object({ dmHistoryLimit: z.number().int().min(0).optional(), textChunkLimit: z.number().int().positive().optional(), mediaMaxMb: z.number().int().positive().optional(), + sendReadReceipts: z.boolean().optional(), + blockStreaming: z.boolean().optional(), + groups: z.object({}).catchall(bluebubblesGroupConfigSchema).optional(), }); export const BlueBubblesConfigSchema = bluebubblesAccountSchema.extend({ diff --git a/extensions/bluebubbles/src/monitor.ts b/extensions/bluebubbles/src/monitor.ts index 9625d91a8..e8e5df1c5 100644 --- a/extensions/bluebubbles/src/monitor.ts +++ b/extensions/bluebubbles/src/monitor.ts @@ -781,6 +781,79 @@ async function processMessage( }, }); + // Mention gating for group chats (parity with iMessage/WhatsApp) + const messageText = text; + const mentionRegexes = core.channel.mentions.buildMentionRegexes(config, route.agentId); + const wasMentioned = message.isGroup + ? core.channel.mentions.matchesMentionPatterns(messageText, mentionRegexes) + : true; + const canDetectMention = mentionRegexes.length > 0; + const requireMention = core.channel.groups.resolveRequireMention({ + cfg: config, + channel: "bluebubbles", + groupId: peerId, + accountId: account.accountId, + }); + + // Command gating (parity with iMessage/WhatsApp) + const useAccessGroups = config.commands?.useAccessGroups !== false; + const hasControlCmd = core.channel.text.hasControlCommand(messageText, config); + const ownerAllowedForCommands = + effectiveAllowFrom.length > 0 + ? isAllowedBlueBubblesSender({ + allowFrom: effectiveAllowFrom, + sender: message.senderId, + chatId: message.chatId ?? undefined, + chatGuid: message.chatGuid ?? undefined, + chatIdentifier: message.chatIdentifier ?? undefined, + }) + : false; + const groupAllowedForCommands = + effectiveGroupAllowFrom.length > 0 + ? isAllowedBlueBubblesSender({ + allowFrom: effectiveGroupAllowFrom, + sender: message.senderId, + chatId: message.chatId ?? undefined, + chatGuid: message.chatGuid ?? undefined, + chatIdentifier: message.chatIdentifier ?? undefined, + }) + : false; + const dmAuthorized = dmPolicy === "open" || ownerAllowedForCommands; + const commandAuthorized = message.isGroup + ? core.channel.commands.resolveCommandAuthorizedFromAuthorizers({ + useAccessGroups, + authorizers: [ + { configured: effectiveAllowFrom.length > 0, allowed: ownerAllowedForCommands }, + { configured: effectiveGroupAllowFrom.length > 0, allowed: groupAllowedForCommands }, + ], + }) + : dmAuthorized; + + // Block control commands from unauthorized senders in groups + if (message.isGroup && hasControlCmd && !commandAuthorized) { + logVerbose( + core, + runtime, + `bluebubbles: drop control command from unauthorized sender ${message.senderId}`, + ); + return; + } + + // Allow control commands to bypass mention gating when authorized (parity with iMessage) + const shouldBypassMention = + message.isGroup && + requireMention && + !wasMentioned && + commandAuthorized && + hasControlCmd; + const effectiveWasMentioned = wasMentioned || shouldBypassMention; + + // Skip group messages that require mention but weren't mentioned + if (message.isGroup && requireMention && canDetectMention && !wasMentioned && !shouldBypassMention) { + logVerbose(core, runtime, `bluebubbles: skipping group message (no mention)`); + return; + } + const baseUrl = account.config.serverUrl?.trim(); const password = account.config.password?.trim(); const maxBytes = @@ -870,7 +943,9 @@ async function processMessage( } } - if (chatGuidForActions && baseUrl && password) { + // Respect sendReadReceipts config (parity with WhatsApp) + const sendReadReceipts = account.config.sendReadReceipts !== false; + if (chatGuidForActions && baseUrl && password && sendReadReceipts) { try { await markBlueBubblesChatRead(chatGuidForActions, { cfg: config, @@ -880,6 +955,8 @@ async function processMessage( } catch (err) { runtime.error?.(`[bluebubbles] mark read failed: ${String(err)}`); } + } else if (!sendReadReceipts) { + logVerbose(core, runtime, "mark read skipped (sendReadReceipts=false)"); } else { logVerbose(core, runtime, "mark read skipped (missing chatGuid or credentials)"); } @@ -920,6 +997,8 @@ async function processMessage( Timestamp: message.timestamp, OriginatingChannel: "bluebubbles", OriginatingTo: `bluebubbles:${outboundTarget}`, + WasMentioned: effectiveWasMentioned, + CommandAuthorized: commandAuthorized, }; if (chatGuidForActions && baseUrl && password) { @@ -983,6 +1062,12 @@ async function processMessage( runtime.error?.(`BlueBubbles ${info.kind} reply failed: ${String(err)}`); }, }, + replyOptions: { + disableBlockStreaming: + typeof account.config.blockStreaming === "boolean" + ? !account.config.blockStreaming + : undefined, + }, }); } finally { if (chatGuidForActions && baseUrl && password) { diff --git a/extensions/bluebubbles/src/onboarding.ts b/extensions/bluebubbles/src/onboarding.ts new file mode 100644 index 000000000..cfaa17b25 --- /dev/null +++ b/extensions/bluebubbles/src/onboarding.ts @@ -0,0 +1,334 @@ +import type { ClawdbotConfig, DmPolicy, WizardPrompter } from "clawdbot/plugin-sdk"; +import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "clawdbot/plugin-sdk"; +import type { + ChannelOnboardingAdapter, + ChannelOnboardingDmPolicy, +} from "../../../src/channels/plugins/onboarding-types.js"; +import { addWildcardAllowFrom, promptAccountId } from "../../../src/channels/plugins/onboarding/helpers.js"; +import { formatDocsLink } from "../../../src/terminal/links.js"; +import { + listBlueBubblesAccountIds, + resolveBlueBubblesAccount, + resolveDefaultBlueBubblesAccountId, +} from "./accounts.js"; +import { normalizeBlueBubblesServerUrl } from "./types.js"; +import { parseBlueBubblesAllowTarget, normalizeBlueBubblesHandle } from "./targets.js"; + +const channel = "bluebubbles" as const; + +function setBlueBubblesDmPolicy(cfg: ClawdbotConfig, dmPolicy: DmPolicy): ClawdbotConfig { + const allowFrom = + dmPolicy === "open" ? addWildcardAllowFrom(cfg.channels?.bluebubbles?.allowFrom) : undefined; + return { + ...cfg, + channels: { + ...cfg.channels, + bluebubbles: { + ...cfg.channels?.bluebubbles, + dmPolicy, + ...(allowFrom ? { allowFrom } : {}), + }, + }, + }; +} + +function setBlueBubblesAllowFrom( + cfg: ClawdbotConfig, + accountId: string, + allowFrom: string[], +): ClawdbotConfig { + if (accountId === DEFAULT_ACCOUNT_ID) { + return { + ...cfg, + channels: { + ...cfg.channels, + bluebubbles: { + ...cfg.channels?.bluebubbles, + allowFrom, + }, + }, + }; + } + return { + ...cfg, + channels: { + ...cfg.channels, + bluebubbles: { + ...cfg.channels?.bluebubbles, + accounts: { + ...cfg.channels?.bluebubbles?.accounts, + [accountId]: { + ...cfg.channels?.bluebubbles?.accounts?.[accountId], + allowFrom, + }, + }, + }, + }, + }; +} + +function parseBlueBubblesAllowFromInput(raw: string): string[] { + return raw + .split(/[\n,]+/g) + .map((entry) => entry.trim()) + .filter(Boolean); +} + +async function promptBlueBubblesAllowFrom(params: { + cfg: ClawdbotConfig; + prompter: WizardPrompter; + accountId?: string; +}): Promise { + const accountId = + params.accountId && normalizeAccountId(params.accountId) + ? (normalizeAccountId(params.accountId) ?? DEFAULT_ACCOUNT_ID) + : resolveDefaultBlueBubblesAccountId(params.cfg); + const resolved = resolveBlueBubblesAccount({ cfg: params.cfg, accountId }); + const existing = resolved.config.allowFrom ?? []; + await params.prompter.note( + [ + "Allowlist BlueBubbles DMs by handle or chat target.", + "Examples:", + "- +15555550123", + "- user@example.com", + "- chat_id:123", + "- chat_guid:iMessage;-;+15555550123", + "Multiple entries: comma- or newline-separated.", + `Docs: ${formatDocsLink("/channels/bluebubbles", "bluebubbles")}`, + ].join("\n"), + "BlueBubbles allowlist", + ); + const entry = await params.prompter.text({ + message: "BlueBubbles allowFrom (handle or chat_id)", + placeholder: "+15555550123, user@example.com, chat_id:123", + initialValue: existing[0] ? String(existing[0]) : undefined, + validate: (value) => { + const raw = String(value ?? "").trim(); + if (!raw) return "Required"; + const parts = parseBlueBubblesAllowFromInput(raw); + for (const part of parts) { + if (part === "*") continue; + const parsed = parseBlueBubblesAllowTarget(part); + if (parsed.kind === "handle" && !parsed.handle) { + return `Invalid entry: ${part}`; + } + } + return undefined; + }, + }); + const parts = parseBlueBubblesAllowFromInput(String(entry)); + const unique = [...new Set(parts)]; + return setBlueBubblesAllowFrom(params.cfg, accountId, unique); +} + +const dmPolicy: ChannelOnboardingDmPolicy = { + label: "BlueBubbles", + channel, + policyKey: "channels.bluebubbles.dmPolicy", + allowFromKey: "channels.bluebubbles.allowFrom", + getCurrent: (cfg) => cfg.channels?.bluebubbles?.dmPolicy ?? "pairing", + setPolicy: (cfg, policy) => setBlueBubblesDmPolicy(cfg, policy), + promptAllowFrom: promptBlueBubblesAllowFrom, +}; + +export const blueBubblesOnboardingAdapter: ChannelOnboardingAdapter = { + channel, + getStatus: async ({ cfg }) => { + const configured = listBlueBubblesAccountIds(cfg).some((accountId) => { + const account = resolveBlueBubblesAccount({ cfg, accountId }); + return account.configured; + }); + return { + channel, + configured, + statusLines: [`BlueBubbles: ${configured ? "configured" : "needs setup"}`], + selectionHint: configured ? "configured" : "iMessage via BlueBubbles app", + quickstartScore: configured ? 1 : 0, + }; + }, + configure: async ({ cfg, prompter, accountOverrides, shouldPromptAccountIds }) => { + const blueBubblesOverride = accountOverrides.bluebubbles?.trim(); + const defaultAccountId = resolveDefaultBlueBubblesAccountId(cfg); + let accountId = blueBubblesOverride + ? normalizeAccountId(blueBubblesOverride) + : defaultAccountId; + if (shouldPromptAccountIds && !blueBubblesOverride) { + accountId = await promptAccountId({ + cfg, + prompter, + label: "BlueBubbles", + currentId: accountId, + listAccountIds: listBlueBubblesAccountIds, + defaultAccountId, + }); + } + + let next = cfg; + const resolvedAccount = resolveBlueBubblesAccount({ cfg: next, accountId }); + + // Prompt for server URL + let serverUrl = resolvedAccount.config.serverUrl?.trim(); + if (!serverUrl) { + await prompter.note( + [ + "Enter the BlueBubbles server URL (e.g., http://192.168.1.100:1234).", + "Find this in the BlueBubbles Server app under Connection.", + `Docs: ${formatDocsLink("/channels/bluebubbles", "bluebubbles")}`, + ].join("\n"), + "BlueBubbles server URL", + ); + const entered = await prompter.text({ + message: "BlueBubbles server URL", + placeholder: "http://192.168.1.100:1234", + validate: (value) => { + const trimmed = String(value ?? "").trim(); + if (!trimmed) return "Required"; + try { + const normalized = normalizeBlueBubblesServerUrl(trimmed); + new URL(normalized); + return undefined; + } catch { + return "Invalid URL format"; + } + }, + }); + serverUrl = String(entered).trim(); + } else { + const keepUrl = await prompter.confirm({ + message: `BlueBubbles server URL already set (${serverUrl}). Keep it?`, + initialValue: true, + }); + if (!keepUrl) { + const entered = await prompter.text({ + message: "BlueBubbles server URL", + placeholder: "http://192.168.1.100:1234", + initialValue: serverUrl, + validate: (value) => { + const trimmed = String(value ?? "").trim(); + if (!trimmed) return "Required"; + try { + const normalized = normalizeBlueBubblesServerUrl(trimmed); + new URL(normalized); + return undefined; + } catch { + return "Invalid URL format"; + } + }, + }); + serverUrl = String(entered).trim(); + } + } + + // Prompt for password + let password = resolvedAccount.config.password?.trim(); + if (!password) { + await prompter.note( + [ + "Enter the BlueBubbles server password.", + "Find this in the BlueBubbles Server app under Settings.", + ].join("\n"), + "BlueBubbles password", + ); + const entered = await prompter.text({ + message: "BlueBubbles password", + validate: (value) => (String(value ?? "").trim() ? undefined : "Required"), + }); + password = String(entered).trim(); + } else { + const keepPassword = await prompter.confirm({ + message: "BlueBubbles password already set. Keep it?", + initialValue: true, + }); + if (!keepPassword) { + const entered = await prompter.text({ + message: "BlueBubbles password", + validate: (value) => (String(value ?? "").trim() ? undefined : "Required"), + }); + password = String(entered).trim(); + } + } + + // Prompt for webhook path (optional) + const existingWebhookPath = resolvedAccount.config.webhookPath?.trim(); + const wantsWebhook = await prompter.confirm({ + message: "Configure a custom webhook path? (default: /bluebubbles-webhook)", + initialValue: Boolean(existingWebhookPath && existingWebhookPath !== "/bluebubbles-webhook"), + }); + let webhookPath = "/bluebubbles-webhook"; + if (wantsWebhook) { + const entered = await prompter.text({ + message: "Webhook path", + placeholder: "/bluebubbles-webhook", + initialValue: existingWebhookPath || "/bluebubbles-webhook", + validate: (value) => { + const trimmed = String(value ?? "").trim(); + if (!trimmed) return "Required"; + if (!trimmed.startsWith("/")) return "Path must start with /"; + return undefined; + }, + }); + webhookPath = String(entered).trim(); + } + + // Apply config + if (accountId === DEFAULT_ACCOUNT_ID) { + next = { + ...next, + channels: { + ...next.channels, + bluebubbles: { + ...next.channels?.bluebubbles, + enabled: true, + serverUrl, + password, + webhookPath, + }, + }, + }; + } else { + next = { + ...next, + channels: { + ...next.channels, + bluebubbles: { + ...next.channels?.bluebubbles, + enabled: true, + accounts: { + ...next.channels?.bluebubbles?.accounts, + [accountId]: { + ...next.channels?.bluebubbles?.accounts?.[accountId], + enabled: next.channels?.bluebubbles?.accounts?.[accountId]?.enabled ?? true, + serverUrl, + password, + webhookPath, + }, + }, + }, + }, + }; + } + + await prompter.note( + [ + "Configure the webhook URL in BlueBubbles Server:", + "1. Open BlueBubbles Server → Settings → Webhooks", + "2. Add your Clawdbot gateway URL + webhook path", + " Example: https://your-gateway-host:3000/bluebubbles-webhook", + "3. Enable the webhook and save", + "", + `Docs: ${formatDocsLink("/channels/bluebubbles", "bluebubbles")}`, + ].join("\n"), + "BlueBubbles next steps", + ); + + return { cfg: next, accountId }; + }, + dmPolicy, + disable: (cfg) => ({ + ...cfg, + channels: { + ...cfg.channels, + bluebubbles: { ...cfg.channels?.bluebubbles, enabled: false }, + }, + }), +}; diff --git a/extensions/bluebubbles/src/types.ts b/extensions/bluebubbles/src/types.ts index 59746e7a7..4425fa69d 100644 --- a/extensions/bluebubbles/src/types.ts +++ b/extensions/bluebubbles/src/types.ts @@ -1,6 +1,11 @@ export type DmPolicy = "pairing" | "allowlist" | "open" | "disabled"; export type GroupPolicy = "open" | "disabled" | "allowlist"; +export type BlueBubblesGroupConfig = { + /** If true, only respond in this group when mentioned. */ + requireMention?: boolean; +}; + export type BlueBubblesAccountConfig = { /** Optional display name for this account (used in CLI/UI lists). */ name?: string; @@ -36,6 +41,10 @@ export type BlueBubblesAccountConfig = { blockStreamingCoalesce?: Record; /** Max outbound media size in MB. */ mediaMaxMb?: number; + /** Send read receipts for incoming messages (default: true). */ + sendReadReceipts?: boolean; + /** Per-group configuration keyed by chat GUID or identifier. */ + groups?: Record; }; export type BlueBubblesActionConfig = { diff --git a/src/channels/plugins/group-mentions.ts b/src/channels/plugins/group-mentions.ts index ec38ca85d..79dfa0320 100644 --- a/src/channels/plugins/group-mentions.ts +++ b/src/channels/plugins/group-mentions.ts @@ -183,3 +183,12 @@ export function resolveSlackGroupRequireMention(params: GroupMentionParams): boo } return true; } + +export function resolveBlueBubblesGroupRequireMention(params: GroupMentionParams): boolean { + return resolveChannelGroupRequireMention({ + cfg: params.cfg, + channel: "bluebubbles", + groupId: params.groupId, + accountId: params.accountId, + }); +} diff --git a/src/channels/plugins/types.core.ts b/src/channels/plugins/types.core.ts index 66f58e576..c3b89bcd7 100644 --- a/src/channels/plugins/types.core.ts +++ b/src/channels/plugins/types.core.ts @@ -31,6 +31,7 @@ export type ChannelSetupInput = { httpUrl?: string; httpHost?: string; httpPort?: string; + webhookPath?: string; useEnv?: boolean; homeserver?: string; userId?: string; diff --git a/src/cli/channels-cli.ts b/src/cli/channels-cli.ts index 9380dde44..586e7c5c2 100644 --- a/src/cli/channels-cli.ts +++ b/src/cli/channels-cli.ts @@ -34,6 +34,7 @@ const optionNamesAdd = [ "httpUrl", "httpHost", "httpPort", + "webhookPath", "useEnv", "homeserver", "userId", @@ -163,6 +164,7 @@ export function registerChannelsCli(program: Command) { .option("--http-url ", "Signal HTTP daemon base URL") .option("--http-host ", "Signal HTTP host") .option("--http-port ", "Signal HTTP port") + .option("--webhook-path ", "BlueBubbles webhook path") .option("--homeserver ", "Matrix homeserver URL") .option("--user-id ", "Matrix user ID") .option("--access-token ", "Matrix access token") diff --git a/src/commands/channels/add-mutators.ts b/src/commands/channels/add-mutators.ts index 1c846d175..01d83b90b 100644 --- a/src/commands/channels/add-mutators.ts +++ b/src/commands/channels/add-mutators.ts @@ -35,6 +35,7 @@ export function applyChannelAccountConfig(params: { httpUrl?: string; httpHost?: string; httpPort?: string; + webhookPath?: string; useEnv?: boolean; homeserver?: string; userId?: string; @@ -62,6 +63,7 @@ export function applyChannelAccountConfig(params: { httpUrl: params.httpUrl, httpHost: params.httpHost, httpPort: params.httpPort, + webhookPath: params.webhookPath, useEnv: params.useEnv, homeserver: params.homeserver, userId: params.userId, diff --git a/src/commands/channels/add.ts b/src/commands/channels/add.ts index 1844105e1..46bedd50b 100644 --- a/src/commands/channels/add.ts +++ b/src/commands/channels/add.ts @@ -26,6 +26,7 @@ export type ChannelsAddOptions = { httpUrl?: string; httpHost?: string; httpPort?: string; + webhookPath?: string; useEnv?: boolean; homeserver?: string; userId?: string; @@ -139,6 +140,7 @@ export async function channelsAddCommand( httpUrl: opts.httpUrl, httpHost: opts.httpHost, httpPort: opts.httpPort, + webhookPath: opts.webhookPath, homeserver: opts.homeserver, userId: opts.userId, accessToken: opts.accessToken, @@ -172,6 +174,7 @@ export async function channelsAddCommand( httpUrl: opts.httpUrl, httpHost: opts.httpHost, httpPort: opts.httpPort, + webhookPath: opts.webhookPath, homeserver: opts.homeserver, userId: opts.userId, accessToken: opts.accessToken, diff --git a/src/config/group-policy.ts b/src/config/group-policy.ts index 8736dfb59..303a2c901 100644 --- a/src/config/group-policy.ts +++ b/src/config/group-policy.ts @@ -1,7 +1,7 @@ import { normalizeAccountId } from "../routing/session-key.js"; import type { ClawdbotConfig } from "./config.js"; -export type GroupPolicyChannel = "whatsapp" | "telegram" | "imessage"; +export type GroupPolicyChannel = "whatsapp" | "telegram" | "imessage" | "bluebubbles"; export type ChannelGroupConfig = { requireMention?: boolean; diff --git a/src/plugin-sdk/index.ts b/src/plugin-sdk/index.ts index c184db128..ab13cf448 100644 --- a/src/plugin-sdk/index.ts +++ b/src/plugin-sdk/index.ts @@ -107,6 +107,7 @@ export { resolveChannelMediaMaxBytes } from "../channels/plugins/media-limits.js export type { NormalizedLocation } from "../channels/location.js"; export { formatLocationText, toLocationContext } from "../channels/location.js"; export { + resolveBlueBubblesGroupRequireMention, resolveDiscordGroupRequireMention, resolveIMessageGroupRequireMention, resolveSlackGroupRequireMention,