Twitch: add bundled setup entry (#68008)

Merged via squash.

Prepared head SHA: 59305356a0
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
This commit is contained in:
Gustavo Madeira Santana
2026-04-17 13:49:08 -04:00
committed by GitHub
parent bbac7773ff
commit 6184f17c91
11 changed files with 556 additions and 67 deletions

View File

@@ -30,6 +30,7 @@ Docs: https://docs.openclaw.ai
- OpenAI Codex/OAuth: keep OpenClaw as the canonical owner for imported Codex CLI OAuth sessions, stop writing refreshed credentials back into `.codex`, and prefer fresher OpenClaw credentials over stale imported CLI state so refresh recovery stays stable. Thanks @vincentkoc.
- OpenAI Codex/OAuth: treat the OpenAI TLS prerequisites probe as advisory instead of a hard blocker, so Codex sign-in can still proceed when the speculative Node/OpenSSL precheck fails but the real OAuth flow still works. Thanks @vincentkoc.
- Models status/OAuth health: align OAuth health reporting with the same effective credential view runtime uses, so expired refreshable sessions stop showing healthy by default and fresher imported Codex CLI credentials surface correctly in `models status`, doctor, and gateway auth status. Thanks @vincentkoc.
- Twitch/setup: load Twitch through the bundled setup-entry discovery path and keep setup/status account detection aligned with runtime config. (#68008) Thanks @gumadeiras.
## 2026.4.15

View File

@@ -0,0 +1,20 @@
import { describe, expect, it } from "vitest";
import { assertBundledChannelEntries } from "../../test/helpers/bundled-channel-entry.ts";
import entry from "./index.js";
import setupEntry from "./setup-entry.js";
describe("twitch bundled entries", () => {
assertBundledChannelEntries({
entry,
expectedId: "twitch",
expectedName: "Twitch",
setupEntry,
});
it("loads the setup-only channel plugin", () => {
const plugin = setupEntry.loadSetupPlugin?.();
expect(plugin?.id).toBe("twitch");
expect(plugin?.setupWizard).toBeDefined();
});
});

View File

@@ -15,6 +15,7 @@
"extensions": [
"./index.ts"
],
"setupEntry": "./setup-entry.ts",
"install": {
"minHostVersion": ">=2026.4.10"
},

View File

@@ -0,0 +1,9 @@
import { defineBundledChannelSetupEntry } from "openclaw/plugin-sdk/channel-entry-contract";
export default defineBundledChannelSetupEntry({
importMetaUrl: import.meta.url,
plugin: {
specifier: "./setup-plugin-api.js",
exportName: "twitchSetupPlugin",
},
});

View File

@@ -0,0 +1,3 @@
// Keep bundled setup entry imports narrow so setup loads do not pull the
// broader Twitch channel plugin surface.
export { twitchSetupPlugin } from "./src/setup-surface.js";

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", () => {
@@ -163,4 +202,32 @@ describe("resolveTwitchAccountContext", () => {
expect(context.accountId).toBe("secondary");
expect(context.account?.username).toBe("second-bot");
});
it("keeps account and token lookup aligned after account id normalization", () => {
const context = resolveTwitchAccountContext(
{
channels: {
twitch: {
accounts: {
Secondary: {
username: "second-bot",
accessToken: "oauth:second-token",
clientId: "second-client",
channel: "#second",
},
},
},
},
} as Parameters<typeof resolveTwitchAccountContext>[0],
"secondary",
);
expect(context.accountId).toBe("secondary");
expect(context.account?.username).toBe("second-bot");
expect(context.tokenResolution).toEqual({
token: "oauth:second-token",
source: "config",
});
expect(context.configured).toBe(true);
});
});

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

