import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/account-id" ;
import { createScopedDmSecurityResolver } from "openclaw/plugin-sdk/channel-config-helpers" ;
import { createChatChannelPlugin } from "openclaw/plugin-sdk/channel-core" ;
import { createAccountStatusSink } from "openclaw/plugin-sdk/channel-lifecycle" ;
import {
createOpenGroupPolicyRestrictSendersWarningCollector,
projectAccountWarningCollector,
} from "openclaw/plugin-sdk/channel-policy" ;
import { buildProbeChannelStatusSummary } from "openclaw/plugin-sdk/channel-status" ;
import { createLazyRuntimeNamedExport } from "openclaw/plugin-sdk/lazy-runtime" ;
import {
createComputedAccountStatusAdapter,
createDefaultChannelRuntimeState,
} from "openclaw/plugin-sdk/status-helpers" ;
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime" ;
import {
type ResolvedBlueBubblesAccount,
resolveBlueBubblesEffectiveAllowPrivateNetwork,
} from "./accounts.js" ;
import { bluebubblesMessageActions } from "./actions.js" ;
import {
bluebubblesCapabilities,
bluebubblesConfigAdapter,
bluebubblesConfigSchema,
bluebubblesReload,
describeBlueBubblesAccount,
bluebubblesMeta as meta,
} from "./channel-shared.js" ;
import type { BlueBubblesProbe } from "./channel.runtime.js" ;
import { createBlueBubblesConversationBindingManager } from "./conversation-bindings.js" ;
import {
matchBlueBubblesAcpConversation,
normalizeBlueBubblesAcpConversationId,
resolveBlueBubblesConversationIdFromTarget,
} from "./conversation-id.js" ;
import { bluebubblesDoctor } from "./doctor.js" ;
import {
resolveBlueBubblesGroupRequireMention,
resolveBlueBubblesGroupToolPolicy,
} from "./group-policy.js" ;
import { createBlueBubblesPairingText } from "./pairing.js" ;
import type { ChannelAccountSnapshot, ChannelPlugin } from "./runtime-api.js" ;
import { collectRuntimeConfigAssignments, secretTargetRegistryEntries } from "./secret-contract.js" ;
import { resolveBlueBubblesOutboundSessionRoute } from "./session-route.js" ;
import { blueBubblesSetupAdapter } from "./setup-core.js" ;
import { blueBubblesSetupWizard } from "./setup-surface.js" ;
import { collectBlueBubblesStatusIssues } from "./status-issues.js" ;
import {
extractHandleFromChatGuid,
inferBlueBubblesTargetChatType,
looksLikeBlueBubblesExplicitTargetId,
looksLikeBlueBubblesTargetId,
normalizeBlueBubblesHandle,
normalizeBlueBubblesMessagingTarget,
parseBlueBubblesTarget,
} from "./targets.js" ;
const loadBlueBubblesChannelRuntime = createLazyRuntimeNamedExport(
() => import ("./channel.runtime.js" ),
"blueBubblesChannelRuntime" ,
);
const resolveBlueBubblesDmPolicy = createScopedDmSecurityResolver<ResolvedBlueBubblesAccount>({
channelKey: "bluebubbles" ,
resolvePolicy: (account) => account.config.dmPolicy,
resolveAllowFrom: (account) => account.config.allowFrom,
policyPathSuffix: "dmPolicy" ,
normalizeEntry: (raw) => normalizeBlueBubblesHandle(raw.replace(/^bluebubbles:/i, "" )),
});
const collectBlueBubblesSecurityWarnings =
createOpenGroupPolicyRestrictSendersWarningCollector<ResolvedBlueBubblesAccount>({
resolveGroupPolicy: (account) => account.config.groupPolicy,
defaultGroupPolicy: "allowlist" ,
surface: "BlueBubbles groups" ,
openScope: "any member" ,
groupPolicyPath: "channels.bluebubbles.groupPolicy" ,
groupAllowFromPath: "channels.bluebubbles.groupAllowFrom" ,
mentionGated: false ,
});
export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount, BlueBubblesProbe> =
createChatChannelPlugin<ResolvedBlueBubblesAccount, BlueBubblesProbe>({
base: {
id: "bluebubbles" ,
meta,
capabilities: bluebubblesCapabilities,
groups: {
resolveRequireMention: resolveBlueBubblesGroupRequireMention,
resolveToolPolicy: resolveBlueBubblesGroupToolPolicy,
},
reload: bluebubblesReload,
configSchema: bluebubblesConfigSchema,
setupWizard: blueBubblesSetupWizard,
config: {
...bluebubblesConfigAdapter,
isConfigured: (account) => account.configured,
describeAccount: (account): ChannelAccountSnapshot => describeBlueBubblesAccount(account),
},
doctor: bluebubblesDoctor,
conversationBindings: {
supportsCurrentConversationBinding: true ,
createManager: ({ cfg, accountId }) =>
createBlueBubblesConversationBindingManager({
cfg,
accountId: accountId ?? undefined,
}),
},
actions: bluebubblesMessageActions,
secrets: {
secretTargetRegistryEntries,
collectRuntimeConfigAssignments,
},
bindings: {
compileConfiguredBinding: ({ conversationId }) =>
normalizeBlueBubblesAcpConversationId(conversationId),
matchInboundConversation: ({ compiledBinding, conversationId }) =>
matchBlueBubblesAcpConversation({
bindingConversationId: compiledBinding.conversationId,
conversationId,
}),
resolveCommandConversation: ({ originatingTo, commandTo, fallbackTo }) => {
const conversationId =
resolveBlueBubblesConversationIdFromTarget(originatingTo ?? "" ) ??
resolveBlueBubblesConversationIdFromTarget(commandTo ?? "" ) ??
resolveBlueBubblesConversationIdFromTarget(fallbackTo ?? "" );
return conversationId ? { conversationId } : null ;
},
},
messaging: {
normalizeTarget: normalizeBlueBubblesMessagingTarget,
inferTargetChatType: ({ to }) => inferBlueBubblesTargetChatType(to),
resolveOutboundSessionRoute: (params) => resolveBlueBubblesOutboundSessionRoute(params),
targetResolver: {
looksLikeId: looksLikeBlueBubblesExplicitTargetId,
hint: "<handle|chat_guid:GUID|chat_id:ID|chat_identifier:ID>" ,
resolveTarget: async ({ normalized }) => {
const to = normalizeOptionalString(normalized);
if (!to) {
return null ;
}
const chatType = inferBlueBubblesTargetChatType(to);
if (!chatType) {
return null ;
}
return {
to,
kind: chatType === "direct" ? "user" : "group" ,
source: "normalized" as const ,
};
},
},
formatTargetDisplay: ({ target, display }) => {
const shouldParseDisplay = (value: string): boolean => {
if (looksLikeBlueBubblesTargetId(value)) {
return true ;
}
return /^(bluebubbles:|chat_guid:|chat_id:|chat_identifier:)/i.test(value);
};
// Helper to extract a clean handle from any BlueBubbles target format
const extractCleanDisplay = (value: string | undefined): string | null => {
const trimmed = normalizeOptionalString(value);
if (!trimmed) {
return null ;
}
try {
const parsed = parseBlueBubblesTarget(trimmed);
if (parsed.kind === "chat_guid" ) {
const handle = extractHandleFromChatGuid(parsed.chatGuid);
if (handle) {
return handle;
}
}
if (parsed.kind === "handle" ) {
return normalizeBlueBubblesHandle(parsed.to);
}
} catch {
// Fall through
}
// Strip common prefixes and try raw extraction
const stripped = trimmed
.replace(/^bluebubbles:/i, "" )
.replace(/^chat_guid:/i, "" )
.replace(/^chat_id:/i, "" )
.replace(/^chat_identifier:/i, "" );
const handle = extractHandleFromChatGuid(stripped);
if (handle) {
return handle;
}
// Don't return raw chat_guid formats - they contain internal routing info
if (stripped.includes(";-;" ) || stripped.includes(";+;" )) {
return null ;
}
return stripped;
};
// Try to get a clean display from the display parameter first
const trimmedDisplay = normalizeOptionalString(display);
if (trimmedDisplay) {
if (!shouldParseDisplay(trimmedDisplay)) {
return trimmedDisplay;
}
const cleanDisplay = extractCleanDisplay(trimmedDisplay);
if (cleanDisplay) {
return cleanDisplay;
}
}
// Fall back to extracting from target
const cleanTarget = extractCleanDisplay(target);
if (cleanTarget) {
return cleanTarget;
}
// Last resort: return display or target as-is
return normalizeOptionalString(display) || normalizeOptionalString(target) || "" ;
},
},
setup: blueBubblesSetupAdapter,
status: createComputedAccountStatusAdapter<ResolvedBlueBubblesAccount, BlueBubblesProbe>({
defaultRuntime: createDefaultChannelRuntimeState(DEFAULT_ACCOUNT_ID),
collectStatusIssues: collectBlueBubblesStatusIssues,
buildChannelSummary: ({ snapshot }) =>
buildProbeChannelStatusSummary(snapshot, { baseUrl: snapshot.baseUrl ?? null }),
probeAccount: async ({ account, timeoutMs }) =>
(await loadBlueBubblesChannelRuntime()).probeBlueBubbles({
baseUrl: account.baseUrl,
password: account.config.password ?? null ,
timeoutMs,
allowPrivateNetwork: resolveBlueBubblesEffectiveAllowPrivateNetwork({
baseUrl: account.baseUrl,
config: account.config,
}),
}),
resolveAccountSnapshot: ({ account, runtime, probe }) => {
const running = runtime?.running ?? false ;
const probeOk = probe?.ok;
return {
accountId: account.accountId,
name: account.name,
enabled: account.enabled,
configured: account.configured,
extra: {
baseUrl: account.baseUrl,
connected: probeOk ?? running,
},
};
},
}),
gateway: {
startAccount: async (ctx) => {
const runtime = await loadBlueBubblesChannelRuntime();
const account = ctx.account;
const conversationBindings = createBlueBubblesConversationBindingManager({
cfg: ctx.cfg,
accountId: ctx.accountId,
});
const webhookPath = runtime.resolveWebhookPathFromConfig(account.config);
const statusSink = createAccountStatusSink({
accountId: ctx.accountId,
setStatus: ctx.setStatus,
});
statusSink({
baseUrl: account.baseUrl,
});
ctx.log?.info(`[${account.accountId}] starting provider (webhook=${webhookPath})`);
try {
return await runtime.monitorBlueBubblesProvider({
account,
config: ctx.cfg,
runtime: ctx.runtime,
abortSignal: ctx.abortSignal,
statusSink,
webhookPath,
});
} finally {
conversationBindings.stop();
}
},
},
},
security: {
resolveDmPolicy: resolveBlueBubblesDmPolicy,
collectWarnings: projectAccountWarningCollector<
ResolvedBlueBubblesAccount,
{ account: ResolvedBlueBubblesAccount }
>(collectBlueBubblesSecurityWarnings),
},
threading: {
buildToolContext: ({ context, hasRepliedRef }) => ({
currentChannelId: normalizeOptionalString(context.To),
currentThreadTs: context.ReplyToIdFull ?? context.ReplyToId,
hasRepliedRef,
}),
},
pairing: {
text: createBlueBubblesPairingText(async (id, message, params) => {
await (await loadBlueBubblesChannelRuntime()).sendMessageBlueBubbles(id, message, params);
}),
},
outbound: {
base: {
deliveryMode: "direct" ,
textChunkLimit: 4000 ,
resolveTarget: ({ to }) => {
const trimmed = normalizeOptionalString(to);
if (!trimmed) {
return {
ok: false ,
error: new Error("Delivering to BlueBubbles requires --to <handle|chat_guid:GUID>" ),
};
}
return { ok: true , to: trimmed };
},
},
attachedResults: {
channel: "bluebubbles" ,
sendText: async ({ cfg, to, text, accountId, replyToId }) => {
const runtime = await loadBlueBubblesChannelRuntime();
const rawReplyToId = normalizeOptionalString(replyToId) ?? "" ;
const replyToMessageGuid = rawReplyToId
? runtime.resolveBlueBubblesMessageId(rawReplyToId, { requireKnownShortId: true })
: "" ;
return await runtime.sendMessageBlueBubbles(to, text, {
cfg: cfg,
accountId: accountId ?? undefined,
replyToMessageGuid: replyToMessageGuid || undefined,
});
},
sendMedia: async (ctx) => {
const runtime = await loadBlueBubblesChannelRuntime();
const { cfg, to, text, mediaUrl, accountId, replyToId } = ctx;
const { mediaPath, mediaBuffer, contentType, filename, caption } = ctx as {
mediaPath?: string;
mediaBuffer?: Uint8Array;
contentType?: string;
filename?: string;
caption?: string;
};
return await runtime.sendBlueBubblesMedia({
cfg: cfg,
to,
mediaUrl,
mediaPath,
mediaBuffer,
contentType,
filename,
caption: caption ?? text ?? undefined,
replyToId: replyToId ?? null ,
accountId: accountId ?? undefined,
});
},
},
},
});
Messung V0.5 in Prozent C=99 H=100 G=99
¤ Dauer der Verarbeitung: 0.12 Sekunden
(vorverarbeitet am 2026-06-06)
¤
*© Formatika GbR, Deutschland