mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 05:30:42 +00:00
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:
committed by
GitHub
parent
bbac7773ff
commit
6184f17c91
@@ -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
|
||||
|
||||
|
||||
20
extensions/twitch/index.test.ts
Normal file
20
extensions/twitch/index.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -15,6 +15,7 @@
|
||||
"extensions": [
|
||||
"./index.ts"
|
||||
],
|
||||
"setupEntry": "./setup-entry.ts",
|
||||
"install": {
|
||||
"minHostVersion": ">=2026.4.10"
|
||||
},
|
||||
|
||||
9
extensions/twitch/setup-entry.ts
Normal file
9
extensions/twitch/setup-entry.ts
Normal 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",
|
||||
},
|
||||
});
|
||||
3
extensions/twitch/setup-plugin-api.ts
Normal file
3
extensions/twitch/setup-plugin-api.ts
Normal 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";
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user