@@ -20,6 +20,8 @@ import {
promptRefreshTokenSetup,
promptToken,
promptUsername,
setTwitchAccount,
twitchSetupPlugin,
twitchSetupWizard,
} from "./setup-surface.js";
import type { TwitchAccountConfig } from "./types.js";
@@ -27,10 +29,13 @@ import type { TwitchAccountConfig } from "./types.js";
// Mock the helpers we're testing
const mockPromptText = vi.fn();
const mockPromptConfirm = vi.fn();
const mockPromptNote = vi.fn();
const mockPrompter: WizardPrompter = {
text: mockPromptText,
confirm: mockPromptConfirm,
note: mockPromptNote,
} as unknown as WizardPrompter;
const originalEnvToken = process.env.OPENCLAW_TWITCH_ACCESS_TOKEN;
const mockAccount: TwitchAccountConfig = {
username: "testbot",
@@ -45,6 +50,11 @@ describe("setup surface helpers", () => {
});
afterEach(() => {
if (originalEnvToken === undefined) {
delete process.env.OPENCLAW_TWITCH_ACCESS_TOKEN;
} else {
process.env.OPENCLAW_TWITCH_ACCESS_TOKEN = originalEnvToken;
}
// Don't restoreAllMocks as it breaks module-level mocks
});
@@ -198,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()
@@ -220,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();
});
});
@@ -251,5 +258,256 @@ describe("setup surface helpers", () => {
expect(lines).toEqual(["Twitch (secondary): configured"]);
});
it("reports status for the requested account override", async () => {
const lines = twitchSetupWizard.status?.resolveStatusLines?.({
cfg: {
channels: {
twitch: {
accounts: {
default: {
username: "default-bot",
accessToken: "oauth:default",
clientId: "default-client",
channel: "#default",
},
secondary: {
username: "secondary-bot",
accessToken: "oauth:secondary",
clientId: "secondary-client",
channel: "#secondary",
},
},
},
},
},
accountId: "secondary",
configured: true,
} as never);
expect(lines).toEqual(["Twitch (secondary): configured"]);
});
it("reports env-token default account setup as configured", async () => {
process.env.OPENCLAW_TWITCH_ACCESS_TOKEN = "oauth:fromenv";
const cfg = {
channels: {
twitch: {
accounts: {
default: {
username: "env-bot",
accessToken: "",
clientId: "env-client",
channel: "#env",
},
},
},
},
} as Parameters<NonNullable<typeof twitchSetupWizard.status>["resolveConfigured"]>[0]["cfg"];
expect(twitchSetupWizard.status?.resolveConfigured({ cfg })).toBe(true);
const account = twitchSetupPlugin.config.resolveAccount(cfg, "default");
expect(await twitchSetupPlugin.config.isConfigured?.(account, cfg)).toBe(true);
});
});
describe("setup wizard account routing", () => {
it("rejects reserved account ids before using them as config keys", () => {
expect(() =>
setTwitchAccount(
{} as Parameters<typeof setTwitchAccount>[0],
{
username: "reserved-bot",
accessToken: "oauth:reserved",
clientId: "reserved-client",
channel: "#reserved",
},
"__proto__",
),
).toThrow("Invalid Twitch account id");
expect(Object.prototype).not.toHaveProperty("username");
});
it("rejects reserved account ids before env-token writes", async () => {
await expect(
configureWithEnvToken(
{} as Parameters<typeof configureWithEnvToken>[0],
mockPrompter,
null,
"oauth:fromenv",
false,
{} as Parameters<typeof configureWithEnvToken>[5],
"__proto__",
),
).rejects.toThrow("Invalid Twitch account id");
expect(mockPromptConfirm).not.toHaveBeenCalled();
});
it("normalizes account ids before rendering status lines", () => {
expect(
twitchSetupWizard.status?.resolveStatusLines?.({
cfg: {},
accountId: "Alerts\r\n\u001b[31m",
configured: false,
} as never),
).toEqual(["Twitch (alerts-31m): needs username, token, and clientId"]);
});
it("reports account-scoped DM policy config keys", () => {
expect(
twitchSetupWizard.dmPolicy?.resolveConfigKeys?.(
{
channels: {
twitch: {
defaultAccount: "secondary",
},
},
} as Parameters<
NonNullable<NonNullable<typeof twitchSetupWizard.dmPolicy>["resolveConfigKeys"]>
>[0],
undefined,
),
).toEqual({
policyKey: "channels.twitch.accounts.secondary.allowedRoles",
allowFromKey: "channels.twitch.accounts.secondary.allowFrom",
});
expect(twitchSetupWizard.dmPolicy?.resolveConfigKeys?.({} as never, "alerts")).toEqual({
policyKey: "channels.twitch.accounts.alerts.allowedRoles",
allowFromKey: "channels.twitch.accounts.alerts.allowFrom",
});
});
it("writes to the requested account when defaultAccount is not created yet", async () => {
mockPromptText
.mockReset()
.mockResolvedValueOnce("secondary-bot" as never)
.mockResolvedValueOnce("oauth:secondary" 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: {
defaultAccount: "secondary",
accounts: {
default: {
username: "default-bot",
accessToken: "oauth:default",
clientId: "default-client",
channel: "#default",
},
},
},
},
} 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?.username).toBe("secondary-bot");
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", () => {
it("lists all configured Twitch accounts", () => {
const cfg = {
channels: {
twitch: {
defaultAccount: "secondary",
accounts: {
default: {
username: "default-bot",
accessToken: "oauth:default",
clientId: "default-client",
channel: "#default",
},
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(["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

@@ -2,6 +2,8 @@
* Twitch setup wizard surface for CLI setup.
*/
import { normalizeOptionalAccountId } from "openclaw/plugin-sdk/account-id";
import { getChatChannelMeta, type ChannelPlugin } from "openclaw/plugin-sdk/core";
import {
formatDocsLink,
type ChannelSetupAdapter,
@@ -9,16 +11,37 @@ import {
type ChannelSetupWizard,
type OpenClawConfig,
type WizardPrompter,
normalizeAccountId,
} from "openclaw/plugin-sdk/setup";
import { DEFAULT_ACCOUNT_ID, getAccountConfig, resolveDefaultTwitchAccountId } from "./config.js";
import {
DEFAULT_ACCOUNT_ID,
getAccountConfig,
listAccountIds,
resolveDefaultTwitchAccountId,
resolveTwitchAccountContext,
} from "./config.js";
import type { TwitchAccountConfig, TwitchRole } from "./types.js";
import { isAccountConfigured } from "./utils/twitch.js";
const channel = "twitch" as const;
const INVALID_ACCOUNT_ID_MESSAGE = "Invalid Twitch account id";
function normalizeRequestedSetupAccountId(accountId: string): string {
const normalized = normalizeOptionalAccountId(accountId);
if (!normalized) {
throw new Error(INVALID_ACCOUNT_ID_MESSAGE);
}
return normalized;
}
function resolveSetupAccountId(cfg: OpenClawConfig, requestedAccountId?: string): string {
const requested = requestedAccountId?.trim();
if (requested) {
return normalizeRequestedSetupAccountId(requested);
}
function resolveSetupAccountId(cfg: OpenClawConfig): string {
const preferred = cfg.channels?.twitch?.defaultAccount?.trim();
return preferred || resolveDefaultTwitchAccountId(cfg);
return preferred ? normalizeAccountId(preferred) : resolveDefaultTwitchAccountId(cfg);
}
export function setTwitchAccount(
@@ -26,7 +49,10 @@ export function setTwitchAccount(
account: Partial<TwitchAccountConfig>,
accountId: string = resolveSetupAccountId(cfg),
): OpenClawConfig {
const existing = getAccountConfig(cfg, accountId);
const resolvedAccountId = accountId.trim()
? normalizeRequestedSetupAccountId(accountId)
: resolveSetupAccountId(cfg);
const existing = getAccountConfig(cfg, resolvedAccountId);
const merged: TwitchAccountConfig = {
username: account.username ?? existing?.username ?? "",
accessToken: account.accessToken ?? existing?.accessToken ?? "",
@@ -55,7 +81,7 @@ export function setTwitchAccount(
...((
(cfg.channels as Record<string, unknown>)?.twitch as Record<string, unknown> | undefined
)?.accounts as Record<string, unknown> | undefined),
[accountId]: merged,
[resolvedAccountId]: merged,
},
},
},
@@ -192,7 +218,15 @@ export async function configureWithEnvToken(
envToken: string,
forceAllowFrom: boolean,
dmPolicy: ChannelSetupDmPolicy,
accountId: string = resolveSetupAccountId(cfg),
): Promise<{ cfg: OpenClawConfig } | null> {
const resolvedAccountId = accountId.trim()
? normalizeRequestedSetupAccountId(accountId)
: resolveSetupAccountId(cfg);
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,
@@ -204,15 +238,25 @@ export async function configureWithEnvToken(
const username = await promptUsername(prompter, account);
const clientId = await promptClientId(prompter, account);
const cfgWithAccount = setTwitchAccount(cfg, {
username,
clientId,
accessToken: "",
enabled: true,
});
const cfgWithAccount = setTwitchAccount(
cfg,
{
username,
clientId,
accessToken: "",
enabled: true,
},
resolvedAccountId,
);
if (forceAllowFrom && dmPolicy.promptAllowFrom) {
return { cfg: await dmPolicy.promptAllowFrom({ cfg: cfgWithAccount, prompter }) };
return {
cfg: await dmPolicy.promptAllowFrom({
cfg: cfgWithAccount,
prompter,
accountId: resolvedAccountId,
}),
};
}
return { cfg: cfgWithAccount };
@@ -222,9 +266,10 @@ function setTwitchAccessControl(
cfg: OpenClawConfig,
allowedRoles: TwitchRole[],
requireMention: boolean,
accountId?: string,
): OpenClawConfig {
const accountId = resolveSetupAccountId(cfg);
const account = getAccountConfig(cfg, accountId);
const resolvedAccountId = resolveSetupAccountId(cfg, accountId);
const account = getAccountConfig(cfg, resolvedAccountId);
if (!account) {
return cfg;
}
@@ -236,12 +281,15 @@ function setTwitchAccessControl(
allowedRoles,
requireMention,
},
accountId,
resolvedAccountId,
);
}
function resolveTwitchGroupPolicy(cfg: OpenClawConfig): "open" | "allowlist" | "disabled" {
const account = getAccountConfig(cfg, resolveSetupAccountId(cfg));
function resolveTwitchGroupPolicy(
cfg: OpenClawConfig,
accountId?: string,
): "open" | "allowlist" | "disabled" {
const account = getAccountConfig(cfg, resolveSetupAccountId(cfg, accountId));
if (account?.allowedRoles?.includes("all")) {
return "open";
}
@@ -254,19 +302,27 @@ function resolveTwitchGroupPolicy(cfg: OpenClawConfig): "open" | "allowlist" | "
function setTwitchGroupPolicy(
cfg: OpenClawConfig,
policy: "open" | "allowlist" | "disabled",
accountId?: string,
): OpenClawConfig {
const allowedRoles: TwitchRole[] =
policy === "open" ? ["all"] : policy === "allowlist" ? ["moderator", "vip"] : [];
return setTwitchAccessControl(cfg, allowedRoles, true);
return setTwitchAccessControl(cfg, allowedRoles, true, accountId);
}
const twitchDmPolicy: ChannelSetupDmPolicy = {
label: "Twitch",
channel,
policyKey: "channels.twitch.allowedRoles",
allowFromKey: "channels.twitch.accounts.<default>.allowFrom",
getCurrent: (cfg) => {
const account = getAccountConfig(cfg, resolveSetupAccountId(cfg));
policyKey: "channels.twitch.accounts.default.allowedRoles",
allowFromKey: "channels.twitch.accounts.default.allowFrom",
resolveConfigKeys: (cfg, accountId) => {
const resolvedAccountId = resolveSetupAccountId(cfg, accountId);
return {
policyKey: `channels.twitch.accounts.${resolvedAccountId}.allowedRoles`,
allowFromKey: `channels.twitch.accounts.${resolvedAccountId}.allowFrom`,
};
},
getCurrent: (cfg, accountId) => {
const account = getAccountConfig(cfg, resolveSetupAccountId(cfg, accountId));
if (account?.allowedRoles?.includes("all")) {
return "open";
}
@@ -275,14 +331,14 @@ const twitchDmPolicy: ChannelSetupDmPolicy = {
}
return "disabled";
},
setPolicy: (cfg, policy) => {
setPolicy: (cfg, policy, accountId) => {
const allowedRoles: TwitchRole[] =
policy === "open" ? ["all"] : policy === "allowlist" ? [] : ["moderator"];
return setTwitchAccessControl(cfg, allowedRoles, true);
return setTwitchAccessControl(cfg, allowedRoles, true, accountId);
},
promptAllowFrom: async ({ cfg, prompter }) => {
const accountId = resolveSetupAccountId(cfg);
const account = getAccountConfig(cfg, accountId);
promptAllowFrom: async ({ cfg, prompter, accountId }) => {
const resolvedAccountId = resolveSetupAccountId(cfg, accountId);
const account = getAccountConfig(cfg, resolvedAccountId);
const existingAllowFrom = account?.allowFrom ?? [];
const entry = await prompter.text({
@@ -302,7 +358,7 @@ const twitchDmPolicy: ChannelSetupDmPolicy = {
...(account ?? undefined),
allowFrom,
},
accountId,
resolvedAccountId,
);
},
};
@@ -311,16 +367,16 @@ const twitchGroupAccess: NonNullable<ChannelSetupWizard["groupAccess"]> = {
label: "Twitch chat",
placeholder: "",
skipAllowlistEntries: true,
currentPolicy: ({ cfg }) => resolveTwitchGroupPolicy(cfg),
currentEntries: ({ cfg }) => {
const account = getAccountConfig(cfg, resolveSetupAccountId(cfg));
currentPolicy: ({ cfg, accountId }) => resolveTwitchGroupPolicy(cfg, accountId),
currentEntries: ({ cfg, accountId }) => {
const account = getAccountConfig(cfg, resolveSetupAccountId(cfg, accountId));
return account?.allowFrom ?? [];
},
updatePrompt: ({ cfg }) => {
const account = getAccountConfig(cfg, resolveSetupAccountId(cfg));
updatePrompt: ({ cfg, accountId }) => {
const account = getAccountConfig(cfg, resolveSetupAccountId(cfg, accountId));
return Boolean(account?.allowedRoles?.length || account?.allowFrom?.length);
},
setPolicy: ({ cfg, policy }) => setTwitchGroupPolicy(cfg, policy),
setPolicy: ({ cfg, accountId, policy }) => setTwitchGroupPolicy(cfg, policy, accountId),
resolveAllowlist: async () => [],
applyAllowlist: ({ cfg }) => cfg,
};
@@ -339,29 +395,28 @@ export const twitchSetupAdapter: ChannelSetupAdapter = {
export const twitchSetupWizard: ChannelSetupWizard = {
channel,
resolveAccountIdForConfigure: ({ defaultAccountId }) => defaultAccountId,
resolveAccountIdForConfigure: ({ cfg, accountOverride }) =>
resolveSetupAccountId(cfg, accountOverride),
resolveShouldPromptAccountIds: () => false,
status: {
configuredLabel: "configured",
unconfiguredLabel: "needs username, token, and clientId",
configuredHint: "configured",
unconfiguredHint: "needs setup",
resolveConfigured: ({ cfg }) => {
const account = getAccountConfig(cfg, resolveSetupAccountId(cfg));
return account ? isAccountConfigured(account) : false;
resolveConfigured: ({ cfg, accountId }) => {
return resolveTwitchAccountContext(cfg, resolveSetupAccountId(cfg, accountId)).configured;
},
resolveStatusLines: ({ cfg }) => {
const accountId = resolveSetupAccountId(cfg);
const account = getAccountConfig(cfg, accountId);
const configured = account ? isAccountConfigured(account) : false;
resolveStatusLines: ({ cfg, accountId }) => {
const resolvedAccountId = resolveSetupAccountId(cfg, accountId);
const configured = resolveTwitchAccountContext(cfg, resolvedAccountId).configured;
return [
`Twitch${accountId !== DEFAULT_ACCOUNT_ID ? ` (${accountId})` : ""}: ${configured ? "configured" : "needs username, token, and clientId"}`,
`Twitch${resolvedAccountId !== DEFAULT_ACCOUNT_ID ? ` (${resolvedAccountId})` : ""}: ${configured ? "configured" : "needs username, token, and clientId"}`,
];
},
},
credentials: [],
finalize: async ({ cfg, prompter, forceAllowFrom }) => {
const accountId = resolveSetupAccountId(cfg);
finalize: async ({ cfg, accountId: requestedAccountId, prompter, forceAllowFrom }) => {
const accountId = resolveSetupAccountId(cfg, requestedAccountId);
const account = getAccountConfig(cfg, accountId);
if (!account || !isAccountConfigured(account)) {
@@ -370,7 +425,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,
@@ -378,6 +433,7 @@ export const twitchSetupWizard: ChannelSetupWizard = {
envToken,
forceAllowFrom,
twitchDmPolicy,
accountId,
);
if (envResult) {
return envResult;
@@ -406,7 +462,7 @@ export const twitchSetupWizard: ChannelSetupWizard = {
const cfgWithAllowFrom =
forceAllowFrom && twitchDmPolicy.promptAllowFrom
? await twitchDmPolicy.promptAllowFrom({ cfg: cfgWithAccount, prompter })
? await twitchDmPolicy.promptAllowFrom({ cfg: cfgWithAccount, prompter, accountId })
: cfgWithAccount;
return { cfg: cfgWithAllowFrom };
@@ -426,3 +482,39 @@ export const twitchSetupWizard: ChannelSetupWizard = {
};
},
};
type ResolvedTwitchAccount = TwitchAccountConfig & { accountId?: string | null };
export const twitchSetupPlugin: ChannelPlugin<ResolvedTwitchAccount> = {
id: channel,
meta: getChatChannelMeta(channel),
capabilities: {
chatTypes: ["group"],
},
config: {
listAccountIds: (cfg) => listAccountIds(cfg),
resolveAccount: (cfg, accountId) => {
const resolvedAccountId = normalizeAccountId(accountId ?? resolveDefaultTwitchAccountId(cfg));
const account = getAccountConfig(cfg, resolvedAccountId);
if (!account) {
return {
accountId: resolvedAccountId,
username: "",
accessToken: "",
clientId: "",
channel: "",
enabled: false,
};
}
return {
accountId: resolvedAccountId,
...account,
};
},
defaultAccountId: (cfg) => resolveDefaultTwitchAccountId(cfg),
isConfigured: (account, cfg) => resolveTwitchAccountContext(cfg, account?.accountId).configured,
isEnabled: (account) => account.enabled !== false,
},
setup: twitchSetupAdapter,
setupWizard: twitchSetupWizard,
};

View File

@@ -65,6 +65,27 @@ describe("token", () => {
expect(result.source).toBe("config");
});
it("should resolve token from normalized account id", () => {
const result = resolveTwitchToken(
{
channels: {
twitch: {
accounts: {
Secondary: {
username: "secondary",
accessToken: "oauth:secondary-token",
},
},
},
},
} as unknown as OpenClawConfig,
{ accountId: "secondary" },
);
expect(result.token).toBe("oauth:secondary-token");
expect(result.source).toBe("config");
});
it("should prioritize config token over env var (simplified config)", () => {
process.env.OPENCLAW_TWITCH_ACCESS_TOKEN = "oauth:env-token";

View File

@@ -9,8 +9,12 @@
* 2. Environment variable: OPENCLAW_TWITCH_ACCESS_TOKEN (default account only)
*/
import {
DEFAULT_ACCOUNT_ID,
normalizeAccountId,
resolveNormalizedAccountEntry,
} from "openclaw/plugin-sdk/account-resolution";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/routing";
export type TwitchTokenSource = "env" | "config" | "none";
@@ -56,10 +60,8 @@ export function resolveTwitchToken(
// Get merged account config (handles both simplified and multi-account patterns)
const twitchCfg = cfg?.channels?.twitch;
const accountCfg =
accountId === DEFAULT_ACCOUNT_ID
? (twitchCfg?.accounts?.[DEFAULT_ACCOUNT_ID] as Record<string, unknown> | undefined)
: (twitchCfg?.accounts?.[accountId] as Record<string, unknown> | undefined);
const accounts = twitchCfg?.accounts as Record<string, Record<string, unknown>> | undefined;
const accountCfg = resolveNormalizedAccountEntry(accounts, accountId, normalizeAccountId);
// For default account, also check base-level config
let token: string | undefined;