Files
moltbot/extensions/bluebubbles/src/send.ts
Josh Avant 806803b7ef feat(secrets): expand SecretRef coverage across user-supplied credentials (#29580)
* feat(secrets): expand secret target coverage and gateway tooling

* docs(secrets): align gateway and CLI secret docs

* chore(protocol): regenerate swift gateway models for secrets methods

* fix(config): restore talk apiKey fallback and stabilize runner test

* ci(windows): reduce test worker count for shard stability

* ci(windows): raise node heap for test shard stability

* test(feishu): make proxy env precedence assertion windows-safe

* fix(gateway): resolve auth password SecretInput refs for clients

* fix(gateway): resolve remote SecretInput credentials for clients

* fix(secrets): skip inactive refs in command snapshot assignments

* fix(secrets): scope gateway.remote refs to effective auth surfaces

* fix(secrets): ignore memory defaults when enabled agents disable search

* fix(secrets): honor Google Chat serviceAccountRef inheritance

* fix(secrets): address tsgo errors in command and gateway collectors

* fix(secrets): avoid auth-store load in providers-only configure

* fix(gateway): defer local password ref resolution by precedence

* fix(secrets): gate telegram webhook secret refs by webhook mode

* fix(secrets): gate slack signing secret refs to http mode

* fix(secrets): skip telegram botToken refs when tokenFile is set

* fix(secrets): gate discord pluralkit refs by enabled flag

* fix(secrets): gate discord voice tts refs by voice enabled

* test(secrets): make runtime fixture modes explicit

* fix(cli): resolve local qr password secret refs

* fix(cli): fail when gateway leaves command refs unresolved

* fix(gateway): fail when local password SecretRef is unresolved

* fix(gateway): fail when required remote SecretRefs are unresolved

* fix(gateway): resolve local password refs only when password can win

* fix(cli): skip local password SecretRef resolution on qr token override

* test(gateway): cast SecretRef fixtures to OpenClawConfig

* test(secrets): activate mode-gated targets in runtime coverage fixture

* fix(cron): support SecretInput webhook tokens safely

* fix(bluebubbles): support SecretInput passwords across config paths

* fix(msteams): make appPassword SecretInput-safe in onboarding/token paths

* fix(bluebubbles): align SecretInput schema helper typing

* fix(cli): clarify secrets.resolve version-skew errors

* refactor(secrets): return structured inactive paths from secrets.resolve

* refactor(gateway): type onboarding secret writes as SecretInput

* chore(protocol): regenerate swift models for secrets.resolve

* feat(secrets): expand extension credential secretref support

* fix(secrets): gate web-search refs by active provider

* fix(onboarding): detect SecretRef credentials in extension status

* fix(onboarding): allow keeping existing ref in secret prompt

* fix(onboarding): resolve gateway password SecretRefs for probe and tui

* fix(onboarding): honor secret-input-mode for local gateway auth

* fix(acp): resolve gateway SecretInput credentials

* fix(secrets): gate gateway.remote refs to remote surfaces

* test(secrets): cover pattern matching and inactive array refs

* docs(secrets): clarify secrets.resolve and remote active surfaces

* fix(bluebubbles): keep existing SecretRef during onboarding

* fix(tests): resolve CI type errors in new SecretRef coverage

* fix(extensions): replace raw fetch with SSRF-guarded fetch

* test(secrets): mark gateway remote targets active in runtime coverage

* test(infra): normalize home-prefix expectation across platforms

* fix(cli): only resolve local qr password refs in password mode

* test(cli): cover local qr token mode with unresolved password ref

* docs(cli): clarify local qr password ref resolution behavior

* refactor(extensions): reuse sdk SecretInput helpers

* fix(wizard): resolve onboarding env-template secrets before plaintext

* fix(cli): surface secrets.resolve diagnostics in memory and qr

* test(secrets): repair post-rebase runtime and fixtures

* fix(gateway): skip remote password ref resolution when token wins

* fix(secrets): treat tailscale remote gateway refs as active

* fix(gateway): allow remote password fallback when token ref is unresolved

* fix(gateway): ignore stale local password refs for none and trusted-proxy

* fix(gateway): skip remote secret ref resolution on local call paths

* test(cli): cover qr remote tailscale secret ref resolution

* fix(secrets): align gateway password active-surface with auth inference

* fix(cli): resolve inferred local gateway password refs in qr

* fix(gateway): prefer resolvable remote password over token ref pre-resolution

* test(gateway): cover none and trusted-proxy stale password refs

* docs(secrets): sync qr and gateway active-surface behavior

* fix: restore stability blockers from pre-release audit

* Secrets: fix collector/runtime precedence contradictions

* docs: align secrets and web credential docs

* fix(rebase): resolve integration regressions after main rebase

* fix(node-host): resolve gateway secret refs for auth

* fix(secrets): harden secretinput runtime readers

* gateway: skip inactive auth secretref resolution

* cli: avoid gateway preflight for inactive secret refs

* extensions: allow unresolved refs in onboarding status

* tests: fix qr-cli module mock hoist ordering

* Security: align audit checks with SecretInput resolution

* Gateway: resolve local-mode remote fallback secret refs

* Node host: avoid resolving inactive password secret refs

* Secrets runtime: mark Slack appToken inactive for HTTP mode

* secrets: keep inactive gateway remote refs non-blocking

* cli: include agent memory secret targets in runtime resolution

* docs(secrets): sync docs with active-surface and web search behavior

* fix(secrets): keep telegram top-level token refs active for blank account tokens

* fix(daemon): resolve gateway password secret refs for probe auth

* fix(secrets): skip IRC NickServ ref resolution when NickServ is disabled

* fix(secrets): align token inheritance and exec timeout defaults

* docs(secrets): clarify active-surface notes in cli docs

* cli: require secrets.resolve gateway capability

* gateway: log auth secret surface diagnostics

* secrets: remove dead provider resolver module

* fix(secrets): restore gateway auth precedence and fallback resolution

* fix(tests): align plugin runtime mock typings

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-03-03 02:58:20 +00:00

478 lines
15 KiB
TypeScript

import crypto from "node:crypto";
import type { OpenClawConfig } from "openclaw/plugin-sdk";
import { stripMarkdown } from "openclaw/plugin-sdk";
import { resolveBlueBubblesAccount } from "./accounts.js";
import {
getCachedBlueBubblesPrivateApiStatus,
isBlueBubblesPrivateApiStatusEnabled,
} from "./probe.js";
import { warnBlueBubbles } from "./runtime.js";
import { normalizeSecretInputString } from "./secret-input.js";
import { extractBlueBubblesMessageId, resolveBlueBubblesSendTarget } from "./send-helpers.js";
import { extractHandleFromChatGuid, normalizeBlueBubblesHandle } from "./targets.js";
import {
blueBubblesFetchWithTimeout,
buildBlueBubblesApiUrl,
type BlueBubblesSendTarget,
} from "./types.js";
export type BlueBubblesSendOpts = {
serverUrl?: string;
password?: string;
accountId?: string;
timeoutMs?: number;
cfg?: OpenClawConfig;
/** Message GUID to reply to (reply threading) */
replyToMessageGuid?: string;
/** Part index for reply (default: 0) */
replyToPartIndex?: number;
/** Effect ID or short name for message effects (e.g., "slam", "balloons") */
effectId?: string;
};
export type BlueBubblesSendResult = {
messageId: string;
};
/** Maps short effect names to full Apple effect IDs */
const EFFECT_MAP: Record<string, string> = {
// Bubble effects
slam: "com.apple.MobileSMS.expressivesend.impact",
loud: "com.apple.MobileSMS.expressivesend.loud",
gentle: "com.apple.MobileSMS.expressivesend.gentle",
invisible: "com.apple.MobileSMS.expressivesend.invisibleink",
"invisible-ink": "com.apple.MobileSMS.expressivesend.invisibleink",
"invisible ink": "com.apple.MobileSMS.expressivesend.invisibleink",
invisibleink: "com.apple.MobileSMS.expressivesend.invisibleink",
// Screen effects
echo: "com.apple.messages.effect.CKEchoEffect",
spotlight: "com.apple.messages.effect.CKSpotlightEffect",
balloons: "com.apple.messages.effect.CKHappyBirthdayEffect",
confetti: "com.apple.messages.effect.CKConfettiEffect",
love: "com.apple.messages.effect.CKHeartEffect",
heart: "com.apple.messages.effect.CKHeartEffect",
hearts: "com.apple.messages.effect.CKHeartEffect",
lasers: "com.apple.messages.effect.CKLasersEffect",
fireworks: "com.apple.messages.effect.CKFireworksEffect",
celebration: "com.apple.messages.effect.CKSparklesEffect",
};
function resolveEffectId(raw?: string): string | undefined {
if (!raw) {
return undefined;
}
const trimmed = raw.trim().toLowerCase();
if (EFFECT_MAP[trimmed]) {
return EFFECT_MAP[trimmed];
}
const normalized = trimmed.replace(/[\s_]+/g, "-");
if (EFFECT_MAP[normalized]) {
return EFFECT_MAP[normalized];
}
const compact = trimmed.replace(/[\s_-]+/g, "");
if (EFFECT_MAP[compact]) {
return EFFECT_MAP[compact];
}
return raw;
}
type PrivateApiDecision = {
canUsePrivateApi: boolean;
throwEffectDisabledError: boolean;
warningMessage?: string;
};
function resolvePrivateApiDecision(params: {
privateApiStatus: boolean | null;
wantsReplyThread: boolean;
wantsEffect: boolean;
}): PrivateApiDecision {
const { privateApiStatus, wantsReplyThread, wantsEffect } = params;
const needsPrivateApi = wantsReplyThread || wantsEffect;
const canUsePrivateApi =
needsPrivateApi && isBlueBubblesPrivateApiStatusEnabled(privateApiStatus);
const throwEffectDisabledError = wantsEffect && privateApiStatus === false;
if (!needsPrivateApi || privateApiStatus !== null) {
return { canUsePrivateApi, throwEffectDisabledError };
}
const requested = [
wantsReplyThread ? "reply threading" : null,
wantsEffect ? "message effects" : null,
]
.filter(Boolean)
.join(" + ");
return {
canUsePrivateApi,
throwEffectDisabledError,
warningMessage: `Private API status unknown; sending without ${requested}. Run a status probe to restore private-api features.`,
};
}
type BlueBubblesChatRecord = Record<string, unknown>;
function extractChatGuid(chat: BlueBubblesChatRecord): string | null {
const candidates = [
chat.chatGuid,
chat.guid,
chat.chat_guid,
chat.identifier,
chat.chatIdentifier,
chat.chat_identifier,
];
for (const candidate of candidates) {
if (typeof candidate === "string" && candidate.trim()) {
return candidate.trim();
}
}
return null;
}
function extractChatId(chat: BlueBubblesChatRecord): number | null {
const candidates = [chat.chatId, chat.id, chat.chat_id];
for (const candidate of candidates) {
if (typeof candidate === "number" && Number.isFinite(candidate)) {
return candidate;
}
}
return null;
}
function extractChatIdentifierFromChatGuid(chatGuid: string): string | null {
const parts = chatGuid.split(";");
if (parts.length < 3) {
return null;
}
const identifier = parts[2]?.trim();
return identifier ? identifier : null;
}
function extractParticipantAddresses(chat: BlueBubblesChatRecord): string[] {
const raw =
(Array.isArray(chat.participants) ? chat.participants : null) ??
(Array.isArray(chat.handles) ? chat.handles : null) ??
(Array.isArray(chat.participantHandles) ? chat.participantHandles : null);
if (!raw) {
return [];
}
const out: string[] = [];
for (const entry of raw) {
if (typeof entry === "string") {
out.push(entry);
continue;
}
if (entry && typeof entry === "object") {
const record = entry as Record<string, unknown>;
const candidate =
(typeof record.address === "string" && record.address) ||
(typeof record.handle === "string" && record.handle) ||
(typeof record.id === "string" && record.id) ||
(typeof record.identifier === "string" && record.identifier);
if (candidate) {
out.push(candidate);
}
}
}
return out;
}
async function queryChats(params: {
baseUrl: string;
password: string;
timeoutMs?: number;
offset: number;
limit: number;
}): Promise<BlueBubblesChatRecord[]> {
const url = buildBlueBubblesApiUrl({
baseUrl: params.baseUrl,
path: "/api/v1/chat/query",
password: params.password,
});
const res = await blueBubblesFetchWithTimeout(
url,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
limit: params.limit,
offset: params.offset,
with: ["participants"],
}),
},
params.timeoutMs,
);
if (!res.ok) {
return [];
}
const payload = (await res.json().catch(() => null)) as Record<string, unknown> | null;
const data = payload && typeof payload.data !== "undefined" ? (payload.data as unknown) : null;
return Array.isArray(data) ? (data as BlueBubblesChatRecord[]) : [];
}
export async function resolveChatGuidForTarget(params: {
baseUrl: string;
password: string;
timeoutMs?: number;
target: BlueBubblesSendTarget;
}): Promise<string | null> {
if (params.target.kind === "chat_guid") {
return params.target.chatGuid;
}
const normalizedHandle =
params.target.kind === "handle" ? normalizeBlueBubblesHandle(params.target.address) : "";
const targetChatId = params.target.kind === "chat_id" ? params.target.chatId : null;
const targetChatIdentifier =
params.target.kind === "chat_identifier" ? params.target.chatIdentifier : null;
const limit = 500;
let participantMatch: string | null = null;
for (let offset = 0; offset < 5000; offset += limit) {
const chats = await queryChats({
baseUrl: params.baseUrl,
password: params.password,
timeoutMs: params.timeoutMs,
offset,
limit,
});
if (chats.length === 0) {
break;
}
for (const chat of chats) {
if (targetChatId != null) {
const chatId = extractChatId(chat);
if (chatId != null && chatId === targetChatId) {
return extractChatGuid(chat);
}
}
if (targetChatIdentifier) {
const guid = extractChatGuid(chat);
if (guid) {
// Back-compat: some callers might pass a full chat GUID.
if (guid === targetChatIdentifier) {
return guid;
}
// Primary match: BlueBubbles `chat_identifier:*` targets correspond to the
// third component of the chat GUID: `service;(+|-) ;identifier`.
const guidIdentifier = extractChatIdentifierFromChatGuid(guid);
if (guidIdentifier && guidIdentifier === targetChatIdentifier) {
return guid;
}
}
const identifier =
typeof chat.identifier === "string"
? chat.identifier
: typeof chat.chatIdentifier === "string"
? chat.chatIdentifier
: typeof chat.chat_identifier === "string"
? chat.chat_identifier
: "";
if (identifier && identifier === targetChatIdentifier) {
return guid ?? extractChatGuid(chat);
}
}
if (normalizedHandle) {
const guid = extractChatGuid(chat);
const directHandle = guid ? extractHandleFromChatGuid(guid) : null;
if (directHandle && directHandle === normalizedHandle) {
return guid;
}
if (!participantMatch && guid) {
// Only consider DM chats (`;-;` separator) as participant matches.
// Group chats (`;+;` separator) should never match when searching by handle/phone.
// This prevents routing "send to +1234567890" to a group chat that contains that number.
const isDmChat = guid.includes(";-;");
if (isDmChat) {
const participants = extractParticipantAddresses(chat).map((entry) =>
normalizeBlueBubblesHandle(entry),
);
if (participants.includes(normalizedHandle)) {
participantMatch = guid;
}
}
}
}
}
}
return participantMatch;
}
/**
* Creates a new chat (DM) and optionally sends an initial message.
* Requires Private API to be enabled in BlueBubbles.
*/
async function createNewChatWithMessage(params: {
baseUrl: string;
password: string;
address: string;
message: string;
timeoutMs?: number;
}): Promise<BlueBubblesSendResult> {
const url = buildBlueBubblesApiUrl({
baseUrl: params.baseUrl,
path: "/api/v1/chat/new",
password: params.password,
});
const payload = {
addresses: [params.address],
message: params.message,
tempGuid: `temp-${crypto.randomUUID()}`,
};
const res = await blueBubblesFetchWithTimeout(
url,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
},
params.timeoutMs,
);
if (!res.ok) {
const errorText = await res.text();
// Check for Private API not enabled error
if (
res.status === 400 ||
res.status === 403 ||
errorText.toLowerCase().includes("private api")
) {
throw new Error(
`BlueBubbles send failed: Cannot create new chat - Private API must be enabled. Original error: ${errorText || res.status}`,
);
}
throw new Error(`BlueBubbles create chat failed (${res.status}): ${errorText || "unknown"}`);
}
const body = await res.text();
if (!body) {
return { messageId: "ok" };
}
try {
const parsed = JSON.parse(body) as unknown;
return { messageId: extractBlueBubblesMessageId(parsed) };
} catch {
return { messageId: "ok" };
}
}
export async function sendMessageBlueBubbles(
to: string,
text: string,
opts: BlueBubblesSendOpts = {},
): Promise<BlueBubblesSendResult> {
const trimmedText = text ?? "";
if (!trimmedText.trim()) {
throw new Error("BlueBubbles send requires text");
}
// Strip markdown early and validate - ensures messages like "***" or "---" don't become empty
const strippedText = stripMarkdown(trimmedText);
if (!strippedText.trim()) {
throw new Error("BlueBubbles send requires text (message was empty after markdown removal)");
}
const account = resolveBlueBubblesAccount({
cfg: opts.cfg ?? {},
accountId: opts.accountId,
});
const baseUrl =
normalizeSecretInputString(opts.serverUrl) ||
normalizeSecretInputString(account.config.serverUrl);
const password =
normalizeSecretInputString(opts.password) ||
normalizeSecretInputString(account.config.password);
if (!baseUrl) {
throw new Error("BlueBubbles serverUrl is required");
}
if (!password) {
throw new Error("BlueBubbles password is required");
}
const privateApiStatus = getCachedBlueBubblesPrivateApiStatus(account.accountId);
const target = resolveBlueBubblesSendTarget(to);
const chatGuid = await resolveChatGuidForTarget({
baseUrl,
password,
timeoutMs: opts.timeoutMs,
target,
});
if (!chatGuid) {
// If target is a phone number/handle and no existing chat found,
// auto-create a new DM chat using the /api/v1/chat/new endpoint
if (target.kind === "handle") {
return createNewChatWithMessage({
baseUrl,
password,
address: target.address,
message: strippedText,
timeoutMs: opts.timeoutMs,
});
}
throw new Error(
"BlueBubbles send failed: chatGuid not found for target. Use a chat_guid target or ensure the chat exists.",
);
}
const effectId = resolveEffectId(opts.effectId);
const wantsReplyThread = Boolean(opts.replyToMessageGuid?.trim());
const wantsEffect = Boolean(effectId);
const privateApiDecision = resolvePrivateApiDecision({
privateApiStatus,
wantsReplyThread,
wantsEffect,
});
if (privateApiDecision.throwEffectDisabledError) {
throw new Error(
"BlueBubbles send failed: reply/effect requires Private API, but it is disabled on the BlueBubbles server.",
);
}
if (privateApiDecision.warningMessage) {
warnBlueBubbles(privateApiDecision.warningMessage);
}
const payload: Record<string, unknown> = {
chatGuid,
tempGuid: crypto.randomUUID(),
message: strippedText,
};
if (privateApiDecision.canUsePrivateApi) {
payload.method = "private-api";
}
// Add reply threading support
if (wantsReplyThread && privateApiDecision.canUsePrivateApi) {
payload.selectedMessageGuid = opts.replyToMessageGuid;
payload.partIndex = typeof opts.replyToPartIndex === "number" ? opts.replyToPartIndex : 0;
}
// Add message effects support
if (effectId && privateApiDecision.canUsePrivateApi) {
payload.effectId = effectId;
}
const url = buildBlueBubblesApiUrl({
baseUrl,
path: "/api/v1/message/text",
password,
});
const res = await blueBubblesFetchWithTimeout(
url,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
},
opts.timeoutMs,
);
if (!res.ok) {
const errorText = await res.text();
throw new Error(`BlueBubbles send failed (${res.status}): ${errorText || "unknown"}`);
}
const body = await res.text();
if (!body) {
return { messageId: "ok" };
}
try {
const parsed = JSON.parse(body) as unknown;
return { messageId: extractBlueBubblesMessageId(parsed) };
} catch {
return { messageId: "ok" };
}
}