mirror of
https://github.com/hansjone/oclaw.git
synced 2026-10-12 20:00:46 +08:00
重构主控编排与运行时预热链路,统一工作区提示词/专家调度协议并补齐 wiki 记忆注入与写回闭环。
同时收敛启动与运维脚本默认行为(含 wiki worker)、更新 Admin 可观测性与相关测试,降低首轮时延并提高运行稳定性。 Made-with: Cursor
This commit is contained in:
parent
4a23b715a2
commit
dbbe3add6a
14438 changed files with 2693620 additions and 2546 deletions
356
openclaw/extensions/bluebubbles/src/channel.ts
Normal file
356
openclaw/extensions/bluebubbles/src/channel.ts
Normal file
|
|
@ -0,0 +1,356 @@
|
|||
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,
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue