Files
openclaw/extensions/bluebubbles/src/channel.ts
2026-03-28 02:53:54 +00:00

356 lines
13 KiB
TypeScript

import { createScopedDmSecurityResolver } from "openclaw/plugin-sdk/channel-config-helpers";
import { createAccountStatusSink } from "openclaw/plugin-sdk/channel-lifecycle";
import { createPairingPrefixStripper } from "openclaw/plugin-sdk/channel-pairing";
import {
createOpenGroupPolicyRestrictSendersWarningCollector,
projectAccountWarningCollector,
} from "openclaw/plugin-sdk/channel-policy";
import { createAttachedChannelResultAdapter } from "openclaw/plugin-sdk/channel-send-result";
import { createChatChannelPlugin } from "openclaw/plugin-sdk/core";
import { createLazyRuntimeNamedExport } from "openclaw/plugin-sdk/lazy-runtime";
import {
createComputedAccountStatusAdapter,
createDefaultChannelRuntimeState,
} from "openclaw/plugin-sdk/status-helpers";
import {
listBlueBubblesAccountIds,
type ResolvedBlueBubblesAccount,
resolveBlueBubblesAccount,
resolveDefaultBlueBubblesAccountId,
} from "./accounts.js";
import { bluebubblesMessageActions } from "./actions.js";
import {
bluebubblesCapabilities,
bluebubblesConfigAdapter,
bluebubblesConfigSchema,
bluebubblesMeta as meta,
bluebubblesReload,
describeBlueBubblesAccount,
} 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 {
resolveBlueBubblesGroupRequireMention,
resolveBlueBubblesGroupToolPolicy,
} from "./group-policy.js";
import type { ChannelAccountSnapshot, ChannelPlugin } from "./runtime-api.js";
import {
buildProbeChannelStatusSummary,
collectBlueBubblesStatusIssues,
DEFAULT_ACCOUNT_ID,
PAIRING_APPROVED_MESSAGE,
} from "./runtime-api.js";
import { resolveBlueBubblesOutboundSessionRoute } from "./session-route.js";
import { blueBubblesSetupAdapter } from "./setup-core.js";
import { blueBubblesSetupWizard } from "./setup-surface.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),
},
actions: bluebubblesMessageActions,
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 = normalized?.trim();
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 = value?.trim();
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 = display?.trim();
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 display?.trim() || target?.trim() || "";
},
},
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: account.config.allowPrivateNetwork === true,
}),
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: context.To?.trim() || undefined,
currentThreadTs: context.ReplyToIdFull ?? context.ReplyToId,
hasRepliedRef,
}),
},
pairing: {
text: {
idLabel: "bluebubblesSenderId",
message: PAIRING_APPROVED_MESSAGE,
normalizeAllowEntry: createPairingPrefixStripper(
/^bluebubbles:/i,
normalizeBlueBubblesHandle,
),
notify: async ({ cfg, id, message }) => {
await (
await loadBlueBubblesChannelRuntime()
).sendMessageBlueBubbles(id, message, {
cfg: cfg,
});
},
},
},
outbound: {
base: {
deliveryMode: "direct",
textChunkLimit: 4000,
resolveTarget: ({ to }) => {
const trimmed = to?.trim();
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 = typeof replyToId === "string" ? replyToId.trim() : "";
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,
});
},
},
},
});