mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-08 07:40:42 +00:00
Summary: - Adds WhatsApp `@newsletter` target normalization, outbound allowFrom bypass, channel session routing, composing-presence suppression, docs/changelog updates, and focused tests. - Reproducibility: yes. Source inspection on current main shows a `120363401234567890@newsletter` target normalizes to null before outbound send, and the current session route has only direct/group semantics. ClawSweeper fixups: - Included follow-up commit: fix(clownfish): address review for ghcrawl-156943-autonomous-smoke (1) - Included follow-up commit: feat(whatsapp): support newsletter targets in message tool Validation: - ClawSweeper review passed for head9ff3f88202. - Required merge gates passed before the squash merge. Prepared head SHA:9ff3f88202Review: https://github.com/openclaw/openclaw/pull/73393#issuecomment-4338584612 Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com> Co-authored-by: openclaw-clownfish[bot] <280122609+openclaw-clownfish[bot]@users.noreply.github.com> Co-authored-by: clawsweeper <274271284+clawsweeper[bot]@users.noreply.github.com>
292 lines
10 KiB
TypeScript
292 lines
10 KiB
TypeScript
import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/account-core";
|
|
import { describeAccountSnapshot } from "openclaw/plugin-sdk/account-helpers";
|
|
import { normalizeE164 } from "openclaw/plugin-sdk/account-resolution";
|
|
import {
|
|
adaptScopedAccountAccessor,
|
|
createScopedChannelConfigAdapter,
|
|
createScopedDmSecurityResolver,
|
|
} from "openclaw/plugin-sdk/channel-config-helpers";
|
|
import {
|
|
collectOpenGroupPolicyRouteAllowlistWarnings,
|
|
createAllowlistProviderGroupPolicyWarningCollector,
|
|
} from "openclaw/plugin-sdk/channel-policy";
|
|
import type { ChannelPlugin } from "openclaw/plugin-sdk/core";
|
|
import { createChannelPluginBase, getChatChannelMeta } from "openclaw/plugin-sdk/core";
|
|
import {
|
|
createDelegatedSetupWizardProxy,
|
|
type ChannelSetupWizard,
|
|
} from "openclaw/plugin-sdk/setup-runtime";
|
|
import {
|
|
hasAnyWhatsAppAuth,
|
|
listWhatsAppAccountIds,
|
|
resolveDefaultWhatsAppAccountId,
|
|
resolveWhatsAppAccount,
|
|
type ResolvedWhatsAppAccount,
|
|
} from "./accounts.js";
|
|
import { formatWhatsAppConfigAllowFromEntries } from "./config-accessors.js";
|
|
import { WhatsAppChannelConfigSchema } from "./config-schema.js";
|
|
import { whatsappDoctor } from "./doctor.js";
|
|
import { resolveLegacyGroupSessionKey } from "./group-session-contract.js";
|
|
import {
|
|
collectUnsupportedSecretRefConfigCandidates,
|
|
unsupportedSecretRefSurfacePatterns,
|
|
} from "./security-contract.js";
|
|
import { applyWhatsAppSecurityConfigFixes } from "./security-fix.js";
|
|
import {
|
|
canonicalizeLegacySessionKey,
|
|
deriveLegacySessionChatType,
|
|
isLegacyGroupSessionKey,
|
|
} from "./session-contract.js";
|
|
|
|
const WHATSAPP_CHANNEL = "whatsapp" as const;
|
|
|
|
const WHATSAPP_GROUP_SCOPE_FIELDS = ["groupPolicy", "groupAllowFrom", "groups"] as const;
|
|
|
|
type WhatsAppGroupScopeField = (typeof WHATSAPP_GROUP_SCOPE_FIELDS)[number];
|
|
|
|
function resolveWhatsAppAccountKey(
|
|
accounts: Record<string, unknown> | undefined,
|
|
accountId: string,
|
|
): string | undefined {
|
|
if (!accounts) {
|
|
return undefined;
|
|
}
|
|
if (Object.hasOwn(accounts, accountId)) {
|
|
return accountId;
|
|
}
|
|
const normalizedAccountId = accountId.trim().toLowerCase();
|
|
return Object.keys(accounts).find((key) => key.trim().toLowerCase() === normalizedAccountId);
|
|
}
|
|
|
|
function resolveWhatsAppGroupScopeBasePath(params: {
|
|
cfg: Parameters<typeof resolveWhatsAppAccount>[0]["cfg"];
|
|
accountId?: string | null;
|
|
}): string {
|
|
const accountId =
|
|
typeof params.accountId === "string"
|
|
? params.accountId.trim() || DEFAULT_ACCOUNT_ID
|
|
: DEFAULT_ACCOUNT_ID;
|
|
const accounts = params.cfg.channels?.whatsapp?.accounts;
|
|
const accountKey = resolveWhatsAppAccountKey(accounts, accountId);
|
|
const defaultAccountKey = resolveWhatsAppAccountKey(accounts, DEFAULT_ACCOUNT_ID);
|
|
const accountConfig = accountKey ? accounts?.[accountKey] : undefined;
|
|
const defaultAccountConfig = defaultAccountKey ? accounts?.[defaultAccountKey] : undefined;
|
|
const matchesAnyGroupScopeField = (config: Record<string, unknown> | undefined): boolean =>
|
|
WHATSAPP_GROUP_SCOPE_FIELDS.some((field) => config?.[field] !== undefined);
|
|
if (matchesAnyGroupScopeField(accountConfig)) {
|
|
return `channels.whatsapp.accounts.${accountKey}`;
|
|
}
|
|
if (accountId !== DEFAULT_ACCOUNT_ID && matchesAnyGroupScopeField(defaultAccountConfig)) {
|
|
return `channels.whatsapp.accounts.${defaultAccountKey}`;
|
|
}
|
|
return "channels.whatsapp";
|
|
}
|
|
|
|
function resolveWhatsAppConfigPath(params: {
|
|
cfg: Parameters<typeof resolveWhatsAppAccount>[0]["cfg"];
|
|
accountId?: string | null;
|
|
field: WhatsAppGroupScopeField;
|
|
}): string {
|
|
return `${resolveWhatsAppGroupScopeBasePath(params)}.${params.field}`;
|
|
}
|
|
|
|
export async function loadWhatsAppChannelRuntime() {
|
|
return await import("./channel.runtime.js");
|
|
}
|
|
|
|
async function loadWhatsAppSetupSurface() {
|
|
return await import("./setup-surface.js");
|
|
}
|
|
|
|
export const whatsappSetupWizardProxy = createWhatsAppSetupWizardProxy(
|
|
async () => (await loadWhatsAppSetupSurface()).whatsappSetupWizard,
|
|
);
|
|
|
|
const whatsappConfigAdapter = createScopedChannelConfigAdapter<ResolvedWhatsAppAccount>({
|
|
sectionKey: WHATSAPP_CHANNEL,
|
|
listAccountIds: listWhatsAppAccountIds,
|
|
resolveAccount: adaptScopedAccountAccessor(resolveWhatsAppAccount),
|
|
defaultAccountId: resolveDefaultWhatsAppAccountId,
|
|
clearBaseFields: [],
|
|
allowTopLevel: false,
|
|
resolveAllowFrom: (account) => account.allowFrom,
|
|
formatAllowFrom: (allowFrom) => formatWhatsAppConfigAllowFromEntries(allowFrom),
|
|
resolveDefaultTo: (account) => account.defaultTo,
|
|
});
|
|
|
|
const whatsappResolveDmPolicy = createScopedDmSecurityResolver<ResolvedWhatsAppAccount>({
|
|
channelKey: WHATSAPP_CHANNEL,
|
|
resolvePolicy: (account) => account.dmPolicy,
|
|
resolveAllowFrom: (account) => account.allowFrom,
|
|
policyPathSuffix: "dmPolicy",
|
|
normalizeEntry: (raw) => normalizeE164(raw),
|
|
inheritSharedDefaultsFromDefaultAccount: true,
|
|
});
|
|
|
|
function createWhatsAppSetupWizardProxy(
|
|
loadWizard: () => Promise<ChannelSetupWizard>,
|
|
): ChannelSetupWizard {
|
|
return createDelegatedSetupWizardProxy({
|
|
channel: WHATSAPP_CHANNEL,
|
|
loadWizard,
|
|
status: {
|
|
configuredLabel: "linked",
|
|
unconfiguredLabel: "not linked",
|
|
configuredHint: "linked",
|
|
unconfiguredHint: "not linked",
|
|
configuredScore: 5,
|
|
unconfiguredScore: 4,
|
|
},
|
|
resolveShouldPromptAccountIds: (params) => params.shouldPromptAccountIds,
|
|
credentials: [],
|
|
delegateFinalize: true,
|
|
disable: (cfg) => ({
|
|
...cfg,
|
|
channels: {
|
|
...cfg.channels,
|
|
whatsapp: {
|
|
...cfg.channels?.whatsapp,
|
|
enabled: false,
|
|
},
|
|
},
|
|
}),
|
|
onAccountRecorded: (accountId, options) => {
|
|
options?.onAccountId?.(WHATSAPP_CHANNEL, accountId);
|
|
},
|
|
});
|
|
}
|
|
|
|
export function createWhatsAppPluginBase(params: {
|
|
groups: NonNullable<ChannelPlugin<ResolvedWhatsAppAccount>["groups"]>;
|
|
setupWizard: NonNullable<ChannelPlugin<ResolvedWhatsAppAccount>["setupWizard"]>;
|
|
setup: NonNullable<ChannelPlugin<ResolvedWhatsAppAccount>["setup"]>;
|
|
isConfigured: NonNullable<ChannelPlugin<ResolvedWhatsAppAccount>["config"]>["isConfigured"];
|
|
}) {
|
|
const collectWhatsAppSecurityWarnings = createAllowlistProviderGroupPolicyWarningCollector<{
|
|
account: ResolvedWhatsAppAccount;
|
|
cfg: Parameters<typeof resolveWhatsAppAccount>[0]["cfg"];
|
|
accountId?: string | null;
|
|
}>({
|
|
providerConfigPresent: (cfg) => cfg.channels?.whatsapp !== undefined,
|
|
resolveGroupPolicy: ({ account }) => account.groupPolicy,
|
|
collect: ({ account, accountId, cfg, groupPolicy }) =>
|
|
collectOpenGroupPolicyRouteAllowlistWarnings({
|
|
groupPolicy,
|
|
routeAllowlistConfigured:
|
|
Boolean(account.groups) && Object.keys(account.groups ?? {}).length > 0,
|
|
restrictSenders: {
|
|
surface: "WhatsApp groups",
|
|
openScope: "any member in allowed groups",
|
|
groupPolicyPath: resolveWhatsAppConfigPath({ cfg, accountId, field: "groupPolicy" }),
|
|
groupAllowFromPath: resolveWhatsAppConfigPath({
|
|
cfg,
|
|
accountId,
|
|
field: "groupAllowFrom",
|
|
}),
|
|
},
|
|
noRouteAllowlist: {
|
|
surface: "WhatsApp groups",
|
|
routeAllowlistPath: resolveWhatsAppConfigPath({ cfg, accountId, field: "groups" }),
|
|
routeScope: "group",
|
|
groupPolicyPath: resolveWhatsAppConfigPath({ cfg, accountId, field: "groupPolicy" }),
|
|
groupAllowFromPath: resolveWhatsAppConfigPath({
|
|
cfg,
|
|
accountId,
|
|
field: "groupAllowFrom",
|
|
}),
|
|
},
|
|
}),
|
|
});
|
|
const base = createChannelPluginBase({
|
|
id: WHATSAPP_CHANNEL,
|
|
meta: {
|
|
...getChatChannelMeta(WHATSAPP_CHANNEL),
|
|
showConfigured: false,
|
|
quickstartAllowFrom: true,
|
|
forceAccountBinding: true,
|
|
preferSessionLookupForAnnounceTarget: true,
|
|
},
|
|
setupWizard: params.setupWizard,
|
|
capabilities: {
|
|
chatTypes: ["direct", "group", "channel"],
|
|
polls: true,
|
|
reactions: true,
|
|
media: true,
|
|
tts: {
|
|
voice: {
|
|
synthesisTarget: "voice-note",
|
|
transcodesAudio: true,
|
|
},
|
|
},
|
|
},
|
|
reload: { configPrefixes: ["web"], noopPrefixes: ["channels.whatsapp"] },
|
|
gatewayMethods: ["web.login.start", "web.login.wait"],
|
|
configSchema: WhatsAppChannelConfigSchema,
|
|
config: {
|
|
...whatsappConfigAdapter,
|
|
isEnabled: (account, cfg) => account.enabled && cfg.web?.enabled !== false,
|
|
disabledReason: () => "disabled",
|
|
isConfigured: params.isConfigured,
|
|
hasPersistedAuthState: ({ cfg }) => hasAnyWhatsAppAuth(cfg),
|
|
unconfiguredReason: () => "not linked",
|
|
describeAccount: (account) =>
|
|
describeAccountSnapshot({
|
|
account,
|
|
configured: Boolean(account.authDir),
|
|
extra: {
|
|
linked: Boolean(account.authDir),
|
|
dmPolicy: account.dmPolicy,
|
|
allowFrom: account.allowFrom,
|
|
},
|
|
}),
|
|
},
|
|
security: {
|
|
applyConfigFixes: applyWhatsAppSecurityConfigFixes,
|
|
resolveDmPolicy: whatsappResolveDmPolicy,
|
|
collectWarnings: collectWhatsAppSecurityWarnings,
|
|
},
|
|
doctor: whatsappDoctor,
|
|
setup: params.setup,
|
|
groups: params.groups,
|
|
});
|
|
return {
|
|
...base,
|
|
setupWizard: base.setupWizard!,
|
|
capabilities: base.capabilities!,
|
|
reload: base.reload!,
|
|
gatewayMethods: base.gatewayMethods!,
|
|
configSchema: base.configSchema!,
|
|
config: base.config!,
|
|
messaging: {
|
|
defaultMarkdownTableMode: "bullets",
|
|
deriveLegacySessionChatType,
|
|
resolveLegacyGroupSessionKey,
|
|
isLegacyGroupSessionKey,
|
|
canonicalizeLegacySessionKey: (params) =>
|
|
canonicalizeLegacySessionKey({ key: params.key, agentId: params.agentId }),
|
|
},
|
|
secrets: {
|
|
unsupportedSecretRefSurfacePatterns,
|
|
collectUnsupportedSecretRefConfigCandidates,
|
|
},
|
|
security: base.security!,
|
|
groups: base.groups!,
|
|
} satisfies Pick<
|
|
ChannelPlugin<ResolvedWhatsAppAccount>,
|
|
| "id"
|
|
| "meta"
|
|
| "setupWizard"
|
|
| "capabilities"
|
|
| "reload"
|
|
| "gatewayMethods"
|
|
| "configSchema"
|
|
| "config"
|
|
| "messaging"
|
|
| "secrets"
|
|
| "security"
|
|
| "doctor"
|
|
| "setup"
|
|
| "groups"
|
|
>;
|
|
}
|