refactor: move matrix msteams twitch to setup wizard

This commit is contained in:
Peter Steinberger 2026-03-15 18:23:06 -07:00
parent 40be12db96
commit 0958aea112
No known key found for this signature in database
10 changed files with 316 additions and 332 deletions

View File

@ -6,12 +6,10 @@ import {
createScopedDmSecurityResolver,
} from "openclaw/plugin-sdk/compat";
import {
applyAccountNameToChannelSection,
buildChannelConfigSchema,
buildProbeChannelStatusSummary,
collectStatusIssuesFromLastError,
DEFAULT_ACCOUNT_ID,
normalizeAccountId,
PAIRING_APPROVED_MESSAGE,
type ChannelPlugin,
} from "openclaw/plugin-sdk/matrix";
@ -30,9 +28,8 @@ import {
type ResolvedMatrixAccount,
} from "./matrix/accounts.js";
import { normalizeMatrixAllowList, normalizeMatrixUserId } from "./matrix/monitor/allowlist.js";
import { matrixOnboardingAdapter } from "./onboarding.js";
import { getMatrixRuntime } from "./runtime.js";
import { normalizeSecretInputString } from "./secret-input.js";
import { matrixSetupAdapter, matrixSetupWizard } from "./setup-surface.js";
import type { CoreConfig } from "./types.js";
// Mutex for serializing account startup (workaround for concurrent dynamic import race condition)
@ -66,38 +63,6 @@ function normalizeMatrixMessagingTarget(raw: string): string | undefined {
return stripped || undefined;
}
function buildMatrixConfigUpdate(
cfg: CoreConfig,
input: {
homeserver?: string;
userId?: string;
accessToken?: string;
password?: string;
deviceName?: string;
initialSyncLimit?: number;
},
): CoreConfig {
const existing = cfg.channels?.matrix ?? {};
return {
...cfg,
channels: {
...cfg.channels,
matrix: {
...existing,
enabled: true,
...(input.homeserver ? { homeserver: input.homeserver } : {}),
...(input.userId ? { userId: input.userId } : {}),
...(input.accessToken ? { accessToken: input.accessToken } : {}),
...(input.password ? { password: input.password } : {}),
...(input.deviceName ? { deviceName: input.deviceName } : {}),
...(typeof input.initialSyncLimit === "number"
? { initialSyncLimit: input.initialSyncLimit }
: {}),
},
},
};
}
const matrixConfigAccessors = createScopedAccountConfigAccessors({
resolveAccount: ({ cfg, accountId }) =>
resolveMatrixAccountConfig({ cfg: cfg as CoreConfig, accountId }),
@ -132,7 +97,7 @@ const resolveMatrixDmPolicy = createScopedDmSecurityResolver<ResolvedMatrixAccou
export const matrixPlugin: ChannelPlugin<ResolvedMatrixAccount> = {
id: "matrix",
meta,
onboarding: matrixOnboardingAdapter,
setupWizard: matrixSetupWizard,
pairing: {
idLabel: "matrixUserId",
normalizeAllowEntry: (entry) => entry.replace(/^matrix:/i, ""),
@ -316,67 +281,7 @@ export const matrixPlugin: ChannelPlugin<ResolvedMatrixAccount> = {
(await loadMatrixChannelRuntime()).resolveMatrixTargets({ cfg, inputs, kind, runtime }),
},
actions: matrixMessageActions,
setup: {
resolveAccountId: ({ accountId }) => normalizeAccountId(accountId),
applyAccountName: ({ cfg, accountId, name }) =>
applyAccountNameToChannelSection({
cfg: cfg as CoreConfig,
channelKey: "matrix",
accountId,
name,
}),
validateInput: ({ input }) => {
if (input.useEnv) {
return null;
}
if (!input.homeserver?.trim()) {
return "Matrix requires --homeserver";
}
const accessToken = input.accessToken?.trim();
const password = normalizeSecretInputString(input.password);
const userId = input.userId?.trim();
if (!accessToken && !password) {
return "Matrix requires --access-token or --password";
}
if (!accessToken) {
if (!userId) {
return "Matrix requires --user-id when using --password";
}
if (!password) {
return "Matrix requires --password when using --user-id";
}
}
return null;
},
applyAccountConfig: ({ cfg, input }) => {
const namedConfig = applyAccountNameToChannelSection({
cfg: cfg as CoreConfig,
channelKey: "matrix",
accountId: DEFAULT_ACCOUNT_ID,
name: input.name,
});
if (input.useEnv) {
return {
...namedConfig,
channels: {
...namedConfig.channels,
matrix: {
...namedConfig.channels?.matrix,
enabled: true,
},
},
} as CoreConfig;
}
return buildMatrixConfigUpdate(namedConfig as CoreConfig, {
homeserver: input.homeserver?.trim(),
userId: input.userId?.trim(),
accessToken: input.accessToken?.trim(),
password: normalizeSecretInputString(input.password),
deviceName: input.deviceName?.trim(),
initialSyncLimit: input.initialSyncLimit,
});
},
},
setup: matrixSetupAdapter,
outbound: {
deliveryMode: "direct",
chunker: (text, limit) => getMatrixRuntime().channel.text.chunkMarkdownText!(text, limit),

View File

@ -1,19 +1,29 @@
import type { DmPolicy } from "openclaw/plugin-sdk/matrix";
import type { ChannelOnboardingDmPolicy } from "../../../src/channels/plugins/onboarding-types.js";
import { promptChannelAccessConfig } from "../../../src/channels/plugins/onboarding/channel-access.js";
import {
addWildcardAllowFrom,
buildSingleChannelSecretPromptState,
formatResolvedUnresolvedNote,
formatDocsLink,
hasConfiguredSecretInput,
mergeAllowFromEntries,
promptSingleChannelSecretInput,
promptChannelAccessConfig,
setTopLevelChannelGroupPolicy,
type SecretInput,
type ChannelOnboardingAdapter,
type ChannelOnboardingDmPolicy,
type WizardPrompter,
} from "openclaw/plugin-sdk/matrix";
} from "../../../src/channels/plugins/onboarding/helpers.js";
import {
applyAccountNameToChannelSection,
migrateBaseNameToDefaultAccount,
} from "../../../src/channels/plugins/setup-helpers.js";
import type { ChannelSetupWizard } from "../../../src/channels/plugins/setup-wizard.js";
import type { ChannelSetupAdapter } from "../../../src/channels/plugins/types.adapters.js";
import type { OpenClawConfig } from "../../../src/config/config.js";
import type { DmPolicy } from "../../../src/config/types.js";
import type { SecretInput } from "../../../src/config/types.secrets.js";
import {
hasConfiguredSecretInput,
normalizeSecretInputString,
} from "../../../src/config/types.secrets.js";
import { formatResolvedUnresolvedNote } from "../../../src/plugin-sdk/resolution-notes.js";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../src/routing/session-key.js";
import { formatDocsLink } from "../../../src/terminal/links.js";
import type { WizardPrompter } from "../../../src/wizard/prompts.js";
import { listMatrixDirectoryGroupsLive } from "./directory-live.js";
import { resolveMatrixAccount } from "./matrix/accounts.js";
import { ensureMatrixSdkInstalled, isMatrixSdkAvailable } from "./matrix/deps.js";
@ -22,6 +32,38 @@ import type { CoreConfig } from "./types.js";
const channel = "matrix" as const;
function buildMatrixConfigUpdate(
cfg: CoreConfig,
input: {
homeserver?: string;
userId?: string;
accessToken?: string;
password?: string;
deviceName?: string;
initialSyncLimit?: number;
},
): CoreConfig {
const existing = cfg.channels?.matrix ?? {};
return {
...cfg,
channels: {
...cfg.channels,
matrix: {
...existing,
enabled: true,
...(input.homeserver ? { homeserver: input.homeserver } : {}),
...(input.userId ? { userId: input.userId } : {}),
...(input.accessToken ? { accessToken: input.accessToken } : {}),
...(input.password ? { password: input.password } : {}),
...(input.deviceName ? { deviceName: input.deviceName } : {}),
...(typeof input.initialSyncLimit === "number"
? { initialSyncLimit: input.initialSyncLimit }
: {}),
},
},
};
}
function setMatrixDmPolicy(cfg: CoreConfig, policy: DmPolicy) {
const allowFrom =
policy === "open" ? addWildcardAllowFrom(cfg.channels?.matrix?.dm?.allowFrom) : undefined;
@ -168,7 +210,7 @@ function setMatrixGroupRooms(cfg: CoreConfig, roomKeys: string[]) {
};
}
const dmPolicy: ChannelOnboardingDmPolicy = {
const matrixDmPolicy: ChannelOnboardingDmPolicy = {
label: "Matrix",
channel,
policyKey: "channels.matrix.dm.policy",
@ -178,26 +220,100 @@ const dmPolicy: ChannelOnboardingDmPolicy = {
promptAllowFrom: promptMatrixAllowFrom,
};
export const matrixOnboardingAdapter: ChannelOnboardingAdapter = {
channel,
getStatus: async ({ cfg }) => {
const account = resolveMatrixAccount({ cfg: cfg as CoreConfig });
const configured = account.configured;
const sdkReady = isMatrixSdkAvailable();
return {
channel,
configured,
statusLines: [
`Matrix: ${configured ? "configured" : "needs homeserver + access token or password"}`,
],
selectionHint: !sdkReady
? "install @vector-im/matrix-bot-sdk"
: configured
? "configured"
: "needs auth",
};
export const matrixSetupAdapter: ChannelSetupAdapter = {
resolveAccountId: ({ accountId }) => normalizeAccountId(accountId),
applyAccountName: ({ cfg, accountId, name }) =>
applyAccountNameToChannelSection({
cfg: cfg as CoreConfig,
channelKey: channel,
accountId,
name,
}),
validateInput: ({ input }) => {
if (input.useEnv) {
return null;
}
if (!input.homeserver?.trim()) {
return "Matrix requires --homeserver";
}
const accessToken = input.accessToken?.trim();
const password = normalizeSecretInputString(input.password);
const userId = input.userId?.trim();
if (!accessToken && !password) {
return "Matrix requires --access-token or --password";
}
if (!accessToken) {
if (!userId) {
return "Matrix requires --user-id when using --password";
}
if (!password) {
return "Matrix requires --password when using --user-id";
}
}
return null;
},
configure: async ({ cfg, runtime, prompter, forceAllowFrom }) => {
applyAccountConfig: ({ cfg, accountId, input }) => {
const namedConfig = applyAccountNameToChannelSection({
cfg: cfg as CoreConfig,
channelKey: channel,
accountId,
name: input.name,
});
const next =
accountId !== DEFAULT_ACCOUNT_ID
? migrateBaseNameToDefaultAccount({
cfg: namedConfig,
channelKey: channel,
})
: namedConfig;
if (input.useEnv) {
return {
...next,
channels: {
...next.channels,
matrix: {
...next.channels?.matrix,
enabled: true,
},
},
} as CoreConfig;
}
return buildMatrixConfigUpdate(next as CoreConfig, {
homeserver: input.homeserver?.trim(),
userId: input.userId?.trim(),
accessToken: input.accessToken?.trim(),
password: normalizeSecretInputString(input.password),
deviceName: input.deviceName?.trim(),
initialSyncLimit: input.initialSyncLimit,
});
},
};
export const matrixSetupWizard: ChannelSetupWizard = {
channel,
resolveAccountIdForConfigure: () => DEFAULT_ACCOUNT_ID,
resolveShouldPromptAccountIds: () => false,
status: {
configuredLabel: "configured",
unconfiguredLabel: "needs homeserver + access token or password",
configuredHint: "configured",
unconfiguredHint: "needs auth",
resolveConfigured: ({ cfg }) => resolveMatrixAccount({ cfg: cfg as CoreConfig }).configured,
resolveStatusLines: ({ cfg }) => {
const configured = resolveMatrixAccount({ cfg: cfg as CoreConfig }).configured;
return [
`Matrix: ${configured ? "configured" : "needs homeserver + access token or password"}`,
];
},
resolveSelectionHint: ({ cfg, configured }) => {
if (!isMatrixSdkAvailable()) {
return "install @vector-im/matrix-bot-sdk";
}
return configured ? "configured" : "needs auth";
},
},
credentials: [],
finalize: async ({ cfg, runtime, prompter, forceAllowFrom }) => {
let next = cfg as CoreConfig;
await ensureMatrixSdkInstalled({
runtime,
@ -231,16 +347,11 @@ export const matrixOnboardingAdapter: ChannelOnboardingAdapter = {
initialValue: true,
});
if (useEnv) {
next = {
...next,
channels: {
...next.channels,
matrix: {
...next.channels?.matrix,
enabled: true,
},
},
};
next = matrixSetupAdapter.applyAccountConfig({
cfg: next,
accountId: DEFAULT_ACCOUNT_ID,
input: { useEnv: true },
}) as CoreConfig;
if (forceAllowFrom) {
next = await promptMatrixAllowFrom({ cfg: next, prompter });
}
@ -284,7 +395,6 @@ export const matrixOnboardingAdapter: ChannelOnboardingAdapter = {
}
if (!accessToken && !passwordConfigured()) {
// Ask auth method FIRST before asking for user ID
const authMode = await prompter.select({
message: "Matrix auth method",
options: [
@ -300,11 +410,8 @@ export const matrixOnboardingAdapter: ChannelOnboardingAdapter = {
validate: (value) => (value?.trim() ? undefined : "Required"),
}),
).trim();
// With access token, we can fetch the userId automatically - don't prompt for it
// The client.ts will use whoami() to get it
userId = "";
} else {
// Password auth requires user ID upfront
userId = String(
await prompter.text({
message: "Matrix user ID",
@ -333,7 +440,7 @@ export const matrixOnboardingAdapter: ChannelOnboardingAdapter = {
const passwordResult = await promptSingleChannelSecretInput({
cfg: next,
prompter,
providerHint: "matrix",
providerHint: channel,
credentialLabel: "password",
accountConfigured: passwordPromptState.accountConfigured,
canUseEnv: passwordPromptState.canUseEnv,
@ -359,7 +466,6 @@ export const matrixOnboardingAdapter: ChannelOnboardingAdapter = {
}),
).trim();
// Ask about E2EE encryption
const enableEncryption = await prompter.confirm({
message: "Enable end-to-end encryption (E2EE)?",
initialValue: existing.encryption ?? false,
@ -375,7 +481,7 @@ export const matrixOnboardingAdapter: ChannelOnboardingAdapter = {
homeserver,
userId: userId || undefined,
accessToken: accessToken || undefined,
password: password,
password,
deviceName: deviceName || undefined,
encryption: enableEncryption || undefined,
},
@ -451,7 +557,7 @@ export const matrixOnboardingAdapter: ChannelOnboardingAdapter = {
return { cfg: next };
},
dmPolicy,
dmPolicy: matrixDmPolicy,
disable: (cfg) => ({
...(cfg as CoreConfig),
channels: {

View File

@ -16,7 +16,6 @@ import {
MSTeamsConfigSchema,
PAIRING_APPROVED_MESSAGE,
} from "openclaw/plugin-sdk/msteams";
import { msteamsOnboardingAdapter } from "./onboarding.js";
import { resolveMSTeamsGroupToolPolicy } from "./policy.js";
import {
normalizeMSTeamsMessagingTarget,
@ -27,6 +26,7 @@ import {
resolveMSTeamsUserAllowlist,
} from "./resolve-allowlist.js";
import { getMSTeamsRuntime } from "./runtime.js";
import { msteamsSetupAdapter, msteamsSetupWizard } from "./setup-surface.js";
import { resolveMSTeamsCredentials } from "./token.js";
type ResolvedMSTeamsAccount = {
@ -56,7 +56,7 @@ export const msteamsPlugin: ChannelPlugin<ResolvedMSTeamsAccount> = {
...meta,
aliases: [...meta.aliases],
},
onboarding: msteamsOnboardingAdapter,
setupWizard: msteamsSetupWizard,
pairing: {
idLabel: "msteamsUserId",
normalizeAllowEntry: (entry) => entry.replace(/^(msteams|user):/i, ""),
@ -145,19 +145,7 @@ export const msteamsPlugin: ChannelPlugin<ResolvedMSTeamsAccount> = {
});
},
},
setup: {
resolveAccountId: () => DEFAULT_ACCOUNT_ID,
applyAccountConfig: ({ cfg }) => ({
...cfg,
channels: {
...cfg.channels,
msteams: {
...cfg.channels?.msteams,
enabled: true,
},
},
}),
},
setup: msteamsSetupAdapter,
messaging: {
normalizeTarget: normalizeMSTeamsMessagingTarget,
targetResolver: {

View File

@ -1,21 +1,19 @@
import type {
ChannelOnboardingAdapter,
ChannelOnboardingDmPolicy,
OpenClawConfig,
DmPolicy,
WizardPrompter,
MSTeamsTeamConfig,
} from "openclaw/plugin-sdk/msteams";
import type { ChannelOnboardingDmPolicy } from "../../../src/channels/plugins/onboarding-types.js";
import { promptChannelAccessConfig } from "../../../src/channels/plugins/onboarding/channel-access.js";
import {
DEFAULT_ACCOUNT_ID,
formatDocsLink,
mergeAllowFromEntries,
promptChannelAccessConfig,
setTopLevelChannelAllowFrom,
setTopLevelChannelDmPolicyWithAllowFrom,
setTopLevelChannelGroupPolicy,
splitOnboardingEntries,
} from "openclaw/plugin-sdk/msteams";
} from "../../../src/channels/plugins/onboarding/helpers.js";
import type { ChannelSetupWizard } from "../../../src/channels/plugins/setup-wizard.js";
import type { ChannelSetupAdapter } from "../../../src/channels/plugins/types.adapters.js";
import type { OpenClawConfig } from "../../../src/config/config.js";
import type { DmPolicy, MSTeamsTeamConfig } from "../../../src/config/types.js";
import { DEFAULT_ACCOUNT_ID } from "../../../src/routing/session-key.js";
import { formatDocsLink } from "../../../src/terminal/links.js";
import type { WizardPrompter } from "../../../src/wizard/prompts.js";
import {
parseMSTeamsTeamEntry,
resolveMSTeamsChannelAllowlist,
@ -29,7 +27,7 @@ const channel = "msteams" as const;
function setMSTeamsDmPolicy(cfg: OpenClawConfig, dmPolicy: DmPolicy) {
return setTopLevelChannelDmPolicyWithAllowFrom({
cfg,
channel: "msteams",
channel,
dmPolicy,
});
}
@ -37,7 +35,7 @@ function setMSTeamsDmPolicy(cfg: OpenClawConfig, dmPolicy: DmPolicy) {
function setMSTeamsAllowFrom(cfg: OpenClawConfig, allowFrom: string[]): OpenClawConfig {
return setTopLevelChannelAllowFrom({
cfg,
channel: "msteams",
channel,
allowFrom,
});
}
@ -138,7 +136,7 @@ async function promptMSTeamsAllowFrom(params: {
async function noteMSTeamsCredentialHelp(prompter: WizardPrompter): Promise<void> {
await prompter.note(
[
"1) Azure Bot registration get App ID + Tenant ID",
"1) Azure Bot registration -> get App ID + Tenant ID",
"2) Add a client secret (App Password)",
"3) Set webhook URL + messaging endpoint",
"Tip: you can also set MSTEAMS_APP_ID / MSTEAMS_APP_PASSWORD / MSTEAMS_TENANT_ID.",
@ -154,7 +152,7 @@ function setMSTeamsGroupPolicy(
): OpenClawConfig {
return setTopLevelChannelGroupPolicy({
cfg,
channel: "msteams",
channel,
groupPolicy,
enabled: true,
});
@ -193,7 +191,7 @@ function setMSTeamsTeamsAllowlist(
};
}
const dmPolicy: ChannelOnboardingDmPolicy = {
const msteamsDmPolicy: ChannelOnboardingDmPolicy = {
label: "MS Teams",
channel,
policyKey: "channels.msteams.dmPolicy",
@ -203,21 +201,46 @@ const dmPolicy: ChannelOnboardingDmPolicy = {
promptAllowFrom: promptMSTeamsAllowFrom,
};
export const msteamsOnboardingAdapter: ChannelOnboardingAdapter = {
export const msteamsSetupAdapter: ChannelSetupAdapter = {
resolveAccountId: () => DEFAULT_ACCOUNT_ID,
applyAccountConfig: ({ cfg }) => ({
...cfg,
channels: {
...cfg.channels,
msteams: {
...cfg.channels?.msteams,
enabled: true,
},
},
}),
};
export const msteamsSetupWizard: ChannelSetupWizard = {
channel,
getStatus: async ({ cfg }) => {
const configured =
Boolean(resolveMSTeamsCredentials(cfg.channels?.msteams)) ||
hasConfiguredMSTeamsCredentials(cfg.channels?.msteams);
return {
channel,
configured,
statusLines: [`MS Teams: ${configured ? "configured" : "needs app credentials"}`],
selectionHint: configured ? "configured" : "needs app creds",
quickstartScore: configured ? 2 : 0,
};
resolveAccountIdForConfigure: () => DEFAULT_ACCOUNT_ID,
resolveShouldPromptAccountIds: () => false,
status: {
configuredLabel: "configured",
unconfiguredLabel: "needs app credentials",
configuredHint: "configured",
unconfiguredHint: "needs app creds",
configuredScore: 2,
unconfiguredScore: 0,
resolveConfigured: ({ cfg }) => {
return (
Boolean(resolveMSTeamsCredentials(cfg.channels?.msteams)) ||
hasConfiguredMSTeamsCredentials(cfg.channels?.msteams)
);
},
resolveStatusLines: ({ cfg }) => {
const configured =
Boolean(resolveMSTeamsCredentials(cfg.channels?.msteams)) ||
hasConfiguredMSTeamsCredentials(cfg.channels?.msteams);
return [`MS Teams: ${configured ? "configured" : "needs app credentials"}`];
},
},
configure: async ({ cfg, prompter }) => {
credentials: [],
finalize: async ({ cfg, prompter }) => {
const resolved = resolveMSTeamsCredentials(cfg.channels?.msteams);
const hasConfigCreds = hasConfiguredMSTeamsCredentials(cfg.channels?.msteams);
const canUseEnv = Boolean(
@ -243,13 +266,11 @@ export const msteamsOnboardingAdapter: ChannelOnboardingAdapter = {
initialValue: true,
});
if (keepEnv) {
next = {
...next,
channels: {
...next.channels,
msteams: { ...next.channels?.msteams, enabled: true },
},
};
next = msteamsSetupAdapter.applyAccountConfig({
cfg: next,
accountId: DEFAULT_ACCOUNT_ID,
input: {},
});
} else {
({ appId, appPassword, tenantId } = await promptMSTeamsCredentials(prompter));
}
@ -308,17 +329,17 @@ export const msteamsOnboardingAdapter: ChannelOnboardingAdapter = {
.filter(Boolean) as Array<{ teamKey: string; channelKey?: string }>;
if (accessConfig.entries.length > 0 && resolveMSTeamsCredentials(next.channels?.msteams)) {
try {
const resolved = await resolveMSTeamsChannelAllowlist({
const resolvedEntries = await resolveMSTeamsChannelAllowlist({
cfg: next,
entries: accessConfig.entries,
});
const resolvedChannels = resolved.filter(
const resolvedChannels = resolvedEntries.filter(
(entry) => entry.resolved && entry.teamId && entry.channelId,
);
const resolvedTeams = resolved.filter(
const resolvedTeams = resolvedEntries.filter(
(entry) => entry.resolved && entry.teamId && !entry.channelId,
);
const unresolved = resolved
const unresolved = resolvedEntries
.filter((entry) => !entry.resolved)
.map((entry) => entry.input);
@ -370,7 +391,7 @@ export const msteamsOnboardingAdapter: ChannelOnboardingAdapter = {
return { cfg: next, accountId: DEFAULT_ACCOUNT_ID };
},
dmPolicy,
dmPolicy: msteamsDmPolicy,
disable: (cfg) => ({
...cfg,
channels: {

View File

@ -1,5 +1,5 @@
/**
* Tests for onboarding.ts helpers
* Tests for setup-surface.ts helpers
*
* Tests cover:
* - promptToken helper
@ -15,11 +15,6 @@ import type { WizardPrompter } from "openclaw/plugin-sdk/twitch";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { TwitchAccountConfig } from "./types.js";
vi.mock("openclaw/plugin-sdk/twitch", () => ({
formatDocsLink: (url: string, fallback: string) => fallback || url,
promptChannelAccessConfig: vi.fn(async () => null),
}));
// Mock the helpers we're testing
const mockPromptText = vi.fn();
const mockPromptConfirm = vi.fn();
@ -35,7 +30,7 @@ const mockAccount: TwitchAccountConfig = {
channel: "#testchannel",
};
describe("onboarding helpers", () => {
describe("setup surface helpers", () => {
beforeEach(() => {
vi.clearAllMocks();
});
@ -46,7 +41,7 @@ describe("onboarding helpers", () => {
describe("promptToken", () => {
it("should return existing token when user confirms to keep it", async () => {
const { promptToken } = await import("./onboarding.js");
const { promptToken } = await import("./setup-surface.js");
mockPromptConfirm.mockResolvedValue(true);
@ -61,7 +56,7 @@ describe("onboarding helpers", () => {
});
it("should prompt for new token when user doesn't keep existing", async () => {
const { promptToken } = await import("./onboarding.js");
const { promptToken } = await import("./setup-surface.js");
mockPromptConfirm.mockResolvedValue(false);
mockPromptText.mockResolvedValue("oauth:newtoken123");
@ -77,7 +72,7 @@ describe("onboarding helpers", () => {
});
it("should use env token as initial value when provided", async () => {
const { promptToken } = await import("./onboarding.js");
const { promptToken } = await import("./setup-surface.js");
mockPromptConfirm.mockResolvedValue(false);
mockPromptText.mockResolvedValue("oauth:fromenv");
@ -92,7 +87,7 @@ describe("onboarding helpers", () => {
});
it("should validate token format", async () => {
const { promptToken } = await import("./onboarding.js");
const { promptToken } = await import("./setup-surface.js");
// Set up mocks - user doesn't want to keep existing token
mockPromptConfirm.mockResolvedValueOnce(false);
@ -124,7 +119,7 @@ describe("onboarding helpers", () => {
});
it("should return early when no existing token and no env token", async () => {
const { promptToken } = await import("./onboarding.js");
const { promptToken } = await import("./setup-surface.js");
mockPromptText.mockResolvedValue("oauth:newtoken");
@ -137,7 +132,7 @@ describe("onboarding helpers", () => {
describe("promptUsername", () => {
it("should prompt for username with validation", async () => {
const { promptUsername } = await import("./onboarding.js");
const { promptUsername } = await import("./setup-surface.js");
mockPromptText.mockResolvedValue("mybot");
@ -152,7 +147,7 @@ describe("onboarding helpers", () => {
});
it("should use existing username as initial value", async () => {
const { promptUsername } = await import("./onboarding.js");
const { promptUsername } = await import("./setup-surface.js");
mockPromptText.mockResolvedValue("testbot");
@ -168,7 +163,7 @@ describe("onboarding helpers", () => {
describe("promptClientId", () => {
it("should prompt for client ID with validation", async () => {
const { promptClientId } = await import("./onboarding.js");
const { promptClientId } = await import("./setup-surface.js");
mockPromptText.mockResolvedValue("abc123xyz");
@ -185,7 +180,7 @@ describe("onboarding helpers", () => {
describe("promptChannelName", () => {
it("should return channel name when provided", async () => {
const { promptChannelName } = await import("./onboarding.js");
const { promptChannelName } = await import("./setup-surface.js");
mockPromptText.mockResolvedValue("#mychannel");
@ -195,7 +190,7 @@ describe("onboarding helpers", () => {
});
it("should require a non-empty channel name", async () => {
const { promptChannelName } = await import("./onboarding.js");
const { promptChannelName } = await import("./setup-surface.js");
mockPromptText.mockResolvedValue("");
@ -210,7 +205,7 @@ describe("onboarding helpers", () => {
describe("promptRefreshTokenSetup", () => {
it("should return empty object when user declines", async () => {
const { promptRefreshTokenSetup } = await import("./onboarding.js");
const { promptRefreshTokenSetup } = await import("./setup-surface.js");
mockPromptConfirm.mockResolvedValue(false);
@ -224,7 +219,7 @@ describe("onboarding helpers", () => {
});
it("should prompt for credentials when user accepts", async () => {
const { promptRefreshTokenSetup } = await import("./onboarding.js");
const { promptRefreshTokenSetup } = await import("./setup-surface.js");
mockPromptConfirm
.mockResolvedValueOnce(true) // First call: useRefresh
@ -242,7 +237,7 @@ describe("onboarding helpers", () => {
});
it("should use existing values as initial prompts", async () => {
const { promptRefreshTokenSetup } = await import("./onboarding.js");
const { promptRefreshTokenSetup } = await import("./setup-surface.js");
const accountWithRefresh = {
...mockAccount,
@ -267,7 +262,7 @@ describe("onboarding helpers", () => {
describe("configureWithEnvToken", () => {
it("should return null when user declines env token", async () => {
const { configureWithEnvToken } = await import("./onboarding.js");
const { configureWithEnvToken } = await import("./setup-surface.js");
// Reset and set up mock - user declines env token
mockPromptConfirm.mockReset().mockResolvedValue(false as never);
@ -287,7 +282,7 @@ describe("onboarding helpers", () => {
});
it("should prompt for username and clientId when using env token", async () => {
const { configureWithEnvToken } = await import("./onboarding.js");
const { configureWithEnvToken } = await import("./setup-surface.js");
// Reset and set up mocks - user accepts env token
mockPromptConfirm.mockReset().mockResolvedValue(true as never);

View File

@ -12,10 +12,10 @@ import { twitchMessageActions } from "./actions.js";
import { removeClientManager } from "./client-manager-registry.js";
import { TwitchConfigSchema } from "./config-schema.js";
import { DEFAULT_ACCOUNT_ID, getAccountConfig, listAccountIds } from "./config.js";
import { twitchOnboardingAdapter } from "./onboarding.js";
import { twitchOutbound } from "./outbound.js";
import { probeTwitch } from "./probe.js";
import { resolveTwitchTargets } from "./resolver.js";
import { twitchSetupAdapter, twitchSetupWizard } from "./setup-surface.js";
import { collectTwitchStatusIssues } from "./status.js";
import { resolveTwitchToken } from "./token.js";
import type {
@ -51,8 +51,9 @@ export const twitchPlugin: ChannelPlugin<TwitchAccountConfig> = {
aliases: ["twitch-chat"],
} satisfies ChannelMeta,
/** Onboarding adapter */
onboarding: twitchOnboardingAdapter,
/** Setup wizard surface */
setup: twitchSetupAdapter,
setupWizard: twitchSetupWizard,
/** Pairing configuration */
pairing: {

View File

@ -1,25 +1,21 @@
/**
* Twitch onboarding adapter for CLI setup wizard.
* Twitch setup wizard surface for CLI setup.
*/
import type { OpenClawConfig } from "openclaw/plugin-sdk/twitch";
import {
formatDocsLink,
promptChannelAccessConfig,
type ChannelOnboardingAdapter,
type ChannelOnboardingDmPolicy,
type WizardPrompter,
} from "openclaw/plugin-sdk/twitch";
import type { ChannelOnboardingDmPolicy } from "../../../src/channels/plugins/onboarding-types.js";
import { promptChannelAccessConfig } from "../../../src/channels/plugins/onboarding/channel-access.js";
import type { ChannelSetupWizard } from "../../../src/channels/plugins/setup-wizard.js";
import type { ChannelSetupAdapter } from "../../../src/channels/plugins/types.adapters.js";
import type { OpenClawConfig } from "../../../src/config/config.js";
import { formatDocsLink } from "../../../src/terminal/links.js";
import type { WizardPrompter } from "../../../src/wizard/prompts.js";
import { DEFAULT_ACCOUNT_ID, getAccountConfig } from "./config.js";
import type { TwitchAccountConfig, TwitchRole } from "./types.js";
import { isAccountConfigured } from "./utils/twitch.js";
const channel = "twitch" as const;
/**
* Set Twitch account configuration
*/
function setTwitchAccount(
export function setTwitchAccount(
cfg: OpenClawConfig,
account: Partial<TwitchAccountConfig>,
): OpenClawConfig {
@ -59,9 +55,6 @@ function setTwitchAccount(
};
}
/**
* Note about Twitch setup
*/
async function noteTwitchSetupHelp(prompter: WizardPrompter): Promise<void> {
await prompter.note(
[
@ -77,17 +70,13 @@ async function noteTwitchSetupHelp(prompter: WizardPrompter): Promise<void> {
);
}
/**
* Prompt for Twitch OAuth token with early returns.
*/
async function promptToken(
export async function promptToken(
prompter: WizardPrompter,
account: TwitchAccountConfig | null,
envToken: string | undefined,
): Promise<string> {
const existingToken = account?.accessToken ?? "";
// If we have an existing token and no env var, ask if we should keep it
if (existingToken && !envToken) {
const keepToken = await prompter.confirm({
message: "Access token already configured. Keep it?",
@ -98,7 +87,6 @@ async function promptToken(
}
}
// Prompt for new token
return String(
await prompter.text({
message: "Twitch OAuth token (oauth:...)",
@ -117,10 +105,7 @@ async function promptToken(
).trim();
}
/**
* Prompt for Twitch username.
*/
async function promptUsername(
export async function promptUsername(
prompter: WizardPrompter,
account: TwitchAccountConfig | null,
): Promise<string> {
@ -133,10 +118,7 @@ async function promptUsername(
).trim();
}
/**
* Prompt for Twitch Client ID.
*/
async function promptClientId(
export async function promptClientId(
prompter: WizardPrompter,
account: TwitchAccountConfig | null,
): Promise<string> {
@ -149,27 +131,20 @@ async function promptClientId(
).trim();
}
/**
* Prompt for optional channel name.
*/
async function promptChannelName(
export async function promptChannelName(
prompter: WizardPrompter,
account: TwitchAccountConfig | null,
): Promise<string> {
const channelName = String(
return String(
await prompter.text({
message: "Channel to join",
initialValue: account?.channel ?? "",
validate: (value) => (value?.trim() ? undefined : "Required"),
}),
).trim();
return channelName;
}
/**
* Prompt for token refresh credentials (client secret and refresh token).
*/
async function promptRefreshTokenSetup(
export async function promptRefreshTokenSetup(
prompter: WizardPrompter,
account: TwitchAccountConfig | null,
): Promise<{ clientSecret?: string; refreshToken?: string }> {
@ -203,10 +178,7 @@ async function promptRefreshTokenSetup(
return { clientSecret, refreshToken };
}
/**
* Configure with env token path (returns early if user chooses env token).
*/
async function configureWithEnvToken(
export async function configureWithEnvToken(
cfg: OpenClawConfig,
prompter: WizardPrompter,
account: TwitchAccountConfig | null,
@ -228,7 +200,7 @@ async function configureWithEnvToken(
const cfgWithAccount = setTwitchAccount(cfg, {
username,
clientId,
accessToken: "", // Will use env var
accessToken: "",
enabled: true,
});
@ -239,9 +211,6 @@ async function configureWithEnvToken(
return { cfg: cfgWithAccount };
}
/**
* Set Twitch access control (role-based)
*/
function setTwitchAccessControl(
cfg: OpenClawConfig,
allowedRoles: TwitchRole[],
@ -259,14 +228,13 @@ function setTwitchAccessControl(
});
}
const dmPolicy: ChannelOnboardingDmPolicy = {
const twitchDmPolicy: ChannelOnboardingDmPolicy = {
label: "Twitch",
channel,
policyKey: "channels.twitch.allowedRoles", // Twitch uses roles instead of DM policy
policyKey: "channels.twitch.allowedRoles",
allowFromKey: "channels.twitch.accounts.default.allowFrom",
getCurrent: (cfg) => {
const account = getAccountConfig(cfg, DEFAULT_ACCOUNT_ID);
// Map allowedRoles to policy equivalent
const account = getAccountConfig(cfg as OpenClawConfig, DEFAULT_ACCOUNT_ID);
if (account?.allowedRoles?.includes("all")) {
return "open";
}
@ -278,10 +246,10 @@ const dmPolicy: ChannelOnboardingDmPolicy = {
setPolicy: (cfg, policy) => {
const allowedRoles: TwitchRole[] =
policy === "open" ? ["all"] : policy === "allowlist" ? [] : ["moderator"];
return setTwitchAccessControl(cfg, allowedRoles, true);
return setTwitchAccessControl(cfg as OpenClawConfig, allowedRoles, true);
},
promptAllowFrom: async ({ cfg, prompter }) => {
const account = getAccountConfig(cfg, DEFAULT_ACCOUNT_ID);
const account = getAccountConfig(cfg as OpenClawConfig, DEFAULT_ACCOUNT_ID);
const existingAllowFrom = account?.allowFrom ?? [];
const entry = await prompter.text({
@ -295,28 +263,43 @@ const dmPolicy: ChannelOnboardingDmPolicy = {
.map((s) => s.trim())
.filter(Boolean);
return setTwitchAccount(cfg, {
return setTwitchAccount(cfg as OpenClawConfig, {
...(account ?? undefined),
allowFrom,
});
},
};
export const twitchOnboardingAdapter: ChannelOnboardingAdapter = {
channel,
getStatus: async ({ cfg }) => {
const account = getAccountConfig(cfg, DEFAULT_ACCOUNT_ID);
const configured = account ? isAccountConfigured(account) : false;
export const twitchSetupAdapter: ChannelSetupAdapter = {
resolveAccountId: () => DEFAULT_ACCOUNT_ID,
applyAccountConfig: ({ cfg }) =>
setTwitchAccount(cfg, {
enabled: true,
}),
};
return {
channel,
configured,
statusLines: [`Twitch: ${configured ? "configured" : "needs username, token, and clientId"}`],
selectionHint: configured ? "configured" : "needs setup",
};
export const twitchSetupWizard: ChannelSetupWizard = {
channel,
resolveAccountIdForConfigure: () => DEFAULT_ACCOUNT_ID,
resolveShouldPromptAccountIds: () => false,
status: {
configuredLabel: "configured",
unconfiguredLabel: "needs username, token, and clientId",
configuredHint: "configured",
unconfiguredHint: "needs setup",
resolveConfigured: ({ cfg }) => {
const account = getAccountConfig(cfg as OpenClawConfig, DEFAULT_ACCOUNT_ID);
return account ? isAccountConfigured(account) : false;
},
resolveStatusLines: ({ cfg }) => {
const account = getAccountConfig(cfg as OpenClawConfig, DEFAULT_ACCOUNT_ID);
const configured = account ? isAccountConfigured(account) : false;
return [`Twitch: ${configured ? "configured" : "needs username, token, and clientId"}`];
},
},
configure: async ({ cfg, prompter, forceAllowFrom }) => {
const account = getAccountConfig(cfg, DEFAULT_ACCOUNT_ID);
credentials: [],
finalize: async ({ cfg, prompter, forceAllowFrom }) => {
const account = getAccountConfig(cfg as OpenClawConfig, DEFAULT_ACCOUNT_ID);
if (!account || !isAccountConfigured(account)) {
await noteTwitchSetupHelp(prompter);
@ -324,29 +307,27 @@ export const twitchOnboardingAdapter: ChannelOnboardingAdapter = {
const envToken = process.env.OPENCLAW_TWITCH_ACCESS_TOKEN?.trim();
// Check if env var is set and config is empty
if (envToken && !account?.accessToken) {
const envResult = await configureWithEnvToken(
cfg,
cfg as OpenClawConfig,
prompter,
account,
envToken,
forceAllowFrom,
dmPolicy,
twitchDmPolicy,
);
if (envResult) {
return envResult;
}
}
// Prompt for credentials
const username = await promptUsername(prompter, account);
const token = await promptToken(prompter, account, envToken);
const clientId = await promptClientId(prompter, account);
const channelName = await promptChannelName(prompter, account);
const { clientSecret, refreshToken } = await promptRefreshTokenSetup(prompter, account);
const cfgWithAccount = setTwitchAccount(cfg, {
const cfgWithAccount = setTwitchAccount(cfg as OpenClawConfig, {
username,
accessToken: token,
clientId,
@ -357,11 +338,10 @@ export const twitchOnboardingAdapter: ChannelOnboardingAdapter = {
});
const cfgWithAllowFrom =
forceAllowFrom && dmPolicy.promptAllowFrom
? await dmPolicy.promptAllowFrom({ cfg: cfgWithAccount, prompter })
forceAllowFrom && twitchDmPolicy.promptAllowFrom
? await twitchDmPolicy.promptAllowFrom({ cfg: cfgWithAccount, prompter })
: cfgWithAccount;
// Prompt for access control if allowFrom not set
if (!account?.allowFrom || account.allowFrom.length === 0) {
const accessConfig = await promptChannelAccessConfig({
prompter,
@ -384,14 +364,15 @@ export const twitchOnboardingAdapter: ChannelOnboardingAdapter = {
? ["moderator", "vip"]
: [];
const cfgWithAccessControl = setTwitchAccessControl(cfgWithAllowFrom, allowedRoles, true);
return { cfg: cfgWithAccessControl };
return {
cfg: setTwitchAccessControl(cfgWithAllowFrom, allowedRoles, true),
};
}
}
return { cfg: cfgWithAllowFrom };
},
dmPolicy,
dmPolicy: twitchDmPolicy,
disable: (cfg) => {
const twitch = (cfg.channels as Record<string, unknown>)?.twitch as
| Record<string, unknown>
@ -405,13 +386,3 @@ export const twitchOnboardingAdapter: ChannelOnboardingAdapter = {
};
},
};
// Export helper functions for testing
export {
promptToken,
promptUsername,
promptClientId,
promptChannelName,
promptRefreshTokenSetup,
configureWithEnvToken,
};

View File

@ -32,11 +32,6 @@ export {
} from "../channels/plugins/config-helpers.js";
export { buildChannelConfigSchema } from "../channels/plugins/config-schema.js";
export { formatPairingApproveHint } from "../channels/plugins/helpers.js";
export type {
ChannelOnboardingAdapter,
ChannelOnboardingDmPolicy,
} from "../channels/plugins/onboarding-types.js";
export { promptChannelAccessConfig } from "../channels/plugins/onboarding/channel-access.js";
export {
buildSingleChannelSecretPromptState,
addWildcardAllowFrom,
@ -113,3 +108,7 @@ export {
buildProbeChannelStatusSummary,
collectStatusIssuesFromLastError,
} from "./status-helpers.js";
export {
matrixSetupAdapter,
matrixSetupWizard,
} from "../../extensions/matrix/src/setup-surface.js";

View File

@ -32,11 +32,6 @@ export {
export { buildChannelConfigSchema } from "../channels/plugins/config-schema.js";
export { resolveChannelMediaMaxBytes } from "../channels/plugins/media-limits.js";
export { buildMediaPayload } from "../channels/plugins/media-payload.js";
export type {
ChannelOnboardingAdapter,
ChannelOnboardingDmPolicy,
} from "../channels/plugins/onboarding-types.js";
export { promptChannelAccessConfig } from "../channels/plugins/onboarding/channel-access.js";
export {
addWildcardAllowFrom,
mergeAllowFromEntries,
@ -122,3 +117,7 @@ export {
createDefaultChannelRuntimeState,
} from "./status-helpers.js";
export { normalizeStringEntries } from "../shared/string-normalization.js";
export {
msteamsSetupAdapter,
msteamsSetupWizard,
} from "../../extensions/msteams/src/setup-surface.js";

View File

@ -22,11 +22,6 @@ export type {
ChannelStatusIssue,
} from "../channels/plugins/types.js";
export type { ChannelPlugin } from "../channels/plugins/types.plugin.js";
export type {
ChannelOnboardingAdapter,
ChannelOnboardingDmPolicy,
} from "../channels/plugins/onboarding-types.js";
export { promptChannelAccessConfig } from "../channels/plugins/onboarding/channel-access.js";
export { createReplyPrefixOptions } from "../channels/reply-prefix.js";
export type { OpenClawConfig } from "../config/config.js";
export { MarkdownConfigSchema } from "../config/zod-schema.core.js";
@ -38,3 +33,7 @@ export type { OpenClawPluginApi } from "../plugins/types.js";
export type { RuntimeEnv } from "../runtime.js";
export { formatDocsLink } from "../terminal/links.js";
export type { WizardPrompter } from "../wizard/prompts.js";
export {
twitchSetupAdapter,
twitchSetupWizard,
} from "../../extensions/twitch/src/setup-surface.js";