* 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>
434 lines
14 KiB
TypeScript
434 lines
14 KiB
TypeScript
import type {
|
|
ChannelAccountSnapshot,
|
|
ChannelDock,
|
|
ChannelPlugin,
|
|
OpenClawConfig,
|
|
} from "openclaw/plugin-sdk";
|
|
import {
|
|
applyAccountNameToChannelSection,
|
|
buildChannelConfigSchema,
|
|
buildTokenChannelStatusSummary,
|
|
DEFAULT_ACCOUNT_ID,
|
|
deleteAccountFromConfigSection,
|
|
chunkTextForOutbound,
|
|
formatAllowFromLowercase,
|
|
formatPairingApproveHint,
|
|
migrateBaseNameToDefaultAccount,
|
|
normalizeAccountId,
|
|
PAIRING_APPROVED_MESSAGE,
|
|
resolveDefaultGroupPolicy,
|
|
resolveOpenProviderRuntimeGroupPolicy,
|
|
resolveChannelAccountConfigBasePath,
|
|
setAccountEnabledInConfigSection,
|
|
} from "openclaw/plugin-sdk";
|
|
import {
|
|
listZaloAccountIds,
|
|
resolveDefaultZaloAccountId,
|
|
resolveZaloAccount,
|
|
type ResolvedZaloAccount,
|
|
} from "./accounts.js";
|
|
import { zaloMessageActions } from "./actions.js";
|
|
import { ZaloConfigSchema } from "./config-schema.js";
|
|
import { zaloOnboardingAdapter } from "./onboarding.js";
|
|
import { probeZalo } from "./probe.js";
|
|
import { resolveZaloProxyFetch } from "./proxy.js";
|
|
import { normalizeSecretInputString } from "./secret-input.js";
|
|
import { sendMessageZalo } from "./send.js";
|
|
import { collectZaloStatusIssues } from "./status-issues.js";
|
|
|
|
const meta = {
|
|
id: "zalo",
|
|
label: "Zalo",
|
|
selectionLabel: "Zalo (Bot API)",
|
|
docsPath: "/channels/zalo",
|
|
docsLabel: "zalo",
|
|
blurb: "Vietnam-focused messaging platform with Bot API.",
|
|
aliases: ["zl"],
|
|
order: 80,
|
|
quickstartAllowFrom: true,
|
|
};
|
|
|
|
function normalizeZaloMessagingTarget(raw: string): string | undefined {
|
|
const trimmed = raw?.trim();
|
|
if (!trimmed) {
|
|
return undefined;
|
|
}
|
|
return trimmed.replace(/^(zalo|zl):/i, "");
|
|
}
|
|
|
|
export const zaloDock: ChannelDock = {
|
|
id: "zalo",
|
|
capabilities: {
|
|
chatTypes: ["direct", "group"],
|
|
media: true,
|
|
blockStreaming: true,
|
|
},
|
|
outbound: { textChunkLimit: 2000 },
|
|
config: {
|
|
resolveAllowFrom: ({ cfg, accountId }) =>
|
|
(resolveZaloAccount({ cfg: cfg, accountId }).config.allowFrom ?? []).map((entry) =>
|
|
String(entry),
|
|
),
|
|
formatAllowFrom: ({ allowFrom }) =>
|
|
formatAllowFromLowercase({ allowFrom, stripPrefixRe: /^(zalo|zl):/i }),
|
|
},
|
|
groups: {
|
|
resolveRequireMention: () => true,
|
|
},
|
|
threading: {
|
|
resolveReplyToMode: () => "off",
|
|
},
|
|
};
|
|
|
|
export const zaloPlugin: ChannelPlugin<ResolvedZaloAccount> = {
|
|
id: "zalo",
|
|
meta,
|
|
onboarding: zaloOnboardingAdapter,
|
|
capabilities: {
|
|
chatTypes: ["direct", "group"],
|
|
media: true,
|
|
reactions: false,
|
|
threads: false,
|
|
polls: false,
|
|
nativeCommands: false,
|
|
blockStreaming: true,
|
|
},
|
|
reload: { configPrefixes: ["channels.zalo"] },
|
|
configSchema: buildChannelConfigSchema(ZaloConfigSchema),
|
|
config: {
|
|
listAccountIds: (cfg) => listZaloAccountIds(cfg),
|
|
resolveAccount: (cfg, accountId) => resolveZaloAccount({ cfg: cfg, accountId }),
|
|
defaultAccountId: (cfg) => resolveDefaultZaloAccountId(cfg),
|
|
setAccountEnabled: ({ cfg, accountId, enabled }) =>
|
|
setAccountEnabledInConfigSection({
|
|
cfg: cfg,
|
|
sectionKey: "zalo",
|
|
accountId,
|
|
enabled,
|
|
allowTopLevel: true,
|
|
}),
|
|
deleteAccount: ({ cfg, accountId }) =>
|
|
deleteAccountFromConfigSection({
|
|
cfg: cfg,
|
|
sectionKey: "zalo",
|
|
accountId,
|
|
clearBaseFields: ["botToken", "tokenFile", "name"],
|
|
}),
|
|
isConfigured: (account) => Boolean(account.token?.trim()),
|
|
describeAccount: (account): ChannelAccountSnapshot => ({
|
|
accountId: account.accountId,
|
|
name: account.name,
|
|
enabled: account.enabled,
|
|
configured: Boolean(account.token?.trim()),
|
|
tokenSource: account.tokenSource,
|
|
}),
|
|
resolveAllowFrom: ({ cfg, accountId }) =>
|
|
(resolveZaloAccount({ cfg: cfg, accountId }).config.allowFrom ?? []).map((entry) =>
|
|
String(entry),
|
|
),
|
|
formatAllowFrom: ({ allowFrom }) =>
|
|
formatAllowFromLowercase({ allowFrom, stripPrefixRe: /^(zalo|zl):/i }),
|
|
},
|
|
security: {
|
|
resolveDmPolicy: ({ cfg, accountId, account }) => {
|
|
const resolvedAccountId = accountId ?? account.accountId ?? DEFAULT_ACCOUNT_ID;
|
|
const basePath = resolveChannelAccountConfigBasePath({
|
|
cfg,
|
|
channelKey: "zalo",
|
|
accountId: resolvedAccountId,
|
|
});
|
|
return {
|
|
policy: account.config.dmPolicy ?? "pairing",
|
|
allowFrom: account.config.allowFrom ?? [],
|
|
policyPath: `${basePath}dmPolicy`,
|
|
allowFromPath: basePath,
|
|
approveHint: formatPairingApproveHint("zalo"),
|
|
normalizeEntry: (raw) => raw.replace(/^(zalo|zl):/i, ""),
|
|
};
|
|
},
|
|
collectWarnings: ({ account, cfg }) => {
|
|
const defaultGroupPolicy = resolveDefaultGroupPolicy(cfg);
|
|
const { groupPolicy } = resolveOpenProviderRuntimeGroupPolicy({
|
|
providerConfigPresent: cfg.channels?.zalo !== undefined,
|
|
groupPolicy: account.config.groupPolicy,
|
|
defaultGroupPolicy,
|
|
});
|
|
if (groupPolicy !== "open") {
|
|
return [];
|
|
}
|
|
const explicitGroupAllowFrom = (account.config.groupAllowFrom ?? []).map((entry) =>
|
|
String(entry),
|
|
);
|
|
const dmAllowFrom = (account.config.allowFrom ?? []).map((entry) => String(entry));
|
|
const effectiveAllowFrom =
|
|
explicitGroupAllowFrom.length > 0 ? explicitGroupAllowFrom : dmAllowFrom;
|
|
if (effectiveAllowFrom.length > 0) {
|
|
return [
|
|
`- Zalo groups: groupPolicy="open" allows any member to trigger (mention-gated). Set channels.zalo.groupPolicy="allowlist" + channels.zalo.groupAllowFrom to restrict senders.`,
|
|
];
|
|
}
|
|
return [
|
|
`- Zalo groups: groupPolicy="open" with no groupAllowFrom/allowFrom allowlist; any member can trigger (mention-gated). Set channels.zalo.groupPolicy="allowlist" + channels.zalo.groupAllowFrom.`,
|
|
];
|
|
},
|
|
},
|
|
groups: {
|
|
resolveRequireMention: () => true,
|
|
},
|
|
threading: {
|
|
resolveReplyToMode: () => "off",
|
|
},
|
|
actions: zaloMessageActions,
|
|
messaging: {
|
|
normalizeTarget: normalizeZaloMessagingTarget,
|
|
targetResolver: {
|
|
looksLikeId: (raw) => {
|
|
const trimmed = raw.trim();
|
|
if (!trimmed) {
|
|
return false;
|
|
}
|
|
return /^\d{3,}$/.test(trimmed);
|
|
},
|
|
hint: "<chatId>",
|
|
},
|
|
},
|
|
directory: {
|
|
self: async () => null,
|
|
listPeers: async ({ cfg, accountId, query, limit }) => {
|
|
const account = resolveZaloAccount({ cfg: cfg, accountId });
|
|
const q = query?.trim().toLowerCase() || "";
|
|
const peers = Array.from(
|
|
new Set(
|
|
(account.config.allowFrom ?? [])
|
|
.map((entry) => String(entry).trim())
|
|
.filter((entry) => Boolean(entry) && entry !== "*")
|
|
.map((entry) => entry.replace(/^(zalo|zl):/i, "")),
|
|
),
|
|
)
|
|
.filter((id) => (q ? id.toLowerCase().includes(q) : true))
|
|
.slice(0, limit && limit > 0 ? limit : undefined)
|
|
.map((id) => ({ kind: "user", id }) as const);
|
|
return peers;
|
|
},
|
|
listGroups: async () => [],
|
|
},
|
|
setup: {
|
|
resolveAccountId: ({ accountId }) => normalizeAccountId(accountId),
|
|
applyAccountName: ({ cfg, accountId, name }) =>
|
|
applyAccountNameToChannelSection({
|
|
cfg: cfg,
|
|
channelKey: "zalo",
|
|
accountId,
|
|
name,
|
|
}),
|
|
validateInput: ({ accountId, input }) => {
|
|
if (input.useEnv && accountId !== DEFAULT_ACCOUNT_ID) {
|
|
return "ZALO_BOT_TOKEN can only be used for the default account.";
|
|
}
|
|
if (!input.useEnv && !input.token && !input.tokenFile) {
|
|
return "Zalo requires token or --token-file (or --use-env).";
|
|
}
|
|
return null;
|
|
},
|
|
applyAccountConfig: ({ cfg, accountId, input }) => {
|
|
const namedConfig = applyAccountNameToChannelSection({
|
|
cfg: cfg,
|
|
channelKey: "zalo",
|
|
accountId,
|
|
name: input.name,
|
|
});
|
|
const next =
|
|
accountId !== DEFAULT_ACCOUNT_ID
|
|
? migrateBaseNameToDefaultAccount({
|
|
cfg: namedConfig,
|
|
channelKey: "zalo",
|
|
})
|
|
: namedConfig;
|
|
if (accountId === DEFAULT_ACCOUNT_ID) {
|
|
return {
|
|
...next,
|
|
channels: {
|
|
...next.channels,
|
|
zalo: {
|
|
...next.channels?.zalo,
|
|
enabled: true,
|
|
...(input.useEnv
|
|
? {}
|
|
: input.tokenFile
|
|
? { tokenFile: input.tokenFile }
|
|
: input.token
|
|
? { botToken: input.token }
|
|
: {}),
|
|
},
|
|
},
|
|
} as OpenClawConfig;
|
|
}
|
|
return {
|
|
...next,
|
|
channels: {
|
|
...next.channels,
|
|
zalo: {
|
|
...next.channels?.zalo,
|
|
enabled: true,
|
|
accounts: {
|
|
...next.channels?.zalo?.accounts,
|
|
[accountId]: {
|
|
...next.channels?.zalo?.accounts?.[accountId],
|
|
enabled: true,
|
|
...(input.tokenFile
|
|
? { tokenFile: input.tokenFile }
|
|
: input.token
|
|
? { botToken: input.token }
|
|
: {}),
|
|
},
|
|
},
|
|
},
|
|
},
|
|
} as OpenClawConfig;
|
|
},
|
|
},
|
|
pairing: {
|
|
idLabel: "zaloUserId",
|
|
normalizeAllowEntry: (entry) => entry.replace(/^(zalo|zl):/i, ""),
|
|
notifyApproval: async ({ cfg, id }) => {
|
|
const account = resolveZaloAccount({ cfg: cfg });
|
|
if (!account.token) {
|
|
throw new Error("Zalo token not configured");
|
|
}
|
|
await sendMessageZalo(id, PAIRING_APPROVED_MESSAGE, { token: account.token });
|
|
},
|
|
},
|
|
outbound: {
|
|
deliveryMode: "direct",
|
|
chunker: chunkTextForOutbound,
|
|
chunkerMode: "text",
|
|
textChunkLimit: 2000,
|
|
sendPayload: async (ctx) => {
|
|
const text = ctx.payload.text ?? "";
|
|
const urls = ctx.payload.mediaUrls?.length
|
|
? ctx.payload.mediaUrls
|
|
: ctx.payload.mediaUrl
|
|
? [ctx.payload.mediaUrl]
|
|
: [];
|
|
if (!text && urls.length === 0) {
|
|
return { channel: "zalo", messageId: "" };
|
|
}
|
|
if (urls.length > 0) {
|
|
let lastResult = await zaloPlugin.outbound!.sendMedia!({
|
|
...ctx,
|
|
text,
|
|
mediaUrl: urls[0],
|
|
});
|
|
for (let i = 1; i < urls.length; i++) {
|
|
lastResult = await zaloPlugin.outbound!.sendMedia!({
|
|
...ctx,
|
|
text: "",
|
|
mediaUrl: urls[i],
|
|
});
|
|
}
|
|
return lastResult;
|
|
}
|
|
const outbound = zaloPlugin.outbound!;
|
|
const limit = outbound.textChunkLimit;
|
|
const chunks = limit && outbound.chunker ? outbound.chunker(text, limit) : [text];
|
|
let lastResult: Awaited<ReturnType<NonNullable<typeof outbound.sendText>>>;
|
|
for (const chunk of chunks) {
|
|
lastResult = await outbound.sendText!({ ...ctx, text: chunk });
|
|
}
|
|
return lastResult!;
|
|
},
|
|
sendText: async ({ to, text, accountId, cfg }) => {
|
|
const result = await sendMessageZalo(to, text, {
|
|
accountId: accountId ?? undefined,
|
|
cfg: cfg,
|
|
});
|
|
return {
|
|
channel: "zalo",
|
|
ok: result.ok,
|
|
messageId: result.messageId ?? "",
|
|
error: result.error ? new Error(result.error) : undefined,
|
|
};
|
|
},
|
|
sendMedia: async ({ to, text, mediaUrl, accountId, cfg }) => {
|
|
const result = await sendMessageZalo(to, text, {
|
|
accountId: accountId ?? undefined,
|
|
mediaUrl,
|
|
cfg: cfg,
|
|
});
|
|
return {
|
|
channel: "zalo",
|
|
ok: result.ok,
|
|
messageId: result.messageId ?? "",
|
|
error: result.error ? new Error(result.error) : undefined,
|
|
};
|
|
},
|
|
},
|
|
status: {
|
|
defaultRuntime: {
|
|
accountId: DEFAULT_ACCOUNT_ID,
|
|
running: false,
|
|
lastStartAt: null,
|
|
lastStopAt: null,
|
|
lastError: null,
|
|
},
|
|
collectStatusIssues: collectZaloStatusIssues,
|
|
buildChannelSummary: ({ snapshot }) => buildTokenChannelStatusSummary(snapshot),
|
|
probeAccount: async ({ account, timeoutMs }) =>
|
|
probeZalo(account.token, timeoutMs, resolveZaloProxyFetch(account.config.proxy)),
|
|
buildAccountSnapshot: ({ account, runtime }) => {
|
|
const configured = Boolean(account.token?.trim());
|
|
return {
|
|
accountId: account.accountId,
|
|
name: account.name,
|
|
enabled: account.enabled,
|
|
configured,
|
|
tokenSource: account.tokenSource,
|
|
running: runtime?.running ?? false,
|
|
lastStartAt: runtime?.lastStartAt ?? null,
|
|
lastStopAt: runtime?.lastStopAt ?? null,
|
|
lastError: runtime?.lastError ?? null,
|
|
mode: account.config.webhookUrl ? "webhook" : "polling",
|
|
lastInboundAt: runtime?.lastInboundAt ?? null,
|
|
lastOutboundAt: runtime?.lastOutboundAt ?? null,
|
|
dmPolicy: account.config.dmPolicy ?? "pairing",
|
|
};
|
|
},
|
|
},
|
|
gateway: {
|
|
startAccount: async (ctx) => {
|
|
const account = ctx.account;
|
|
const token = account.token.trim();
|
|
let zaloBotLabel = "";
|
|
const fetcher = resolveZaloProxyFetch(account.config.proxy);
|
|
try {
|
|
const probe = await probeZalo(token, 2500, fetcher);
|
|
const name = probe.ok ? probe.bot?.name?.trim() : null;
|
|
if (name) {
|
|
zaloBotLabel = ` (${name})`;
|
|
}
|
|
ctx.setStatus({
|
|
accountId: account.accountId,
|
|
bot: probe.bot,
|
|
});
|
|
} catch {
|
|
// ignore probe errors
|
|
}
|
|
ctx.log?.info(`[${account.accountId}] starting provider${zaloBotLabel}`);
|
|
const { monitorZaloProvider } = await import("./monitor.js");
|
|
return monitorZaloProvider({
|
|
token,
|
|
account,
|
|
config: ctx.cfg,
|
|
runtime: ctx.runtime,
|
|
abortSignal: ctx.abortSignal,
|
|
useWebhook: Boolean(account.config.webhookUrl),
|
|
webhookUrl: account.config.webhookUrl,
|
|
webhookSecret: normalizeSecretInputString(account.config.webhookSecret),
|
|
webhookPath: account.config.webhookPath,
|
|
fetcher,
|
|
statusSink: (patch) => ctx.setStatus({ accountId: ctx.accountId, ...patch }),
|
|
});
|
|
},
|
|
},
|
|
};
|