fix(runtime): lazy-load setup shims and align contracts

This commit is contained in:
Vincent Koc
2026-03-19 12:17:25 -07:00
parent 7bbd01379e
commit 3b79494cbf
22 changed files with 909 additions and 160 deletions

View File

@@ -3,12 +3,12 @@ import {
hasConfiguredSecretInput,
normalizeSecretInputString,
} from "openclaw/plugin-sdk/config-runtime";
import type { DiscordAccountConfig, OpenClawConfig } from "openclaw/plugin-sdk/discord-core";
import {
mergeDiscordAccountConfig,
resolveDefaultDiscordAccountId,
resolveDiscordAccountConfig,
} from "./accounts.js";
import type { DiscordAccountConfig, OpenClawConfig } from "./runtime-api.js";
export type DiscordCredentialStatus = "available" | "configured_unavailable" | "missing";

View File

@@ -3,12 +3,8 @@ import {
createAccountListHelpers,
} from "openclaw/plugin-sdk/account-helpers";
import { normalizeAccountId } from "openclaw/plugin-sdk/account-id";
import type {
DiscordAccountConfig,
DiscordActionConfig,
OpenClawConfig,
} from "openclaw/plugin-sdk/discord-core";
import { resolveAccountEntry } from "openclaw/plugin-sdk/routing";
import type { DiscordAccountConfig, DiscordActionConfig, OpenClawConfig } from "./runtime-api.js";
import { resolveDiscordToken } from "./token.js";
export type ResolvedDiscordAccount = {

View File

@@ -1,5 +1,7 @@
import { discordSetupWizard as discordSetupWizardImpl } from "./setup-surface.js";
import { createDiscordSetupWizardProxy } from "./setup-core.js";
type DiscordSetupWizard = typeof import("./setup-surface.js").discordSetupWizard;
export const discordSetupWizard: DiscordSetupWizard = { ...discordSetupWizardImpl };
export const discordSetupWizard: DiscordSetupWizard = createDiscordSetupWizardProxy(
async () => (await import("./setup-surface.js")).discordSetupWizard,
);

View File

@@ -0,0 +1,163 @@
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id";
import {
hasConfiguredSecretInput,
normalizeSecretInputString,
type OpenClawConfig,
} from "openclaw/plugin-sdk/config-runtime";
import type { DiscordAccountConfig } from "./runtime-api.js";
import { resolveDiscordToken } from "./token.js";
export type InspectedDiscordSetupAccount = {
accountId: string;
enabled: boolean;
token: string;
tokenSource: "env" | "config" | "none";
tokenStatus: "available" | "configured_unavailable" | "missing";
configured: boolean;
config: DiscordAccountConfig;
};
function resolveDiscordAccountEntry(
cfg: OpenClawConfig,
accountId: string,
): DiscordAccountConfig | undefined {
const accounts = cfg.channels?.discord?.accounts;
if (!accounts || typeof accounts !== "object" || Array.isArray(accounts)) {
return undefined;
}
const normalized = normalizeAccountId(accountId);
const direct = accounts[normalized];
if (direct) {
return direct;
}
const matchKey = Object.keys(accounts).find((key) => normalizeAccountId(key) === normalized);
return matchKey ? accounts[matchKey] : undefined;
}
function inspectConfiguredToken(value: unknown): {
token: string;
tokenSource: "config";
tokenStatus: "available" | "configured_unavailable";
} | null {
const normalized = normalizeSecretInputString(value);
if (normalized) {
return {
token: normalized.replace(/^Bot\s+/i, ""),
tokenSource: "config",
tokenStatus: "available",
};
}
if (hasConfiguredSecretInput(value)) {
return {
token: "",
tokenSource: "config",
tokenStatus: "configured_unavailable",
};
}
return null;
}
export function listDiscordSetupAccountIds(cfg: OpenClawConfig): string[] {
const accounts = cfg.channels?.discord?.accounts;
const ids =
accounts && typeof accounts === "object" && !Array.isArray(accounts)
? Object.keys(accounts)
.map((accountId) => normalizeAccountId(accountId))
.filter(Boolean)
: [];
return [...new Set([DEFAULT_ACCOUNT_ID, ...ids])];
}
export function resolveDefaultDiscordSetupAccountId(cfg: OpenClawConfig): string {
return listDiscordSetupAccountIds(cfg)[0] ?? DEFAULT_ACCOUNT_ID;
}
export function resolveDiscordSetupAccountConfig(params: {
cfg: OpenClawConfig;
accountId?: string | null;
}): { accountId: string; config: DiscordAccountConfig } {
const accountId = normalizeAccountId(params.accountId ?? DEFAULT_ACCOUNT_ID);
const { accounts: _ignored, ...base } = (params.cfg.channels?.discord ??
{}) as DiscordAccountConfig & {
accounts?: unknown;
};
return {
accountId,
config: {
...base,
...(resolveDiscordAccountEntry(params.cfg, accountId) ?? {}),
},
};
}
export function inspectDiscordSetupAccount(params: {
cfg: OpenClawConfig;
accountId?: string | null;
}): InspectedDiscordSetupAccount {
const { accountId, config } = resolveDiscordSetupAccountConfig(params);
const enabled = params.cfg.channels?.discord?.enabled !== false && config.enabled !== false;
const accountConfig = resolveDiscordAccountEntry(params.cfg, accountId);
const hasAccountToken = Boolean(
accountConfig &&
Object.prototype.hasOwnProperty.call(accountConfig as Record<string, unknown>, "token"),
);
const accountToken = inspectConfiguredToken(accountConfig?.token);
if (accountToken) {
return {
accountId,
enabled,
token: accountToken.token,
tokenSource: accountToken.tokenSource,
tokenStatus: accountToken.tokenStatus,
configured: true,
config,
};
}
if (hasAccountToken) {
return {
accountId,
enabled,
token: "",
tokenSource: "none",
tokenStatus: "missing",
configured: false,
config,
};
}
const channelToken = inspectConfiguredToken(params.cfg.channels?.discord?.token);
if (channelToken) {
return {
accountId,
enabled,
token: channelToken.token,
tokenSource: channelToken.tokenSource,
tokenStatus: channelToken.tokenStatus,
configured: true,
config,
};
}
const tokenResolution = resolveDiscordToken(params.cfg, { accountId });
if (tokenResolution.token) {
return {
accountId,
enabled,
token: tokenResolution.token,
tokenSource: tokenResolution.source,
tokenStatus: "available",
configured: true,
config,
};
}
return {
accountId,
enabled,
token: "",
tokenSource: "none",
tokenStatus: "missing",
configured: false,
config,
};
}

View File

@@ -1,24 +1,27 @@
import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/account-id";
import type { DiscordGuildEntry } from "openclaw/plugin-sdk/config-runtime";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import { createEnvPatchedAccountSetupAdapter } from "openclaw/plugin-sdk/setup-adapter-runtime";
import type {
ChannelSetupAdapter,
ChannelSetupDmPolicy,
ChannelSetupWizard,
} from "openclaw/plugin-sdk/setup-runtime";
import { formatDocsLink } from "openclaw/plugin-sdk/setup-tools";
import {
inspectDiscordSetupAccount,
listDiscordSetupAccountIds,
resolveDiscordSetupAccountConfig,
} from "./setup-account-state.js";
import {
createAccountScopedAllowFromSection,
createAccountScopedGroupAccessSection,
createAllowlistSetupWizardProxy,
createLegacyCompatChannelDmPolicy,
DEFAULT_ACCOUNT_ID,
createEnvPatchedAccountSetupAdapter,
parseMentionOrPrefixedId,
patchChannelConfigForAccount,
setSetupChannelEnabled,
type OpenClawConfig,
} from "openclaw/plugin-sdk/setup";
import {
createAllowlistSetupWizardProxy,
type ChannelSetupAdapter,
type ChannelSetupDmPolicy,
type ChannelSetupWizard,
} from "openclaw/plugin-sdk/setup";
import { formatDocsLink } from "openclaw/plugin-sdk/setup-tools";
import { inspectDiscordAccount } from "./account-inspect.js";
import { listDiscordAccountIds, resolveDiscordAccount } from "./accounts.js";
} from "./setup-runtime-helpers.js";
const channel = "discord" as const;
@@ -104,8 +107,8 @@ export function createDiscordSetupWizardBase(handlers: {
configuredScore: 2,
unconfiguredScore: 1,
resolveConfigured: ({ cfg }) =>
listDiscordAccountIds(cfg).some((accountId) => {
const account = inspectDiscordAccount({ cfg, accountId });
listDiscordSetupAccountIds(cfg).some((accountId) => {
const account = inspectDiscordSetupAccount({ cfg, accountId });
return account.configured;
}),
},
@@ -122,7 +125,7 @@ export function createDiscordSetupWizardBase(handlers: {
inputPrompt: "Enter Discord bot token",
allowEnv: ({ accountId }: { accountId: string }) => accountId === DEFAULT_ACCOUNT_ID,
inspect: ({ cfg, accountId }: { cfg: OpenClawConfig; accountId: string }) => {
const account = inspectDiscordAccount({ cfg, accountId });
const account = inspectDiscordSetupAccount({ cfg, accountId });
return {
accountConfigured: account.configured,
hasConfiguredValue: account.tokenStatus !== "missing",
@@ -136,25 +139,24 @@ export function createDiscordSetupWizardBase(handlers: {
},
],
groupAccess: createAccountScopedGroupAccessSection({
channel,
label: "Discord channels",
placeholder: "My Server/#general, guildId/channelId, #support",
currentPolicy: ({ cfg, accountId }: { cfg: OpenClawConfig; accountId: string }) =>
resolveDiscordAccount({ cfg, accountId }).config.groupPolicy ?? "allowlist",
resolveDiscordSetupAccountConfig({ cfg, accountId }).config.groupPolicy ?? "allowlist",
currentEntries: ({ cfg, accountId }: { cfg: OpenClawConfig; accountId: string }) =>
Object.entries(resolveDiscordAccount({ cfg, accountId }).config.guilds ?? {}).flatMap(
([guildKey, value]) => {
const channels = value?.channels ?? {};
const channelKeys = Object.keys(channels);
if (channelKeys.length === 0) {
const input = /^\d+$/.test(guildKey) ? `guild:${guildKey}` : guildKey;
return [input];
}
return channelKeys.map((channelKey) => `${guildKey}/${channelKey}`);
},
),
Object.entries(
resolveDiscordSetupAccountConfig({ cfg, accountId }).config.guilds ?? {},
).flatMap(([guildKey, value]) => {
const channels = value?.channels ?? {};
const channelKeys = Object.keys(channels);
if (channelKeys.length === 0) {
const input = /^\d+$/.test(guildKey) ? `guild:${guildKey}` : guildKey;
return [input];
}
return channelKeys.map((channelKey) => `${guildKey}/${channelKey}`);
}),
updatePrompt: ({ cfg, accountId }: { cfg: OpenClawConfig; accountId: string }) =>
Boolean(resolveDiscordAccount({ cfg, accountId }).config.guilds),
Boolean(resolveDiscordSetupAccountConfig({ cfg, accountId }).config.guilds),
resolveAllowlist: handlers.resolveGroupAllowlist,
fallbackResolved: (entries) => entries.map((input) => ({ input, resolved: false })),
applyAllowlist: ({
@@ -168,7 +170,6 @@ export function createDiscordSetupWizardBase(handlers: {
}) => setDiscordGuildChannelAllowlist(cfg, accountId, resolved as never),
}),
allowFrom: createAccountScopedAllowFromSection({
channel,
credentialInputKey: "token",
helpTitle: "Discord allowlist",
helpLines: [

View File

@@ -0,0 +1,436 @@
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import type {
ChannelSetupDmPolicy,
ChannelSetupWizard,
WizardPrompter,
} from "openclaw/plugin-sdk/setup-runtime";
import {
resolveDefaultDiscordSetupAccountId,
resolveDiscordSetupAccountConfig,
} from "./setup-account-state.js";
export function parseMentionOrPrefixedId(params: {
value: string;
mentionPattern: RegExp;
prefixPattern?: RegExp;
idPattern: RegExp;
normalizeId?: (id: string) => string;
}): string | null {
const trimmed = params.value.trim();
if (!trimmed) {
return null;
}
const mentionMatch = trimmed.match(params.mentionPattern);
if (mentionMatch?.[1]) {
return params.normalizeId ? params.normalizeId(mentionMatch[1]) : mentionMatch[1];
}
if (params.prefixPattern?.test(trimmed)) {
const stripped = trimmed.replace(params.prefixPattern, "").trim();
if (!stripped || !params.idPattern.test(stripped)) {
return null;
}
return params.normalizeId ? params.normalizeId(stripped) : stripped;
}
if (!params.idPattern.test(trimmed)) {
return null;
}
return params.normalizeId ? params.normalizeId(trimmed) : trimmed;
}
function splitSetupEntries(raw: string): string[] {
return raw
.split(/[\n,;]+/g)
.map((entry) => entry.trim())
.filter(Boolean);
}
function mergeAllowFromEntries(
current: Array<string | number> | null | undefined,
additions: Array<string | number>,
): string[] {
const merged = [...(current ?? []), ...additions]
.map((value) => String(value).trim())
.filter(Boolean);
return [...new Set(merged)];
}
function patchDiscordChannelConfigForAccount(params: {
cfg: OpenClawConfig;
accountId: string;
patch: Record<string, unknown>;
}): OpenClawConfig {
const accountId = normalizeAccountId(params.accountId);
const channelConfig = (params.cfg.channels?.discord as Record<string, unknown> | undefined) ?? {};
if (accountId === DEFAULT_ACCOUNT_ID) {
return {
...params.cfg,
channels: {
...params.cfg.channels,
discord: {
...channelConfig,
...params.patch,
enabled: true,
},
},
};
}
const accounts =
(channelConfig.accounts as Record<string, Record<string, unknown>> | undefined) ?? {};
const accountConfig = accounts[accountId] ?? {};
return {
...params.cfg,
channels: {
...params.cfg.channels,
discord: {
...channelConfig,
enabled: true,
accounts: {
...accounts,
[accountId]: {
...accountConfig,
...params.patch,
enabled: true,
},
},
},
},
};
}
export function setSetupChannelEnabled(
cfg: OpenClawConfig,
channel: string,
enabled: boolean,
): OpenClawConfig {
const channelConfig = (cfg.channels?.[channel] as Record<string, unknown> | undefined) ?? {};
return {
...cfg,
channels: {
...cfg.channels,
[channel]: {
...channelConfig,
enabled,
},
},
};
}
export function patchChannelConfigForAccount(params: {
cfg: OpenClawConfig;
channel: "discord";
accountId: string;
patch: Record<string, unknown>;
}): OpenClawConfig {
return patchDiscordChannelConfigForAccount({
cfg: params.cfg,
accountId: params.accountId,
patch: params.patch,
});
}
export function createLegacyCompatChannelDmPolicy(params: {
label: string;
channel: "discord";
promptAllowFrom?: ChannelSetupDmPolicy["promptAllowFrom"];
}): ChannelSetupDmPolicy {
return {
label: params.label,
channel: params.channel,
policyKey: `channels.${params.channel}.dmPolicy`,
allowFromKey: `channels.${params.channel}.allowFrom`,
getCurrent: (cfg) =>
(
cfg.channels?.[params.channel] as
| {
dmPolicy?: "open" | "pairing" | "allowlist";
dm?: { policy?: "open" | "pairing" | "allowlist" };
}
| undefined
)?.dmPolicy ??
(
cfg.channels?.[params.channel] as
| {
dmPolicy?: "open" | "pairing" | "allowlist";
dm?: { policy?: "open" | "pairing" | "allowlist" };
}
| undefined
)?.dm?.policy ??
"pairing",
setPolicy: (cfg, policy) =>
patchDiscordChannelConfigForAccount({
cfg,
accountId: DEFAULT_ACCOUNT_ID,
patch: {
dmPolicy: policy,
...(policy === "open"
? {
allowFrom: [
...new Set(
[
...(((
cfg.channels?.discord as { allowFrom?: Array<string | number> } | undefined
)?.allowFrom ?? []) as Array<string | number>),
"*",
]
.map((value) => String(value).trim())
.filter(Boolean),
),
],
}
: {}),
},
}),
...(params.promptAllowFrom ? { promptAllowFrom: params.promptAllowFrom } : {}),
};
}
async function noteChannelLookupFailure(params: {
prompter: Pick<WizardPrompter, "note">;
label: string;
error: unknown;
}) {
await params.prompter.note(
`Channel lookup failed; keeping entries as typed. ${String(params.error)}`,
params.label,
);
}
export function createAccountScopedAllowFromSection(params: {
credentialInputKey?: NonNullable<ChannelSetupWizard["allowFrom"]>["credentialInputKey"];
helpTitle?: string;
helpLines?: string[];
message: string;
placeholder: string;
invalidWithoutCredentialNote: string;
parseId: NonNullable<NonNullable<ChannelSetupWizard["allowFrom"]>["parseId"]>;
resolveEntries: NonNullable<NonNullable<ChannelSetupWizard["allowFrom"]>["resolveEntries"]>;
}): NonNullable<ChannelSetupWizard["allowFrom"]> {
return {
...(params.helpTitle ? { helpTitle: params.helpTitle } : {}),
...(params.helpLines ? { helpLines: params.helpLines } : {}),
...(params.credentialInputKey ? { credentialInputKey: params.credentialInputKey } : {}),
message: params.message,
placeholder: params.placeholder,
invalidWithoutCredentialNote: params.invalidWithoutCredentialNote,
parseId: params.parseId,
resolveEntries: params.resolveEntries,
apply: ({ cfg, accountId, allowFrom }) =>
patchDiscordChannelConfigForAccount({
cfg,
accountId,
patch: { dmPolicy: "allowlist", allowFrom },
}),
};
}
export function createAccountScopedGroupAccessSection<TResolved>(params: {
label: string;
placeholder: string;
helpTitle?: string;
helpLines?: string[];
skipAllowlistEntries?: boolean;
currentPolicy: NonNullable<ChannelSetupWizard["groupAccess"]>["currentPolicy"];
currentEntries: NonNullable<ChannelSetupWizard["groupAccess"]>["currentEntries"];
updatePrompt: NonNullable<ChannelSetupWizard["groupAccess"]>["updatePrompt"];
resolveAllowlist?: NonNullable<
NonNullable<ChannelSetupWizard["groupAccess"]>["resolveAllowlist"]
>;
fallbackResolved: (entries: string[]) => TResolved;
applyAllowlist: (params: {
cfg: OpenClawConfig;
accountId: string;
resolved: TResolved;
}) => OpenClawConfig;
}): NonNullable<ChannelSetupWizard["groupAccess"]> {
return {
label: params.label,
placeholder: params.placeholder,
...(params.helpTitle ? { helpTitle: params.helpTitle } : {}),
...(params.helpLines ? { helpLines: params.helpLines } : {}),
...(params.skipAllowlistEntries ? { skipAllowlistEntries: true } : {}),
currentPolicy: params.currentPolicy,
currentEntries: params.currentEntries,
updatePrompt: params.updatePrompt,
setPolicy: ({ cfg, accountId, policy }) =>
patchDiscordChannelConfigForAccount({
cfg,
accountId,
patch: { groupPolicy: policy },
}),
...(params.resolveAllowlist
? {
resolveAllowlist: async ({ cfg, accountId, credentialValues, entries, prompter }) => {
try {
return await params.resolveAllowlist!({
cfg,
accountId,
credentialValues,
entries,
prompter,
});
} catch (error) {
await noteChannelLookupFailure({
prompter,
label: params.label,
error,
});
return params.fallbackResolved(entries);
}
},
}
: {}),
applyAllowlist: ({ cfg, accountId, resolved }) =>
params.applyAllowlist({
cfg,
accountId,
resolved: resolved as TResolved,
}),
};
}
export function createAllowlistSetupWizardProxy<TGroupResolved>(params: {
loadWizard: () => Promise<ChannelSetupWizard>;
createBase: (handlers: {
promptAllowFrom: NonNullable<ChannelSetupDmPolicy["promptAllowFrom"]>;
resolveAllowFromEntries: NonNullable<
NonNullable<ChannelSetupWizard["allowFrom"]>["resolveEntries"]
>;
resolveGroupAllowlist: NonNullable<
NonNullable<NonNullable<ChannelSetupWizard["groupAccess"]>["resolveAllowlist"]>
>;
}) => ChannelSetupWizard;
fallbackResolvedGroupAllowlist: (entries: string[]) => TGroupResolved;
}) {
return params.createBase({
promptAllowFrom: async ({ cfg, prompter, accountId }) => {
const wizard = await params.loadWizard();
if (!wizard.dmPolicy?.promptAllowFrom) {
return cfg;
}
return await wizard.dmPolicy.promptAllowFrom({ cfg, prompter, accountId });
},
resolveAllowFromEntries: async ({ cfg, accountId, credentialValues, entries }) => {
const wizard = await params.loadWizard();
if (!wizard.allowFrom) {
return entries.map((input) => ({ input, resolved: false, id: null }));
}
return await wizard.allowFrom.resolveEntries({
cfg,
accountId,
credentialValues,
entries,
});
},
resolveGroupAllowlist: async ({ cfg, accountId, credentialValues, entries, prompter }) => {
const wizard = await params.loadWizard();
if (!wizard.groupAccess?.resolveAllowlist) {
return params.fallbackResolvedGroupAllowlist(entries) as Awaited<
ReturnType<
NonNullable<NonNullable<ChannelSetupWizard["groupAccess"]>["resolveAllowlist"]>
>
>;
}
return (await wizard.groupAccess.resolveAllowlist({
cfg,
accountId,
credentialValues,
entries,
prompter,
})) as Awaited<
ReturnType<NonNullable<NonNullable<ChannelSetupWizard["groupAccess"]>["resolveAllowlist"]>>
>;
},
});
}
export async function resolveEntriesWithOptionalToken<TResult>(params: {
token?: string | null;
entries: string[];
buildWithoutToken: (input: string) => TResult;
resolveEntries: (params: { token: string; entries: string[] }) => Promise<TResult[]>;
}): Promise<TResult[]> {
const token = params.token?.trim();
if (!token) {
return params.entries.map(params.buildWithoutToken);
}
return await params.resolveEntries({
token,
entries: params.entries,
});
}
export async function promptLegacyChannelAllowFromForAccount(params: {
cfg: OpenClawConfig;
prompter: WizardPrompter;
accountId?: string;
noteTitle: string;
noteLines: string[];
message: string;
placeholder: string;
parseId: (value: string) => string | null;
invalidWithoutTokenNote: string;
resolveEntries: (params: {
token: string;
entries: string[];
}) => Promise<Array<{ input: string; resolved: boolean; id?: string | null }>>;
resolveToken: (accountId: string) => string | null | undefined;
resolveExisting: (accountId: string, cfg: OpenClawConfig) => Array<string | number>;
}): Promise<OpenClawConfig> {
const accountId = normalizeAccountId(
params.accountId ?? resolveDefaultDiscordSetupAccountId(params.cfg),
);
await params.prompter.note(params.noteLines.join("\n"), params.noteTitle);
const token = params.resolveToken(accountId);
const existing = params.resolveExisting(accountId, params.cfg);
while (true) {
const entry = await params.prompter.text({
message: params.message,
placeholder: params.placeholder,
initialValue: existing[0] ? String(existing[0]) : undefined,
validate: (value) => (String(value ?? "").trim() ? undefined : "Required"),
});
const parts = splitSetupEntries(String(entry));
if (!token) {
const ids = parts.map(params.parseId).filter(Boolean) as string[];
if (ids.length !== parts.length) {
await params.prompter.note(params.invalidWithoutTokenNote, params.noteTitle);
continue;
}
return patchDiscordChannelConfigForAccount({
cfg: params.cfg,
accountId,
patch: {
dmPolicy: "allowlist",
allowFrom: mergeAllowFromEntries(existing, ids),
},
});
}
const results = await params.resolveEntries({ token, entries: parts }).catch(() => null);
if (!results) {
await params.prompter.note("Failed to resolve usernames. Try again.", params.noteTitle);
continue;
}
const unresolved = results.filter((result) => !result.resolved || !result.id);
if (unresolved.length > 0) {
await params.prompter.note(
`Could not resolve: ${unresolved.map((result) => result.input).join(", ")}`,
params.noteTitle,
);
continue;
}
return patchDiscordChannelConfigForAccount({
cfg: params.cfg,
accountId,
patch: {
dmPolicy: "allowlist",
allowFrom: mergeAllowFromEntries(
existing,
results.map((result) => result.id as string),
),
},
});
}
}

View File

@@ -1,20 +1,26 @@
import {
resolveEntriesWithOptionalToken,
type OpenClawConfig,
promptLegacyChannelAllowFromForAccount,
type WizardPrompter,
} from "openclaw/plugin-sdk/setup";
import { type ChannelSetupWizard } from "openclaw/plugin-sdk/setup";
type ChannelSetupWizard,
} from "openclaw/plugin-sdk/setup-runtime";
import { formatDocsLink } from "openclaw/plugin-sdk/setup-tools";
import { resolveDefaultDiscordAccountId, resolveDiscordAccount } from "./accounts.js";
import { resolveDiscordChannelAllowlist } from "./resolve-channels.js";
import { resolveDiscordUserAllowlist } from "./resolve-users.js";
import {
resolveDefaultDiscordSetupAccountId,
resolveDiscordSetupAccountConfig,
} from "./setup-account-state.js";
import {
createDiscordSetupWizardBase,
DISCORD_TOKEN_HELP_LINES,
parseDiscordAllowFromId,
setDiscordGuildChannelAllowlist,
} from "./setup-core.js";
import {
promptLegacyChannelAllowFromForAccount,
resolveEntriesWithOptionalToken,
} from "./setup-runtime-helpers.js";
import { resolveDiscordToken } from "./token.js";
const channel = "discord" as const;
@@ -48,13 +54,8 @@ async function promptDiscordAllowFrom(params: {
}): Promise<OpenClawConfig> {
return await promptLegacyChannelAllowFromForAccount({
cfg: params.cfg,
channel,
prompter: params.prompter,
accountId: params.accountId,
defaultAccountId: resolveDefaultDiscordAccountId(params.cfg),
resolveAccount: (cfg, accountId) => resolveDiscordAccount({ cfg, accountId }),
resolveExisting: (account) => account.config.allowFrom ?? account.config.dm?.allowFrom ?? [],
resolveToken: (account) => account.token,
noteTitle: "Discord allowlist",
noteLines: [
"Allowlist Discord DMs by username (we resolve to user ids).",
@@ -69,6 +70,11 @@ async function promptDiscordAllowFrom(params: {
placeholder: "@alice, 123456789012345678",
parseId: parseDiscordAllowFromId,
invalidWithoutTokenNote: "Bot token missing; use numeric user ids (or mention form) only.",
resolveExisting: (accountId, cfg) => {
const account = resolveDiscordSetupAccountConfig({ cfg, accountId }).config;
return account.allowFrom ?? account.dm?.allowFrom ?? [];
},
resolveToken: (accountId) => resolveDiscordToken(params.cfg, { accountId }).token,
resolveEntries: async ({ token, entries }) =>
(
await resolveDiscordUserAllowlist({
@@ -91,7 +97,7 @@ async function resolveDiscordGroupAllowlist(params: {
}) {
return await resolveEntriesWithOptionalToken({
token:
resolveDiscordAccount({ cfg: params.cfg, accountId: params.accountId }).token ||
resolveDiscordToken(params.cfg, { accountId: params.accountId }).token ||
(typeof params.credentialValues.token === "string" ? params.credentialValues.token : ""),
entries: params.entries,
buildWithoutToken: (input) => ({
@@ -111,7 +117,7 @@ export const discordSetupWizard: ChannelSetupWizard = createDiscordSetupWizardBa
resolveAllowFromEntries: async ({ cfg, accountId, credentialValues, entries }) =>
await resolveDiscordAllowFromEntries({
token:
resolveDiscordAccount({ cfg, accountId }).token ||
resolveDiscordToken(cfg, { accountId }).token ||
(typeof credentialValues.token === "string" ? credentialValues.token : ""),
entries,
}),

View File

@@ -100,7 +100,6 @@ function createHandlerHarness() {
mediaMaxBytes: 5 * 1024 * 1024,
startupMs: Date.now() - 120_000,
startupGraceMs: 60_000,
dropPreStartupMessages: false,
directTracker: {
isDirectMessage: vi.fn().mockResolvedValue(true),
},

View File

@@ -590,7 +590,6 @@ describe("matrix monitor handler pairing account scope", () => {
mediaMaxBytes: 10_000_000,
startupMs: 0,
startupGraceMs: 0,
dropPreStartupMessages: false,
directTracker: {
isDirectMessage: async () => false,
},

View File

@@ -115,7 +115,6 @@ describe("createMatrixRoomMessageHandler thread root media", () => {
mediaMaxBytes: 5 * 1024 * 1024,
startupMs: Date.now() - 120_000,
startupGraceMs: 60_000,
dropPreStartupMessages: false,
directTracker: {
isDirectMessage: vi.fn().mockResolvedValue(true),
},

View File

@@ -77,6 +77,14 @@
"types": "./dist/plugin-sdk/setup.d.ts",
"default": "./dist/plugin-sdk/setup.js"
},
"./plugin-sdk/setup-adapter-runtime": {
"types": "./dist/plugin-sdk/setup-adapter-runtime.d.ts",
"default": "./dist/plugin-sdk/setup-adapter-runtime.js"
},
"./plugin-sdk/setup-runtime": {
"types": "./dist/plugin-sdk/setup-runtime.d.ts",
"default": "./dist/plugin-sdk/setup-runtime.js"
},
"./plugin-sdk/channel-setup": {
"types": "./dist/plugin-sdk/channel-setup.d.ts",
"default": "./dist/plugin-sdk/channel-setup.js"

View File

@@ -9,6 +9,8 @@
"runtime",
"runtime-env",
"setup",
"setup-adapter-runtime",
"setup-runtime",
"channel-setup",
"setup-tools",
"config-runtime",

View File

@@ -1,9 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import * as acpSessionManager from "../acp/control-plane/manager.js";
import type {
AcpCloseSessionInput,
AcpInitializeSessionInput,
} from "../acp/control-plane/manager.types.js";
import type { AcpInitializeSessionInput } from "../acp/control-plane/manager.types.js";
import {
clearRuntimeConfigSnapshot,
setRuntimeConfigSnapshot,

View File

@@ -1,4 +1,4 @@
import fs from "node:fs/promises";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { expect, vi } from "vitest";
@@ -7,7 +7,11 @@ import {
createThreadBindingManager as createDiscordThreadBindingManager,
} from "../../../../extensions/discord/runtime-api.js";
import { createFeishuThreadBindingManager } from "../../../../extensions/feishu/api.js";
import { createMatrixThreadBindingManager } from "../../../../extensions/matrix/api.js";
import {
createMatrixThreadBindingManager,
resetMatrixThreadBindingsForTests,
} from "../../../../extensions/matrix/api.js";
import { setMatrixRuntime } from "../../../../extensions/matrix/index.js";
import { createTelegramThreadBindingManager } from "../../../../extensions/telegram/runtime-api.js";
import type { OpenClawConfig } from "../../../config/config.js";
import {
@@ -181,6 +185,12 @@ function expectClearedSessionBinding(params: {
const telegramDescribeMessageToolMock = vi.fn();
const discordDescribeMessageToolMock = vi.fn();
const sendMessageMatrixMock = vi.hoisted(() =>
vi.fn(async (to: string, _message: string, opts?: { threadId?: string }) => ({
messageId: opts?.threadId ? "$matrix-thread" : "$matrix-root",
roomId: to.replace(/^room:/, ""),
})),
);
bundledChannelRuntimeSetters.setTelegramRuntime({
channel: {
@@ -213,6 +223,48 @@ bundledChannelRuntimeSetters.setLineRuntime({
},
} as never);
vi.mock("../../../../extensions/matrix/src/matrix/send.js", async () => {
const actual = await vi.importActual<
typeof import("../../../../extensions/matrix/src/matrix/send.js")
>("../../../../extensions/matrix/src/matrix/send.js");
return {
...actual,
sendMessageMatrix: sendMessageMatrixMock,
};
});
const matrixSessionBindingStateDir = fs.mkdtempSync(
path.join(os.tmpdir(), "openclaw-matrix-session-binding-contract-"),
);
const matrixSessionBindingAuth = {
accountId: "ops",
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "token",
} as const;
function resetMatrixSessionBindingStateDir() {
fs.rmSync(matrixSessionBindingStateDir, { recursive: true, force: true });
fs.mkdirSync(matrixSessionBindingStateDir, { recursive: true });
}
async function createContractMatrixThreadBindingManager() {
resetMatrixSessionBindingStateDir();
setMatrixRuntime({
state: {
resolveStateDir: () => matrixSessionBindingStateDir,
},
} as never);
return await createMatrixThreadBindingManager({
accountId: matrixSessionBindingAuth.accountId,
auth: matrixSessionBindingAuth,
client: {} as never,
idleTimeoutMs: 24 * 60 * 60 * 1000,
maxAgeMs: 0,
enableSweeper: false,
});
}
export const pluginContractRegistry: PluginContractEntry[] = bundledChannelPlugins.map(
(plugin) => ({
id: plugin.id,
@@ -595,24 +647,6 @@ const baseSessionBindingCfg = {
session: { mainKey: "main", scope: "per-sender" },
} satisfies OpenClawConfig;
async function createContractMatrixThreadBindingManager() {
const stateDir = await fs.mkdtemp(path.join(os.tmpdir(), "matrix-contract-thread-bindings-"));
return await createMatrixThreadBindingManager({
accountId: "ops",
auth: {
accountId: "ops",
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "token",
},
client: {} as never,
stateDir,
idleTimeoutMs: 24 * 60 * 60 * 1000,
maxAgeMs: 0,
enableSweeper: false,
});
}
export const sessionBindingContractRegistry: SessionBindingContractEntry[] = [
{
id: "discord",
@@ -744,47 +778,43 @@ export const sessionBindingContractRegistry: SessionBindingContractEntry[] = [
await createContractMatrixThreadBindingManager();
return getSessionBindingService().getCapabilities({
channel: "matrix",
accountId: "ops",
accountId: matrixSessionBindingAuth.accountId,
});
},
bindAndResolve: async () => {
await createContractMatrixThreadBindingManager();
const service = getSessionBindingService();
const binding = await service.bind({
targetSessionKey: "agent:matrix:subagent:child-1",
targetSessionKey: "agent:matrix:child:thread-1",
targetKind: "subagent",
conversation: {
channel: "matrix",
accountId: "ops",
conversationId: "!room:example",
accountId: matrixSessionBindingAuth.accountId,
conversationId: "$thread",
parentConversationId: "!room:example",
},
placement: "child",
placement: "current",
metadata: {
label: "codex-matrix",
introText: "intro root",
},
});
expectResolvedSessionBinding({
channel: "matrix",
accountId: "ops",
conversationId: "$root",
parentConversationId: "!room:example",
targetSessionKey: "agent:matrix:subagent:child-1",
accountId: matrixSessionBindingAuth.accountId,
conversationId: "$thread",
targetSessionKey: "agent:matrix:child:thread-1",
});
return binding;
},
unbindAndVerify: unbindAndExpectClearedSessionBinding,
cleanup: async () => {
const manager = await createContractMatrixThreadBindingManager();
manager.stop();
expect(
getSessionBindingService().resolveByConversation({
channel: "matrix",
accountId: "ops",
conversationId: "$root",
parentConversationId: "!room:example",
}),
).toBeNull();
resetMatrixThreadBindingsForTests();
resetMatrixSessionBindingStateDir();
expectClearedSessionBinding({
channel: "matrix",
accountId: matrixSessionBindingAuth.accountId,
conversationId: "$thread",
});
},
},
{

View File

@@ -485,7 +485,7 @@ export function installSessionBindingContractSuite(params: {
expectedCapabilities: SessionBindingCapabilities;
}) {
it("registers the expected session binding capabilities", async () => {
expect(await params.getCapabilities()).toEqual(params.expectedCapabilities);
expect(await Promise.resolve(params.getCapabilities())).toEqual(params.expectedCapabilities);
});
it("binds and resolves a session binding through the shared service", async () => {

View File

@@ -1,10 +1,6 @@
import type { OpenClawConfig } from "../../config/config.js";
import type { DmPolicy, GroupPolicy } from "../../config/types.js";
import type { SecretInput } from "../../config/types.secrets.js";
import {
promptSecretRefForSetup,
resolveSecretInputModeForEnvSelection,
} from "../../plugins/provider-auth-input.js";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../routing/session-key.js";
import type { WizardPrompter } from "../../wizard/prompts.js";
import {
@@ -18,6 +14,15 @@ import type {
} from "./setup-wizard-types.js";
import type { ChannelSetupWizard, ChannelSetupWizardAllowFromEntry } from "./setup-wizard.js";
let providerAuthInputPromise:
| Promise<typeof import("../../plugins/provider-auth-input.js")>
| undefined;
function loadProviderAuthInput() {
providerAuthInputPromise ??= import("../../plugins/provider-auth-input.js");
return providerAuthInputPromise;
}
export const promptAccountId: PromptAccountId = async (params: PromptAccountIdParams) => {
const existingIds = params.listAccountIds(params.cfg);
const initial = params.currentId?.trim() || params.defaultAccountId || DEFAULT_ACCOUNT_ID;
@@ -994,6 +999,8 @@ export async function promptSingleChannelSecretInput(params: {
inputPrompt: string;
preferredEnvVar?: string;
}): Promise<SingleChannelSecretInputPromptResult> {
const { promptSecretRefForSetup, resolveSecretInputModeForEnvSelection } =
await loadProviderAuthInput();
const selectedMode = await resolveSecretInputModeForEnvSelection({
prompter: params.prompter as WizardPrompter,
explicitMode: params.secretInputMode,

View File

@@ -314,6 +314,12 @@ function isUnsupportedSecretsResolveError(err: unknown): boolean {
);
}
function isDirectRuntimeWebTargetPath(path: string): boolean {
return (
path === "tools.web.fetch.firecrawl.apiKey" || /^tools\.web\.search\.[^.]+\.apiKey$/.test(path)
);
}
async function resolveCommandSecretRefsLocally(params: {
config: OpenClawConfig;
commandName: string;
@@ -329,12 +335,22 @@ async function resolveCommandSecretRefsLocally(params: {
env: process.env,
});
const localResolutionDiagnostics: string[] = [];
const discoveredTargets = discoverConfigSecretTargetsByIds(sourceConfig, params.targetIds).filter(
(target) => !params.allowedPaths || params.allowedPaths.has(target.path),
);
const runtimeWebTargets = discoveredTargets.filter((target) =>
targetsRuntimeWebPath(target.path),
);
collectConfigAssignments({
config: structuredClone(params.config),
context,
});
if (
targetsRuntimeWebResolution({ targetIds: params.targetIds, allowedPaths: params.allowedPaths })
targetsRuntimeWebResolution({
targetIds: params.targetIds,
allowedPaths: params.allowedPaths,
}) &&
!runtimeWebTargets.every((target) => isDirectRuntimeWebTargetPath(target.path))
) {
try {
await resolveRuntimeWebTools({
@@ -359,13 +375,7 @@ async function resolveCommandSecretRefsLocally(params: {
);
const runtimeWebActivePaths = new Set<string>();
const runtimeWebInactiveDiagnostics: string[] = [];
for (const target of discoverConfigSecretTargetsByIds(sourceConfig, params.targetIds)) {
if (!targetsRuntimeWebPath(target.path)) {
continue;
}
if (params.allowedPaths && !params.allowedPaths.has(target.path)) {
continue;
}
for (const target of runtimeWebTargets) {
const runtimeState = classifyRuntimeWebTargetPathState({
config: sourceConfig,
path: target.path,
@@ -390,10 +400,7 @@ async function resolveCommandSecretRefsLocally(params: {
.filter((warning) => !params.allowedPaths || params.allowedPaths.has(warning.path))
.map((warning) => warning.message);
const activePaths = new Set(context.assignments.map((assignment) => assignment.path));
for (const target of discoverConfigSecretTargetsByIds(sourceConfig, params.targetIds)) {
if (params.allowedPaths && !params.allowedPaths.has(target.path)) {
continue;
}
for (const target of discoveredTargets) {
await resolveTargetSecretLocally({
target,
sourceConfig,

View File

@@ -1,8 +1,17 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import type { ChannelPlugin } from "../../channels/plugins/types.js";
import type {
ChannelDirectoryEntryKind,
ChannelMessagingAdapter,
ChannelOutboundAdapter,
ChannelPlugin,
} from "../../channels/plugins/types.js";
import type { OpenClawConfig } from "../../config/config.js";
import { setActivePluginRegistry } from "../../plugins/runtime.js";
import { createOutboundTestPlugin, createTestRegistry } from "../../test-utils/channel-plugins.js";
import {
createChannelTestPluginBase,
createTestRegistry,
} from "../../test-utils/channel-plugins.js";
import { createIMessageTestPlugin } from "../../test-utils/imessage-test-plugin.js";
import { runMessageAction } from "./message-action-runner.js";
const slackConfig = {
@@ -52,48 +61,112 @@ const runDrySend = (params: {
action: "send",
});
const createDryRunPlugin = (id: "slack" | "whatsapp" | "telegram" | "imessage"): ChannelPlugin => {
const plugin = createOutboundTestPlugin({
id,
outbound: {} as never,
});
type ResolvedTestTarget = { to: string; kind: ChannelDirectoryEntryKind };
const resolveTarget: NonNullable<
NonNullable<ChannelPlugin["messaging"]>["targetResolver"]
>["resolveTarget"] = async ({ input, normalized }) => {
if (id === "slack") {
const raw = input.replace(/^#/, "");
return { to: `channel:${raw}`, kind: "group", source: "normalized" };
}
if (id === "telegram") {
return { to: `group:${normalized || input}`, kind: "group", source: "normalized" };
}
if (id === "whatsapp") {
return { to: `group:${normalized || input}`, kind: "group", source: "normalized" };
}
return { to: normalized || input, kind: "user", source: "normalized" };
const directOutbound: ChannelOutboundAdapter = { deliveryMode: "direct" };
function normalizeSlackTarget(raw: string): string {
const trimmed = raw.trim();
if (!trimmed) {
return trimmed;
}
if (trimmed.startsWith("#")) {
return trimmed.slice(1).trim();
}
if (/^channel:/i.test(trimmed)) {
return trimmed.replace(/^channel:/i, "").trim();
}
if (/^user:/i.test(trimmed)) {
return trimmed.replace(/^user:/i, "").trim();
}
const mention = trimmed.match(/^<@([A-Z0-9]+)>$/i);
if (mention?.[1]) {
return mention[1];
}
return trimmed;
}
function createConfiguredTestPlugin(params: {
id: "slack" | "telegram" | "whatsapp";
isConfigured: (cfg: OpenClawConfig) => boolean;
normalizeTarget: (raw: string) => string | undefined;
resolveTarget: (input: string) => ResolvedTestTarget | null;
}): ChannelPlugin {
const messaging: ChannelMessagingAdapter = {
normalizeTarget: params.normalizeTarget,
targetResolver: {
looksLikeId: (raw) => Boolean(params.resolveTarget(raw.trim())),
hint: "<id>",
resolveTarget: async (resolverParams) => {
const resolved = params.resolveTarget(resolverParams.input);
return resolved ? { ...resolved, source: "normalized" } : null;
},
},
inferTargetChatType: (inferParams) =>
params.resolveTarget(inferParams.to)?.kind === "user" ? "direct" : "group",
};
return {
...plugin,
config: {
listAccountIds: () => ["default"],
resolveAccount: () => ({}),
},
messaging: {
inferTargetChatType: ({ to }) => {
if (id === "imessage" && to.startsWith("imessage:")) {
return "direct";
}
return "group";
...createChannelTestPluginBase({
id: params.id,
config: {
listAccountIds: () => ["default"],
resolveAccount: () => ({ enabled: true }),
isConfigured: (_account, cfg) => params.isConfigured(cfg),
},
targetResolver: {
looksLikeId: () => true,
resolveTarget,
},
},
}),
outbound: directOutbound,
messaging,
};
};
}
const slackTestPlugin = createConfiguredTestPlugin({
id: "slack",
isConfigured: (cfg) => Boolean(cfg.channels?.slack?.botToken?.trim()),
normalizeTarget: (raw) => normalizeSlackTarget(raw) || undefined,
resolveTarget: (input) => {
const normalized = normalizeSlackTarget(input);
if (!normalized) {
return null;
}
if (/^[A-Z0-9]+$/i.test(normalized)) {
const kind = /^U/i.test(normalized) ? "user" : "group";
return { to: normalized, kind };
}
return null;
},
});
const telegramTestPlugin = createConfiguredTestPlugin({
id: "telegram",
isConfigured: (cfg) => Boolean(cfg.channels?.telegram?.botToken?.trim()),
normalizeTarget: (raw) => raw.trim() || undefined,
resolveTarget: (input) => {
const normalized = input.trim();
if (!normalized) {
return null;
}
return {
to: normalized.replace(/^telegram:/i, ""),
kind: normalized.startsWith("@") ? "user" : "group",
};
},
});
const whatsappTestPlugin = createConfiguredTestPlugin({
id: "whatsapp",
isConfigured: (cfg) => Boolean(cfg.channels?.whatsapp),
normalizeTarget: (raw) => raw.trim() || undefined,
resolveTarget: (input) => {
const normalized = input.trim();
if (!normalized) {
return null;
}
return {
to: normalized,
kind: normalized.endsWith("@g.us") ? "group" : "user",
};
},
});
describe("runMessageAction context isolation", () => {
beforeEach(() => {
@@ -102,22 +175,22 @@ describe("runMessageAction context isolation", () => {
{
pluginId: "slack",
source: "test",
plugin: createDryRunPlugin("slack"),
plugin: slackTestPlugin,
},
{
pluginId: "whatsapp",
source: "test",
plugin: createDryRunPlugin("whatsapp"),
plugin: whatsappTestPlugin,
},
{
pluginId: "telegram",
source: "test",
plugin: createDryRunPlugin("telegram"),
plugin: telegramTestPlugin,
},
{
pluginId: "imessage",
source: "test",
plugin: createDryRunPlugin("imessage"),
plugin: createIMessageTestPlugin(),
},
]),
);

View File

@@ -58,7 +58,6 @@ export const MESSAGE_ACTION_TARGET_MODE: Record<ChannelMessageActionName, Messag
ban: "none",
"set-profile": "none",
"set-presence": "none",
"set-profile": "none",
"download-file": "none",
};

View File

@@ -55,7 +55,6 @@ export type {
OpenClawPluginDefinition,
PluginCommandContext,
PluginLogger,
PluginCommandContext,
PluginInteractiveTelegramHandlerContext,
} from "../plugins/types.js";
export type { OpenClawConfig } from "../config/config.js";

View File

@@ -0,0 +1 @@
export { createEnvPatchedAccountSetupAdapter } from "../channels/plugins/setup-helpers.js";

View File

@@ -0,0 +1,25 @@
export type { OpenClawConfig } from "../config/config.js";
export type { WizardPrompter } from "../wizard/prompts.js";
export type { ChannelSetupAdapter } from "../channels/plugins/types.adapters.js";
export type { ChannelSetupDmPolicy } from "../channels/plugins/setup-wizard-types.js";
export type {
ChannelSetupWizard,
ChannelSetupWizardAllowFromEntry,
} from "../channels/plugins/setup-wizard.js";
export { DEFAULT_ACCOUNT_ID } from "../routing/session-key.js";
export { createEnvPatchedAccountSetupAdapter } from "../channels/plugins/setup-helpers.js";
export {
createAccountScopedAllowFromSection,
createAccountScopedGroupAccessSection,
createLegacyCompatChannelDmPolicy,
parseMentionOrPrefixedId,
patchChannelConfigForAccount,
promptLegacyChannelAllowFromForAccount,
resolveEntriesWithOptionalToken,
setSetupChannelEnabled,
} from "../channels/plugins/setup-wizard-helpers.js";
export { createAllowlistSetupWizardProxy } from "../channels/plugins/setup-wizard-proxy.js";