Twitch: tighten setup account handling

This commit is contained in:
Gustavo Madeira Santana
2026-04-17 11:04:50 -04:00
parent 4453e698e5
commit c8967b087b
4 changed files with 142 additions and 19 deletions

View File

@@ -54,6 +54,30 @@ describe("getAccountConfig", () => {
expect(result?.username).toBe("secondbot");
});
it("normalizes account ids without reading inherited account properties", () => {
const accounts = Object.create({
inherited: {
username: "inherited-bot",
accessToken: "oauth:inherited",
},
}) as Record<string, unknown>;
accounts.Secondary = {
username: "secondbot",
accessToken: "oauth:secondary",
};
const cfg = {
channels: {
twitch: {
accounts,
},
},
};
expect(getAccountConfig(cfg, "SECONDARY\r\n")).toMatchObject({ username: "secondbot" });
expect(getAccountConfig(cfg, "inherited")).toBeNull();
});
it("returns null for non-existent account ID", () => {
const result = getAccountConfig(mockMultiAccountConfig, "nonexistent");
@@ -120,6 +144,21 @@ describe("listAccountIds", () => {
} as Parameters<typeof listAccountIds>[0]),
).toEqual(["default", "secondary"]);
});
it("normalizes configured account ids", () => {
expect(
listAccountIds({
channels: {
twitch: {
accounts: {
Secondary: { username: "secondbot" },
"Alerts\r\n\u001b[31m": { username: "alerts" },
},
},
},
} as Parameters<typeof listAccountIds>[0]),
).toEqual(["alerts-31m", "secondary"]);
});
});
describe("resolveDefaultTwitchAccountId", () => {

View File

@@ -1,4 +1,8 @@
import { listCombinedAccountIds } from "openclaw/plugin-sdk/account-resolution";
import {
listCombinedAccountIds,
normalizeAccountId,
resolveNormalizedAccountEntry,
} from "openclaw/plugin-sdk/account-resolution";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import { resolveTwitchToken, type TwitchTokenResolution } from "./token.js";
import type { TwitchAccountConfig } from "./types.js";
@@ -36,14 +40,19 @@ export function getAccountConfig(
}
const cfg = coreConfig as OpenClawConfig;
const normalizedAccountId = normalizeAccountId(accountId);
const twitch = cfg.channels?.twitch;
// Access accounts via unknown to handle union type (single-account vs multi-account)
const twitchRaw = twitch as Record<string, unknown> | undefined;
const accounts = twitchRaw?.accounts as Record<string, TwitchAccountConfig> | undefined;
// For default account, check base-level config first
if (accountId === DEFAULT_ACCOUNT_ID) {
const accountFromAccounts = accounts?.[DEFAULT_ACCOUNT_ID];
if (normalizedAccountId === DEFAULT_ACCOUNT_ID) {
const accountFromAccounts = resolveNormalizedAccountEntry(
accounts,
DEFAULT_ACCOUNT_ID,
normalizeAccountId,
);
// Base-level properties that can form an implicit default account
const baseLevel = {
@@ -87,11 +96,12 @@ export function getAccountConfig(
}
// For non-default accounts, only check accounts object
if (!accounts || !accounts[accountId]) {
const account = resolveNormalizedAccountEntry(accounts, normalizedAccountId, normalizeAccountId);
if (!account) {
return null;
}
return accounts[accountId] as TwitchAccountConfig | null;
return account;
}
/**
@@ -113,16 +123,19 @@ export function listAccountIds(cfg: OpenClawConfig): string[] {
typeof twitchRaw.channel === "string");
return listCombinedAccountIds({
configuredAccountIds: Object.keys(accountMap ?? {}),
configuredAccountIds: Object.keys(accountMap ?? {}).map((accountId) =>
normalizeAccountId(accountId),
),
implicitAccountId: hasBaseLevelConfig ? DEFAULT_ACCOUNT_ID : undefined,
});
}
export function resolveDefaultTwitchAccountId(cfg: OpenClawConfig): string {
const preferred =
const preferredRaw =
typeof cfg.channels?.twitch?.defaultAccount === "string"
? cfg.channels.twitch.defaultAccount.trim()
: "";
const preferred = preferredRaw ? normalizeAccountId(preferredRaw) : "";
const ids = listAccountIds(cfg);
if (preferred && ids.includes(preferred)) {
return preferred;
@@ -137,7 +150,9 @@ export function resolveTwitchAccountContext(
cfg: OpenClawConfig,
accountId?: string | null,
): ResolvedTwitchAccountContext {
const resolvedAccountId = accountId?.trim() || resolveDefaultTwitchAccountId(cfg);
const resolvedAccountId = accountId?.trim()
? normalizeAccountId(accountId)
: resolveDefaultTwitchAccountId(cfg);
const account = getAccountConfig(cfg, resolvedAccountId);
const tokenResolution = resolveTwitchToken(cfg, { accountId: resolvedAccountId });
return {

View File

@@ -208,7 +208,7 @@ describe("setup surface helpers", () => {
expect(defaultAccount?.clientId).toBe("test-client-id");
});
it("writes env-token setup to the configured default account", async () => {
it("skips env-token shortcut for non-default accounts", async () => {
mockPromptConfirm.mockReset().mockResolvedValue(true as never);
mockPromptText
.mockReset()
@@ -230,12 +230,9 @@ describe("setup surface helpers", () => {
{} as Parameters<typeof configureWithEnvToken>[5],
);
const secondaryAccount = result?.cfg.channels?.twitch?.accounts?.secondary as
| { username?: string; clientId?: string }
| undefined;
expect(secondaryAccount?.username).toBe("secondary-bot");
expect(secondaryAccount?.clientId).toBe("secondary-client");
expect(result?.cfg.channels?.twitch?.accounts?.default).toBeUndefined();
expect(result).toBeNull();
expect(mockPromptConfirm).not.toHaveBeenCalled();
expect(mockPromptText).not.toHaveBeenCalled();
});
});
@@ -402,6 +399,41 @@ describe("setup surface helpers", () => {
expect(twitch?.accounts?.secondary?.accessToken).toBe("oauth:secondary");
expect(twitch?.accounts?.default?.username).toBe("default-bot");
});
it("persists a token instead of using env-token shortcut for non-default finalize", async () => {
process.env.OPENCLAW_TWITCH_ACCESS_TOKEN = "oauth:fromenv";
mockPromptText
.mockReset()
.mockResolvedValueOnce("secondary-bot" as never)
.mockResolvedValueOnce("oauth:persisted" as never)
.mockResolvedValueOnce("secondary-client" as never)
.mockResolvedValueOnce("#secondary" as never);
mockPromptConfirm.mockReset().mockResolvedValue(false as never);
const result = await twitchSetupWizard.finalize?.({
cfg: {
channels: {
twitch: {
accounts: {},
},
},
} as Parameters<NonNullable<typeof twitchSetupWizard.finalize>>[0]["cfg"],
accountId: "secondary",
credentialValues: {},
runtime: {} as Parameters<NonNullable<typeof twitchSetupWizard.finalize>>[0]["runtime"],
prompter: mockPrompter,
options: {},
forceAllowFrom: false,
});
const twitch = result?.cfg?.channels?.twitch;
expect(twitch?.accounts?.secondary?.accessToken).toBe("oauth:persisted");
expect(mockPromptConfirm).toHaveBeenCalledTimes(1);
expect(mockPromptConfirm).toHaveBeenCalledWith({
message: "Enable automatic token refresh (requires client secret and refresh token)?",
initialValue: false,
});
});
});
describe("setup-only plugin config", () => {
@@ -431,5 +463,31 @@ describe("setup surface helpers", () => {
expect(twitchSetupPlugin.config.listAccountIds(cfg)).toEqual(["default", "secondary"]);
expect(twitchSetupPlugin.config.defaultAccountId?.(cfg)).toBe("secondary");
});
it("normalizes exposed account ids", () => {
const cfg = {
channels: {
twitch: {
accounts: {
Secondary: {
username: "secondary-bot",
accessToken: "oauth:secondary",
clientId: "secondary-client",
channel: "#secondary",
},
},
},
},
} as Parameters<typeof twitchSetupPlugin.config.listAccountIds>[0];
expect(twitchSetupPlugin.config.listAccountIds(cfg)).toEqual(["secondary"]);
expect(twitchSetupPlugin.config.defaultAccountId?.(cfg)).toBe("secondary");
expect(twitchSetupPlugin.config.resolveAccount(cfg, "SECONDARY\r\n").accountId).toBe(
"secondary",
);
expect(twitchSetupPlugin.config.resolveAccount(cfg, "SECONDARY\r\n").username).toBe(
"secondary-bot",
);
});
});
});

View File

@@ -208,6 +208,11 @@ export async function configureWithEnvToken(
dmPolicy: ChannelSetupDmPolicy,
accountId: string = resolveSetupAccountId(cfg),
): Promise<{ cfg: OpenClawConfig } | null> {
const resolvedAccountId = normalizeAccountId(accountId);
if (resolvedAccountId !== DEFAULT_ACCOUNT_ID) {
return null;
}
const useEnv = await prompter.confirm({
message: "Twitch env var OPENCLAW_TWITCH_ACCESS_TOKEN detected. Use env token?",
initialValue: true,
@@ -227,11 +232,17 @@ export async function configureWithEnvToken(
accessToken: "",
enabled: true,
},
accountId,
resolvedAccountId,
);
if (forceAllowFrom && dmPolicy.promptAllowFrom) {
return { cfg: await dmPolicy.promptAllowFrom({ cfg: cfgWithAccount, prompter, accountId }) };
return {
cfg: await dmPolicy.promptAllowFrom({
cfg: cfgWithAccount,
prompter,
accountId: resolvedAccountId,
}),
};
}
return { cfg: cfgWithAccount };
@@ -400,7 +411,7 @@ export const twitchSetupWizard: ChannelSetupWizard = {
const envToken = process.env.OPENCLAW_TWITCH_ACCESS_TOKEN?.trim();
if (envToken && !account?.accessToken) {
if (accountId === DEFAULT_ACCOUNT_ID && envToken && !account?.accessToken) {
const envResult = await configureWithEnvToken(
cfg,
prompter,
@@ -469,7 +480,7 @@ export const twitchSetupPlugin: ChannelPlugin<ResolvedTwitchAccount> = {
config: {
listAccountIds: (cfg) => listAccountIds(cfg),
resolveAccount: (cfg, accountId) => {
const resolvedAccountId = accountId ?? resolveDefaultTwitchAccountId(cfg);
const resolvedAccountId = normalizeAccountId(accountId ?? resolveDefaultTwitchAccountId(cfg));
const account = getAccountConfig(cfg, resolvedAccountId);
if (!account) {
return {